Backend Deep Dive #1: Kafka는 왜 유실되지 않았을까? CAP 이론으로 본 메시지 시스템 구조

backendcapmessage-queueidempotence

Backend Deep Dive.
그 첫 번째, 오늘은 분산 시스템을 지탱하는 두 개의 큰 기둥, CAP 이론과 메시지 큐에 대해 알아보자.

1. 분산 시스템의 나침반, CAP 이론

왜 분산 환경을 고민해야 하는가?

하나의 서버로 모든 것을 처리하는 모놀리식(Monolithic) 구조는 상대적으로 단순하다.
하지만 다음과 같은 경우에, 우리는 분산 환경을 고려하게 된다.

  • 처음부터 높은 가용성이 필요한 경우
  • 여러 기능이 독립적으로 확장되어야 하는 경우
  • 서비스가 성장하여 단일 서버로 감당 불가한 경우

이렇게 여러 대의 서버를 운영하게 되는 순간, 우리는 단일 서버 환경에서는 겪지 못했던 새로운 질문들과 마주하게 된다.

  • "여러 서버의 데이터를 어떻게 일관되게 유지할까?"
  • "한 대의 서버가 고장 나도 시스템은 계속 동작해야 하지 않을까?"

CAP 이론은 바로 이런 분산 환경에서 우리가 어떤 선택을 해야 하는지 알려주는 일종의 나침반이다.

CAP: 세 가지 속성

CAP은 다음 세 가지 속성의 앞 글자를 딴 용어이다.

  • C - 일관성 (Consistency)

    • 모든 사용자가 언제, 어느 서버에 접속하든 동일한 최신 데이터를 봐야 한다.
    • 예시: 은행 계좌 잔액 (1원의 오차도 허용되지 않음)
  • A - 가용성 (Availability)

    • 시스템의 일부에 문제가 생겨도, 전체 시스템은 항상 응답해야 한다.
    • 예시: 소셜 미디어 피드 (조금 느려도 멈추는 것보단 낫다)
  • P - 분할 허용성 (Partition Tolerance)

    • 네트워크 장애가 발생해도 시스템은 계속 동작해야 한다.
    • 현실적으로 분산 시스템에서는 반드시 고려해야 할 요소이다.

현대 클라우드 환경에서 네트워크 문제는 언제든 발생할 수 있는 상수이다. 따라서 P는 선택이 아닌 필수이다.

결국 남는 선택: C vs A

그렇다면 이 CAP 이론은 어디까지 적용될까? 정답은 '데이터를 여러 곳에 복제하여 저장하는 모든 분산 환경'이다. 데이터베이스나 메시지 큐처럼 데이터 저장이 핵심인 시스템을 선택할 때는 이 이론이 직접적이고 절대적인 기준이 된다.

P가 필수라면, 우리는 결국 선택의 기로에 놓인다.

"네트워크에 문제가 생겼을 때(P), 데이터의 완벽한 일치(C)와 중단 없는 서비스(A) 중 무엇을 더 우선할 것인가?"

  • CP (일관성 우선, Consistency > Availability)

    • "틀린 데이터를 보여줄 바엔 응답하지 않겠다!"
    • 예시: 은행, 증권 시스템 등 데이터 정합성이 중요한 서비스
  • AP (가용성 우선, Availability > Consistency)

    • "일단 보여주고 나중에 맞추자!"
    • 예시: SNS, 웹 서비스 등 사용자 경험이 중요한 서비스

분산 시스템의 마법 주문: 멱등성 (Idempotence)

분산 환경의 가장 큰 적은 '불확실성'이다. "요청을 보냈는데, 성공했을까? 실패했을까? 응답이 없네..." 이런 불확실한 상황에서 가장 단순한 해결책은 '다시 시도(Retry)'이다. 하지만 결제 요청을 두 번 보내면 어떻게 될까? 이중 결제가 발생할 수 있다.

여기서 멱등성(Idempotence)이라는 마법 주문이 등장한다. 이게 왜 구현 난이도를 낮출까? '불확실성'을 제거해주기 때문이다.

분산 환경에서는 실패와 재시도가 빈번하다. 이때 중요한 것은 같은 요청이 여러 번 실행돼도 결과가 한 번만 적용되는 것이다.

  • 멱등성이란?

    • 같은 요청을 1번 보내든, 10번 보내든 결과는 동일해야 함
    • 예시: 결제 요청에 고유 ID를 부여 → 서버가 해당 ID를 기억하고 중복 요청 차단
  • 이점:

    • 재시도 로직이 단순해짐
    • 안정성 및 신뢰도 향상
    • 개발자가 안심하고 실패 상황을 다룰 수 있음

2. 데이터 유실의 미스터리: RabbitMQ vs. Kafka

문제의 발단

이러한 선택의 문제는 비단 데이터베이스에만 국한되지 않는다. 서비스의 각 부분들이 서로 대화하는 방식, 즉 '메시지 큐'를 선택할 때도 똑같이 적용된다.

최근 우리 팀의 실제 사례를 통해 이 차이를 극명하게 느낄 수 있었다. RabbitMQ를 사용한 한 프로덕트(이하 A 프로덕트)는 종종 데이터 유실이 발생했지만, Kafka를 사용한 다른 프로덕트(이하 B 프로덕트)는 데이터 유실이 전혀 없었다.
단순히 기술의 좋고 나쁨 문제였을까? 사실 A 프로덕트가 초기에 RabbitMQ를 선택했던 데에는 운영 및 관리의 용이성이라는 현실적인 이유가 있었다. 상대적으로 구조가 복잡한 Kafka에 비해, RabbitMQ는 가용 인력으로 충분히 관리할 수 있다고 판단했던 것이다.

이 선택의 차이는 두 시스템의 근본적인 철학 차이에서 비롯된다.
바로 스마트 브로커(Smart Broker)와 스마트 컨슈머(Smart Consumer) 모델의 차이이다.

RabbitMQ: Smart Broker

RabbitMQ는 '브로커(Broker, 메시지 중개인)'가 매우 똑똑하다.
브로커는 메시지를 받아서 어떤 소비자에게 전달할지 라우팅하고, 소비자의 상태를 모니터링하며, 메시지를 소비자에게 직접 전달(Push)해주는 것까지 책임진다.

다이어그램을 로딩 중...

하지만 이 책임감 때문에 문제가 생길 수 있다.
브로커는 소비자에게 메시지를 전달한 뒤 "처리 완료했니?"라고 묻고, 소비자가 "응!"이라는 확인증(Ack)을 보내주면 비로소 메시지를 삭제한다. 만약 소비자가 성급하게 확인증부터 보내고 실제 처리에 실패하면, 똑똑한 브로커는 이미 임무가 끝난 줄 알고 메시지를 지워버린다. 데이터 유실은 바로 이 지점에서, 개발자의 'Ack 처리 시점' 선택에 따라 발생하게 된다.

  • 브로커(Broker)가 모든 걸 책임짐
  • 메시지를 받아서 → 소비자에게 Push → 처리 완료 여부를 Ack로 확인
  • 소비자가 Ack를 먼저 보내고 처리에 실패하면? → 메시지 삭제됨 → 데이터 유실

Kafka: Dumb Broker, Smart Consumer

반면 Kafka의 브로커는 상대적으로 멍청하다. 브로커의 유일한 임무는 들어오는 메시지를 주제(Topic)별 로그(Log)에 순서대로 기록하고 보관하는 것이다. 누구에게 메시지를 전달하거나 처리 여부를 확인하는 책임이 없다.

다이어그램을 로딩 중...
  • 브로커는 단순히 메시지를 기록(Log)하고 보관
  • 소비자(Consumer)가 직접 메시지를 Pull
  • 소비자가 직접 Offset(어디까지 읽었는지)를 관리함
  • 소비자가 잠시 죽어도 → 이전 위치부터 다시 읽기 가능
  • 브로커는 메시지를 자동으로 삭제하지 않음 (보존 기간까지 유지)

Kafka의 메시지는 기본 보존 정책(log.retention.ms)에 따라 eventual하게 삭제되긴 하지만, 이는 유실이 아닌 보관 정책이다.

결론: 책임의 주체 차이

항목RabbitMQKafka
모델Smart BrokerSmart Consumer
메시지 전달PushPull
메시지 삭제Ack 수신 후 삭제보존 주기 설정 시 삭제
유실 위험소비자의 처리 실패 시 유실 가능Offset 기반으로 안전

마치며

기술 선택에는 장단점이 명확하며, RabbitMQ가 제공하는 관리의 편리함이 때로는 Kafka의 데이터 보장 능력보다 더 유리한 선택일 수 있다. 중요한 것은 각 시스템의 철학을 이해하고 우리 팀의 상황에 맞는 최적의 선택을 하는 것이다.

오늘은 분산 시스템을 설계할 때 가장 먼저 고려해야 할 CAP 이론과, 그 이론이 실제 메시지 큐 선택에 어떻게 영향을 미치는지 살펴보았다. 이런 기본 원리들을 이해하는 것이 탄탄한 백엔드 시스템을 구축하는 첫걸음이라 생각한다.

(참고: 본문에 언급된 RabbitMQ와 Kafka의 동작 방식 차이는 아래 코드 예제를 통해 더 명확하게 이해할 수 있다.)

// 이 코드는 개념 이해를 돕기 위한 의사 코드(Pseudo-code)에 가까우며,
// 실제 실행을 위해서는 RabbitMQ, Kafka 클라이언트 라이브러리 의존성을 추가하고
// 각 메시지 큐 서버가 실행 중이어야 한다. (예: build.gradle.kts)
// implementation("com.rabbitmq:amqp-client:5.14.2")
// implementation("org.apache.kafka:kafka-clients:3.1.0")
 
import com.rabbitmq.client.AMQP
import com.rabbitmq.client.ConnectionFactory
import com.rabbitmq.client.DefaultConsumer
import com.rabbitmq.client.Envelope
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.consumer.KafkaConsumer
import java.time.Duration
import java.util.*
 
// --- RabbitMQ 소비자 예제 ---
// '스마트 브로커'와 함께 동작하는 소비자
// 개발자가 직접 Ack/Nack를 제어해야 하는 책임을 가진다.
 
object RabbitMQConsumer {
    private const val QUEUE_NAME = "my_queue"
 
    fun run() {
        val factory = ConnectionFactory()
        factory.host = "localhost"
        val connection = factory.newConnection()
        val channel = connection.createChannel()
 
        channel.queueDeclare(QUEUE_NAME, false, false, false, null)
        println(" [*] 메시지를 기다리는 중. 종료하려면 프로그램을 중단하세요.")
 
        // autoAck=false로 설정하여 수동으로 Ack를 보내도록 설정
        // 이것이 '책임'의 시작점.
        val autoAck = false
 
        val consumer = object : DefaultConsumer(channel) {
            override fun handleDelivery(
                consumerTag: String?,
                envelope: Envelope,
                properties: AMQP.BasicProperties?,
                body: ByteArray
            ) {
                val message = String(body, Charsets.UTF_8)
                println(" [x] 메시지 수신: '$message'")
 
                try {
                    // 일부러 짝수 메시지는 실패하는 상황을 시뮬레이션
                    if (message.toInt() % 2 == 0) {
                        throw RuntimeException("처리 실패: 짝수 메시지")
                    }
                    // 실제 비즈니스 로직 수행
                    Thread.sleep(1000)
                    println(" [v] 메시지 처리 성공: '$message'")
 
                    // 1. 처리 성공 시: 브로커에게 '처리 완료' 확인증(Ack)을 보낸다.
                    // 이 신호를 받은 브로커는 큐에서 메시지를 완전히 삭제한다.
                    channel.basicAck(envelope.deliveryTag, false)
 
                } catch (e: Exception) {
                    println(" [!] ${e.message}")
                    // 2. 처리 실패 시: 브로커에게 '처리 실패' 신호(Nack)를 보낸다.
                    // requeue=true로 설정하면 메시지가 다시 큐로 돌아가 다른 소비자에게 전달된다.
                    channel.basicNack(envelope.deliveryTag, false, true)
                }
            }
        }
        channel.basicConsume(QUEUE_NAME, autoAck, consumer)
    }
}
 
// --- Kafka 소비자 예제 ---
// '멍청한 브로커'와 함께 동작하는 '스마트 컨슈머'
// 소비자가 스스로 어디까지 읽었는지(offset)를 관리한다.
 
object KafkaConsumer {
    private const val TOPIC_NAME = "my_topic"
    private const val GROUP_ID = "my_group"
 
    fun run() {
        val props = Properties()
        props[ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG] = "localhost:9092"
        props[ConsumerConfig.GROUP_ID_CONFIG] = GROUP_ID
        props[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = "org.apache.kafka.common.serialization.StringDeserializer"
        props[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = "org.apache.kafka.common.serialization.StringDeserializer"
        // enable.auto.commit=true가 기본값이며, 주기적으로 오프셋을 자동으로 커밋합니다.
        // 더 정교한 제어를 위해 false로 두고 수동으로 커밋할 수도 있습니다.
        props[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = "true"
        props[ConsumerConfig.AUTO_OFFSET_RESET_CONFIG] = "earliest"
 
        val consumer = KafkaConsumer<String, String>(props)
        consumer.subscribe(listOf(TOPIC_NAME))
 
        println("[*] Kafka에서 메시지를 기다리는 중...")
 
        try {
            while (true) {
                // 소비자는 그저 루프를 돌며 메시지를 가져와(poll) 처리하기만 하면 된다.
                val records = consumer.poll(Duration.ofMillis(100))
                for (record in records) {
                    println(
                        "토픽: ${record.topic()}, 파티션: ${record.partition()}, " +
                        "오프셋: ${record.offset()}, 키: ${record.key()}, 값: ${record.value()}"
                    )
                    // 실제 비즈니스 로직 처리
                    // 만약 여기서 에러가 발생하여 소비자가 중단되더라도,
                    // 오프셋이 커밋되지 않았기 때문에 재시작 시 이 메시지부터 다시 처리한다.
                    // 데이터 유실이 발생하지 않는다.
                }
            }
        } catch (e: Exception) {
            println("소비자 에러: ${e.message}")
        } finally {
            consumer.close()
            println("소비자 종료")
        }
    }
}
 
fun main() {
    // 실행하려는 예제의 주석을 해제.
    // RabbitMQConsumer.run()
    // KafkaConsumer.run()
}
 

📚 참고 링크