Backend Deep Dive #1: Kafka는 왜 유실되지 않았을까? CAP 이론으로 본 메시지 시스템 구조
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하게 삭제되긴 하지만, 이는 유실이 아닌 보관 정책이다.
결론: 책임의 주체 차이
항목 | RabbitMQ | Kafka |
---|---|---|
모델 | Smart Broker | Smart Consumer |
메시지 전달 | Push | Pull |
메시지 삭제 | 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()
}