서비스 개발자를 위한 카프카 (4)
-
1. 아파치 카프카란? 기본 개념 및 철학
\*본 시리즈는 필자가 Confluent의 문서들과 강의 한번에 끝내는 Kafka Ecosystem, 이외의 여러 자료들을 통해 학습한 내용을 기반으로, 응용 서비스 개발자들이 카프카를 사용하는데 필요한 개념과 코드들을 컴팩트하게 모아 정리하는 것을 취지로 합니다. 따라 인프라적인 내용이나 DevOps 관점에서의 깊이 있는 탐구는 다루지 않습니다. 시작하며: 왜 Kafka인가? Spaghetti Code 현대의 소프트웨어는 점점 더 복잡해지고, 비즈니스가 성장함에 따라 관여하는 이해관계자와 기능의 수도 늘어납니다. 자연스럽게 서비스의 개수는 많아지고, 각 서비스는 서로를 호출하며 거미줄처럼 얽히게 됩니다. 이러한 강한 결합 구조는 하나의 작은 장애가 시스템 전체로 번지는 연쇄 장애를 일으키기 쉽고, 기술 부채가 쌓이며 새로운 기능을 추가하거나 변경하는 것을 극도로 어렵게 만듭니다. 이러한 복잡성을 제어하기 위해 아키텍처 설계 역사에서는 오래전부터 시스템 간의 의존성을 끊어내 느슨한 결합(Loose Coupling) 을 만들려는 노력이 계속되어 왔고, 그 중심에는 메시지 큐(Message Queue) 를 활용한 디자인 패턴이 있었습니다. 메시지 큐는 중간에서 메시지를 보관했다가 전달하는 버퍼 역할을 함으로써, 생산자(Producer)와 소비자(Consumer) 패턴으로 시스템을 분리하여 시스템 전체의 안정성을 높이는 효과적인 해법이었습니다. Java Producer - Consumer 패턴 구조 하지만 데이터가 폭발적으로 많아지는 오늘날, 기존 메시지 큐만으로는 한계가 명확해졌습니다. 데이터 유실 문제: 장애가 발생했을 때 중요한 메시지가 그대로 사라져 버릴 위험이 있었습니다. 어려운 병렬 처리: 처리할 메시지가 많아져서 컨슈머 서버를 여러 대 늘리면, 같은 메시지를 여러 서버가 중복으로 처리하는 문제가 생겼습니다. 기존 메시지 큐에는 '컨슈머 그룹' 같은 개념이 없어 누가 어떤 메시지를 처리할지 똑똑하게 관리해주지 못했기 때문입니다. 이럴 때 주요하게 사용되는 기술이 아파치 카프카(Apache Kafka) 입니다. 카프카 공식 문서는 스스로를 이렇게 소개합니다. "아파치 카프카는 고성능 데이터 파이프라인, 스트리밍 분석, 데이터 통합 및 미션 크리티컬 애플리케이션을 위해 수천 개의 회사에서 사용하는 오픈소스 분산 이벤트 스트리밍 플랫폼입니다."출처:Apache Kafka 공식 홈페이지 이 '이벤트 스트리밍 플랫폼'이라는 철학 덕분에, 카프카는 데이터를 절대 유실하지 않는 영속성을 보장하고, '컨슈머 그룹' 기능을 통해 여러 대의 컨슈머가 중복 없이 메시지를 나눠 처리하는 완벽한 병렬 처리를 가능하게 해줍니다. 카프카 핵심 개념과 아키텍처 분산 커밋 로그 카프카를 이해하는 가장 중요한 키워드는 분산 커밋 로그(Distributed Commit Log) 입니다. 말이 좀 어려운데, 사실 여러분이 자주 쓰는 데이터베이스의 트랜잭션 로그와 똑같은 개념이라고 생각하면 쉽습니다. 데이터베이스가 모든 변경 사항(INSERT, UPDATE, DELETE)을 안전한 로그 파일에 순서대로 착착 쌓아두는 것처럼, 카프카도 모든 데이터(이벤트)를 순서가 보장된, 절대 바뀌지 않는(Immutable) 로그의 형태로 디스크에 차곡차곡 기록(Append-only)합니다. 이런 단순한 구조 덕분에 디스크에 데이터를 쓰는데도 엄청난 처리량을 보여주고, 데이터의 영속성까지 확실히 보장하는 거죠 이렇게 데이터를 로그 형태로 쌓기 때문에, 일반적인 메시지 큐와 다르게 데이터를 소비해도 바로 삭제되지 않습니다. (물론, 설정에 따라 오래된 데이터는 주기적으로 지워집니다.) 그럼 데이터를 소비하는 쪽에서는 어떻게 자기가 어디까지 읽었는지 알 수 있을까요? 컨슈머 (Consumer) 와 오프셋 (Offset) 바로 여기서 컨슈머(Consumer) 가 등장합니다. 컨슈머는 토픽에서 데이터를 가져와 사용하는 클라이언트 애플리케이션입니다. 우리가 띄운 스프링 서버 인스턴스 1개가 하나의 컨슈머가 될 수 있죠. 중요한 점은, 카프카는 컨슈머가 데이터를 얼마나 가져갔는지 전혀 신경 쓰지 않는다는 겁니다. Consumer Group Protocol: Scalability and Fault Tolerance 대신 컨슈머 스스로가 "아, 나 여기까지 읽었어!" 하고 자신이 읽은 데이터의 위치를 '오프셋(Offset)'이라는 지표로 기록하고 기억합니다. 덕분에 여러 다른 컨슈머들이 각자 다른 위치에서 데이터를 읽어갈 수 있고, 문제가 생기면 특정 오프셋으로 돌아가 데이터를 다시 읽어오는 것도 가능해지죠. 컨슈머들은 보통 '컨슈머 그룹' 이라는 팀 단위로 움직이는데, 하나의 팀에 속한 컨슈머들은 데이터를 중복 없이 나눠서 병렬로 처리하는 역할을 맡습니다. 컨슈머 그룹 활용 이 '컨슈머 그룹' 기능이 왜 그렇게 유용한지 마이크로서비스 환경을 예로 생각하면 알 수 있습니다. 보통 하나의 서비스도 안정성을 위해 여러 개의 서버 인스턴스로 띄워두죠. 이때 order-created 같은 이벤트가 발생하면, '결제 서비스'도 처리해야 하고 '알림 서비스'도 처리해야 합니다. 여기서 중요한 건, 각 서비스별로 딱 한 번씩만 처리되어야 한다는 점이죠. 기존 메시지 큐 방식이라면 이게 꽤나 머리 아픕니다. 모든 인스턴스에 메시지를 다 보내면(Broadcast), '결제 서비스' 서버 3대가 전부 똑같은 결제를 3번 시도하는 대참사가 일어나겠죠. 이걸 막으려면 서비스 내부에서 자체적으로 리더를 뽑아서 '대표' 인스턴스 하나만 일하도록 복잡한 로직을 또 짜야만 했습니다. 하지만 카프카는 이걸 '컨슈머 그룹' 하나로 깔끔하게 해결합니다. 그냥 '결제 서비스' 전체를 하나의 컨슈머 그룹으로, '알림 서비스' 전체를 또 다른 컨슈머 그룹으로 묶어주기만 하면 끝입니다. 카프카가 알아서 각 그룹에게 메시지를 한 번씩 전달하고, 그룹 내에서는 단 하나의 인스턴스만 그 메시지를 처리하도록 분배해주거든요. 골치 아픈 리더 선출 같은 걸 전혀 고민할 필요가 없어 매우 편하죠. 프로듀서 (Producer) 지금까지 데이터를 '소비'하는 컨슈머에 대해 알아봤으니, 이제 반대로 이 데이터를 '생산'하는 주체에 대해 알아볼 차례입니다. order-created 같은 이벤트는 대체 누가, 어떻게 만드는 걸까요? 바로 여기서 프로듀서(Producer) 가 등장합니다. 프로듀서는 카프카 토픽으로 데이터를 생성해서 보내는 모든 클라이언트 애플리케이션을 의미합니다. 예를 들어, 사용자가 주문을 완료했을 때 '주문 서비스'가 order-created 이벤트를 만들어서 카프카로 쏘는 역할을 한다면, 이 '주문 서비스'가 바로 프로듀서가 되는 거죠. 지금까지 설명한 프로듀서와 컨슈머가 바로 카프카 생태계에서 우리 WAS가 맡는 역할입니다. 프로듀서랑 컨슈머 쪽에서는 저희가 설정해야하는 값들이 많은데, 이를 위해서는 아래 토픽, 파티션 등 개념을 알아야 하기에 다음 포스트로 다뤄보겠습니다. 토픽 (Topic) 과 파티션 (Partition) 그렇다면 프로듀서가 발행하고, 컨슈머가 읽어오는 그 '커밋 로그'는 대체 어디에 쌓이는 걸까요? 그 데이터 저장소가 바로 토픽(Topic) 입니다. 토픽은 데이터를 구분하기 위한 카테고리 또는 채널 이름입니다. DB의 테이블처럼, 프로듀서는 order-events 같은 특정 토픽에 데이터를 보내고, 컨슈머는 이 토픽을 '구독'해서 데이터를 가져오는 거죠. 그런데 대용량 데이터가 하나의 토픽에 전부 쌓이면 너무 느려지지 않을까요? 그래서 카프카는 토픽을 여러 개의 작은 조각으로 나누는데, 이것이 바로 파티션(Partition) 입니다. Apache Kafka Data Access Semantics: Consumers and Membership | Confluent 파티션으로 토픽을 나누면, 여러 컨슈머가 각 파티션에 동시에 접근해 데이터를 병렬로 처리할 수 있습니다. 즉, 파티션의 개수가 곧 그 토픽의 최대 병렬 처리량이 됩니다. 브로커 (Broker) 와 클러스터 그럼 이 토픽과 파티션이라는 데이터 저장소는 실제로 어디에 위치할까요? 바로 브로커(Broker) 입니다. 출처 (https://newsletter.systemdesigncodex.com/p/introduction-to-kafka) 브로커는 카프카 클러스터를 구성하는 서버 한 대 한 대를 의미합니다. 반대로 클러스터는 브로커의 집합입니다. 우리가 만든 order-events 토픽을 3개의 파티션으로 쪼갰다고 상상해 보세요. 카프카는 부하를 분산하고 안정성을 높이기 위해 이 파티션들을 여러 브로커에 나눠서 저장합니다. 파티션 0은 브로커 1번 서버에 파티션 1은 브로커 2번 서버에 파티션 2는 브로커 3번 서버에 이렇게 토픽의 파티션들을 여러 브로커에 분산시키는 구조 덕분에, 하나의 서버에 트래픽이 몰리는 것을 막고 전체 처리량을 극대화할 수 있습니다. 리더 브로커 여기서 중요한 규칙이 하나 있습니다. 각 파티션마다 '책임자' 브로커가 한 명씩 정해지는데, 이 책임자가 바로 리더(Leader) 입니다. 특정 파티션에 대한 모든 데이터 읽기/쓰기 요청은 반드시 해당 파티션의 리더 브로커를 통해서만 이루어져야 합니다. 만약 아무 브로커나 파티션에 데이터를 쓸 수 있다면 데이터 순서가 뒤죽박죽 엉망이 되겠죠? 그래서 카프카는 각 파티션마다 단 하나의 리더를 두어 데이터의 정합성과 순서를 보장하는 것입니다.  브로커 실제 구성 예시 개념은 알겠는데, 그래서 실제로 어떻게 돌아가는지 감이 잘 안 오실 수 있습니다. 아주 구체적인 시나리오를 하나 설정해 봅시다. 카프카 클러스터: 3대의 서버로 구성 (브로커 1, 브로커 2, 브로커 3) 토픽: order-events 라는 이름의 토픽 파티션 개수: 2개 (파티션 0, 파티션 1) 복제 계수(Replication Factor): 3 (하나의 파티션은 원본 1개 + 복제본 2개, 총 3개가 존재) 이 설정은 "모든 파티션은 모든 브로커에 하나씩 존재하여, 어떤 서버가 다운돼도 데이터를 안전하게 지킨다"는 의미입니다.. step 1. 리더와 팔로워 자동 선출 클러스터가 구성되면, 카프카는 각 파티션의 '책임자'인 리더(Leader) 와 '백업'인 팔로워(Follower) 를 자동으로 분배합니다. 이때 중요한 점은 리더를 한 브로커에 몰아주지 않고 최대한 골고루 분산시킨다는 것입니다. 예를 들어, 아래와 같이 역할이 정해질 수 있습니다. | | 파티션 0 | 파티션 1 | | ----- | -------------- | -------------- | | 브로커 1 | 리더 (Leader) | 팔로워 (Follower) | | 브로커 2 | 팔로워 (Follower) | 리더 (Leader) | | 브로커 3 | 팔로워 (Follower) | 팔로워 (Follower) | 이렇게 되면 파티션 0에 대한 모든 읽기/쓰기 요청은 오직 브로커 1만 처리합니다. 파티션 1에 대한 모든 읽기/쓰기 요청은 오직 브로커 2만 처리합니다. 브로커 1이 파티션 0에 새로운 데이터를 받으면, 즉시 브로커 2와 브로커 3에 있는 파티션 0의 팔로워들에게 데이터를 복제해서 나눠줍니다. 브로커 2 역시 파티션 1의 리더로서 동일한 작업을 수행하죠. step 2. 장애 발생 시 리더 교체  만약 파티션 1의 리더인 브로커 2가 갑자기 다운되면 어떻게 될까요? 클러스터의 지휘자(주키퍼/KRaft)가 브로커 2의 장애를 즉시 감지합니다. 지휘자는 파티션 1의 팔로워였던 브로커 1과 브로커 3 중에서 새로운 리더를 선출합니다. (여기서는 브로커 3이 새로운 리더가 되었다고 가정해 봅시다.) 클러스터의 상태는 아래와 같이 실시간으로 변경됩니다. | | 파티션 0 | 파티션 1 | | ----- | -------------- | ------------------- | | 브로커 1 | 리더 (Leader) | 팔로워 (Follower) | | 브로커 2 | (서버 다운) | (서버 다운) | | 브로커 3 | 팔로워 (Follower) | 새로운 리더 (New Leader) | 이제부터 파티션 1에 데이터를 보내려던 프로듀서와 데이터를 읽으려던 컨슈머는 자동으로 새로운 리더인 브로커 3으로 접속하게 됩니다. 이 모든 과정이 수 초 내에 자동으로 이루어지기 때문에, 서비스 중단 없이 안정적으로 데이터를 계속 처리할 수 있는 것입니다. 여기서 인프라적으로 들어가면 ISR(In-Sync Replica)이나 EOS(Exactly-Once Semantics) 모드 등 동기화에 대한 처리가 있지만 시리즈 취지에 부합하지 않아서 넘어가겠습니다. 궁금하신 분들은 원리가 재밌으니 알아보셔도 좋을 거 같네요. 이처럼 카프카는 파티션 단위로 리더와 팔로워를 여러 브로커에 교차로 분산시키는 영리한 설계를 통해, 부하 분산과 고가용성(장애 대응 능력) 이라는 두 마리 토끼를 동시에 잡습니다. 카프카의 활용 사례 카프카의 활용사례에 대해서는 많은 아티클이 있지만, 지금까지 배운 이론의 활용을 볼 겸 흥미롭게 본 것들을 공유합니다. 토스 뱅크에서의 사례 원문: 은행 최초 코어뱅킹 MSA 전환기 (feat. 지금 이자 받기) 복잡한 테이블에 걸친 트랜잭션을 비동기로 전환해 성능 최적화를 한 사례입니다. "DB 쓰기가 지연됐을 때 고객 잔액에 실시간으로 문제가 생기는가?" 를 기준으로 즉시성이 보장되어야 하는 작업과, 세금 처리처럼 나중에 처리해도 괜찮은 작업을 구분했습니다. 카카오에서의 사례 ::iframe{src="https://youtu.be/7\_VdIFH6M6Q?si=EfXa7ri\_dkKBoCRO" width="560" height="315"} 재밌게 본 영상이라 공유합니다. 앞서 나온 EOS 등의 개념이나 구현법 등을 설명합니다.
2025년 08월 27일 -
2. Kafka Producer - ACKS 옵션과 ISR, 메시지 전송 신뢰도
지난 글에서는 프로듀서, 컨슈머, 브로커, 토픽, 파티션 등 카프카의 핵심 개념과 아키텍처에 대해 알아보았습니다. 프로듀서가 producer.send()를 통해 메시지를 발행하면, 해당 메시지는 토픽의 파티션으로 전송되고 컨슈머가 이를 가져가 처리하는 흐름을 이해했습니다. 그런데 여기서 한 가지 중요한 질문이 생깁니다. producer.send()를 호출하고 나면, 우리는 "메시지가 안전하게 전송되었다"라고 언제 확신할 수 있을까요? 프로듀서의 요청이 리더 브로커에 도달하자마자일까요? 아니면 모든 복제본에 저장이 완료되었을 때일까요? 바로 이 신뢰도의 수준을 애플리케이션의 요구사항에 맞게 정밀하게 제어하는 핵심 장치가 바로 프로듀서의 acks 옵션입니다. 이번 글에서는 acks 옵션과 이를 뒷받침하는 ISR(In-Sync Replicas) 개념을 통해 카프카가 어떻게 메시지 전송의 신뢰도를 보장하는지, 그리고 개발자로서 우리는 어떤 트레이드오프를 고려해야 하는지 알아보겠습니다. acks 옵션 acks는 "acknowledgments"의 약자로, 프로듀서가 보낸 메시지에 대해 브로커로부터 "잘 받았다"는 확인 응답을 얼마나 기다릴지 결정하는 설정 값입니다. 프로듀서 측에서 설정해주어야 합니다. 이 옵션은 크게 세 가지로 나뉩니다. 이미지 및 정보 출처 - Confluent acks=0 producer-acks-0 lower latency / no strong durability  프로듀서는 메시지를 브로커로 전송하고, 어떠한 확인 응답도 기다리지 않습니다. (fire and forget) 그냥 네트워크 버퍼에 메시지를 쓰는 순간 "전송 성공"으로 간주합니다. 낮은 지연시간으로 인해 높은 처리량을 보장하지만, 메시지 유실 위험이 매우 높습니다. 일부가 유실되어도 전체 시스템에 큰 영향이 없는 데이터 수집 (로그, 메트릭 수집 등)에 적합합니다. 데이터 유실: 브로커가 메시지를 받기 전에 프로듀서에 장애가 발생하거나, 네트워크 문제, 브로커가 다운되는 경우  메시지 중복: 발생 X acks=1 producer-acks-1 a little higher latency / a little bit better durability  프로듀서는 메시지를 보낸 후, 파티션의 리더 브로커가 자신의 로컬 디스크에 메시지를 기록했다는 확인 응답을 받을 때까지 기다립니다. 보통의 카프카 인프라에선 레플리카 옵션으로 리더 브로커의 데이터를 팔로워에서 주기적으로 복제해 리더 인스턴스의 장애에 대응합니다. 이 옵션은 프로듀서가 리더 브로커한테만 메시지 확인 유무에 대한 응답을 기다립니다. 만약 설정한 시간동안 응답이 오지 않으면, 메시지를 재발행합니다. 리더 브로커에 확인을 받는 만큼, 지연시간이 조금 더 생기지만, 그만큼 유실 위험성을 낮출 수 있습니다. 데이터 유실: 리더 파티션이 메시지를 받은 후 응답을 보냈으나, 팔로워 파티션들이 복제하기 전에 리더가 다운 메시지 중복: 프로듀서가 리더로부터 응답을 받지 못해 메시지를 재전송할 경우 ex) 리더가 메시지를 받았지만 ack 응답을 보내기 전에 장애가 나, 프로듀서가 메시지가 전송되지 않았다고 판단하고 재시도한 경우 acks=all (OR -1) producer-acks-all higher latency / highest level of durability  프로듀서는 리더 브로커뿐만 아니라, 해당 파티션을 복제하고 있는 모든 'In-Sync' 상태의 팔로워 브로커들(ISR)까지 메시지를 기록했다는 확인 응답을 모두 받을 때까지 기다립니다.  ISR(In-Sync Replicas)은 리더 파티션을 포함하여 거의 실시간으로 리더의 모든 메시지를 복제하고 있는 레플리카들의 집합입니다. 리더와 팔로워들의 응답을 모두 기다려야 하기 때문에, 지연시간이 가장 깁니다.  데이터 유실: min.insync.replicas 설정보다 적은 수의 레플리카가 동기화되어 있을 때, 프로듀서의 요청이 실패하는 경우 혹은 모든 레플리카가 동시에 다운되는 경우 메시지 중복: 모든 ISR에 복제가 완료되었지만, 리더 브로커의 최종 응답을 받지 못하고 프로듀서가 재시도하는 경우 프로듀서 멱등성(Idempotence) producer-idempotency 프로듀서 멱등성은 동일한 메시지를 여러 번 전송해도 카프카 브로커에 정확히 한 번만 기록되도록 보장하는 기능입니다. 이 기능은 acks=-1 설정과 결합되어 프로듀서의 재시도로 인한 메시지 중복 문제를 해결합니다. 설정 시 acks=-1을 강제하며, enable.idempotence=true 를 통해 설정합니다. 멱등성이 true로 설정된 프로듀서는 고유한 ID를 할당 받고 각 메시지에 자신의 ID와 Sequence Number를 부여해 브로커에게 전송합니다. 브로커는 각 프로듀서가 파티션에 마지막으로 기록한 메시지의 Sequence Number를 기억합니다. 프로듀서가 메시지를 재전송하면, 브로커는 메시지에 포함된 Sequence Number를 확인하여 이전에 이미 받은 메시지인지 판단합니다. 만약 수신된 메시지의 Sequence Number가 이미 기록된 Sequence Number와 같거나 더 작으면, 브로커는 해당 메시지를 중복으로 간주하고 무시합니다. 이를 통해 단일 세션 내에서의 메시지 중복 문제를 해결할 수 있습니다. 한계 멱등성(Idempotence)은 단일 프로듀서 세션 내에서만 작동합니다. 하지만 프로듀서 프로세스가 충돌하거나 재시작되면, 기존에 처리중이던 메시지도 유실될 수 있고, redis 등으로 재시도 로직을 엔지니어링 해도 새로운 인스턴스는 브로커로부터 완전히 새로운 고유 ID를 할당받기에 중복 발송을 해결할 수 없습니다.  이러한 문제를 해결하기 위해 카프카의 트랜잭션(Transactions) 기능이 필요합니다. 이는 추후 알아보겠습니다. ISR(In-Sync Replicas) 앞에서 본 acks의 의미는 결국 “어떤 지점까지 쓰기가 도달하면 성공으로 볼 것인가?”에 대한 합의입니다. 이 합의를 안전하게 이행하려면 리더 단독이 아니라, 리더와 거의 실시간으로 보조하는 복제본 집합이 필요합니다. 그 집합이 ISR(In-Sync Replicas) 입니다. ISR에는 항상 리더가 포함되며, 일정 기준 이상 뒤처지지 않는 팔로워들만 포함됩니다. LEO, HW ISR의 동작 원리를 살펴보기 위해 일부 용어를 정의하겠습니다. ISR LEO(Log End Offset) 각 브로커(리더/팔로워)가 가진 로그의 “마지막” 위치입니다. 새 레코드가 추가되면 해당 브로커의 LEO가 증가합니다. HW(High Watermark) 파티션의 모든 ISR 구성원이 “공통적으로 도달한” 오프셋입니다. 컨슈머는 기본적으로 HW를 넘어서 읽지 않습니다. 즉, HW를 기준으로 “커밋된 데이터”가 정의됩니다. 프로듀서 쓰기 흐름은 대략 다음과 같습니다. 리더가 레코드를 자신의 로그에 append하여 리더 LEO 증가 팔로워들이 Fetch 요청으로 리더로부터 레코드를 복제하여 각자 LEO 증가 ISR 모든 구성원의 LEO가 특정 오프셋 이상으로 정렬되면, 리더가 그 오프셋까지의 HW를 전진 자세한 내용은 위 링크에서 확인할 수 있습니다. replica.lag.time.max.ms 팔로워가 리더를 일정 시간 이상 따라가지 못하면 ISR에서 제외됩니다. 반대로 충분히 따라잡으면 다시 포함됩니다. replica.lag.time.max.ms 설정을 통해 관리됩니다. 팔로워가 이 시간 이상 리더 데이터를 가져가지 못하면 ISR에서 제외됩니다. 과거에는 replica.lag.max.messages라는 메시지 개수 기준이 있었지만, 버전에 따라 비권장/제거 추세입니다. 이 메커니즘 덕분에 ISR은 “지금 이 순간 리더와 의미 있게 동기화된 집합”이라는 성질을 유지합니다. 즉, acks=all이 기대하는 복제 보장은 ISR의 질에 달려 있습니다. min.insync.replicas acks=all의 의미를 보장하기 위해, ISR의 최소 크기를 강제해야 합니다. min.insync.replicas 설정을 통해 관리됩니다. acks=all, replication factor=3, min.insync.replicas=2라면, minisr 프로듀서가 메시지를 보내면, 리더가 메시지를 기록하고 팔로워에게 복제합니다. ISR이 3이기에 ack를 받을 수 있습니다. minisr2 팔로워 하나가 장애가 난 상황입니다. ISR이 2이지만, min.insync.replicas가 2이기에 리더는 ack를 보내게 됩니다. 만약 min.insync.replicas가 3이였다면, ISR이 존재해서 대응은 여전히 가능한 상황이지만, 리더가 쓰기를 거절해 클러스터가 마비될 수 있겠죠? 따라서 주로 repication factor=3이면, min.insync.replicas=2로 설정이 권장됩니다. unclean.leader.election.enable unclean.leader.election.enable=true면, ISR에 없는 노드도 리더로 승격될 수 있습니다. 가용성은 올라가지만, 새 리더의 로그가 뒤쳐져 있으면 데이터 손실이 발생합니다. 강한 내구성이 요구되는 환경에서는 반드시 false로 두어야 합니다. 메시지의 key 와 순서 보장 Kafka에서 메시지는 키(Key) 와 값(Value) 으로 구성됩니다. 여기서 키는 메시지의 값에 대한 메타데이터 역할을 하며, Kafka 브로커가 메시지를 어느 파티션에 저장할지 결정하는 중요한 기준이 됩니다. 키가 null 인 경우, 메시지는 별도의 커스텀 파티셔너를 만들지 않는 한 라운드 로빈 방식으로 각 파티션에 균등하게 분배됩니다.  카프카는 파티션 내에서의 순서만 보장합니다. 만약 동일 객체에 관련된 메시지가 순서 보장이 필요할 때, 키 값을 지정하지 않으면 여러 파티션에 분산되기에 메시지의 순서가 보장되지 않습니다. 반면, 키가 null이 아닌 경우에는 동일한 키를 가진 메시지들은 항상 동일한 파티션에 기록됩니다. 이 덕분에 메시지들은 파티션 내에서 기록된 순서대로 정렬되며, 컨슈머도 그 순서대로 메시지를 읽게 됩니다. 그러나 키가 너무 편향되어 있다면 특정 파티션에 메시지가 몰려 부하 불균형이 발생할 수 있습니다. 예를 들어, 웹사이트에서 'Guest'라는 키를 사용하는 메시지가 대부분이라면, 해당 파티션에만 트래픽이 집중되어 성능 문제가 발생할 수 있습니다. 따라서 키를 설계할 때는 메시지를 파티션에 고르게 분산시킬 수 있도록 카디널리티가 높은 값을 사용하는 것이 중요합니다. 마치며 카프카의 메시지 전송 신뢰도는 단순히 "보장된다" 또는 "안된다"의 이분법적인 개념이 아닙니다. acks와 min.insync.replicas 같은 옵션들을 통해 개발자가 서비스의 요구사항에 맞춰 지연 시간, 처리량, 내구성 사이의 관계를 능동적으로 조절할 수가 있죠. 우리가 만드는 서비스의 데이터가 얼마나 중요한지, 약간의 유실이 치명적인지, 아니면 속도가 더 중요한지를 명확히 이해하고 그에 맞는 최적의 옵션을 선택하는 것이 중요하겠습니다. WAS - 프로듀서 쪽 개념은 이걸로 마무리하고, 다음 글에서는 컨슈머 쪽에서의 순서 보장, 재처리, 오프셋 커밋 전략을 통해 e2e 신뢰도를 완성하는 방법을 살펴보겠습니다.
2025년 09월 02일 -
3. Kafka Consumer - 오프셋 커밋과 재처리 전략, Dead Letter Queue(DLQ)
지난 글에선 카프카 프로듀서 측 주요 개념과 발생할 수 있는 시나리오 및 대응법들을 살펴봤습니다. 사실 프로듀서 측은 세팅만 잘 해두면 큰 문제가 생기지는 않습니다. 데이터의 영속화와 메시지 발행간의 무결성만 잘 지킨다면 외에는 크게 신경 쓸 부분이 없기 때문이죠. 다만 컨슈머 측엔 토픽을 다루는 서비스 로직이 들어가다 보니 발생할 수 있는 상황의 복잡도가 굉장히 높습니다. 이번 글에선 컨슈머 측에서 여러 상황을 컨트롤하기 위해 필요한 개념과 방법들에 대해 알아보겠습니다. 오프셋 커밋 전략 오프셋은 파티션 내에서 각 메시지가 갖는 유일하고 순차적인 식별 번호입니다. 컨슈머가 메시지를 성공적으로 처리하고 나면, 브로커에게 자신이 다음으로 읽어야 할 메시지의 위치, 즉 오프셋을 커밋합니다. 덕분에 컨슈머에 장애가 생기거나 리밸런싱이 일어나도 중단된 지점부터 메시지 처리를 지속할 수 있습니다. 컨슈머는 오프셋을 자동 커밋과 수동 커밋 방식 중 하나로 처리할 수 있습니다. 자동 커밋은 특정한 기준(일정 주기, 일정 개수, 레코드 처리 후 등등)에 도달할 때 마다 오프셋을 커밋하는 방식이고, 수동 커밋은 개발자가 코드 내에서 명시적으로 커밋 시점을 제어하는 방식입니다. 결론부터 말씀 드리자면, 대부분의 경우 수동 커밋이 유리합니다. 오프셋 커밋 전략은 처리 방법론에 해당하는 자동 커밋 vs 수동 커밋 이전에, at-most-once를 보장하느냐, at-least-once를 보장하느냐로도 나눌 수 있습니다. 이에 따라 데이터가 유실될 수도, 중복 처리될 수도 있습니다. 시나리오를 한번 알아보겠습니다. at-most-once: 데이터 유실 시나리오 만약 컨슈머가 데이터에 대한 로직 처리 시점 이전에 커밋을 해버리면, 메시지는 최대 한번(at-most-once) 처리된다고 의미할 수 있습니다. 이 경우 로직 도중 서버가 내려가는 등의 이슈가 생길 시 처리 중이던 데이터는 이미 처리된 것으로 커밋된 상태라 데이터 유실이 생길 수 있습니다. 대부분의 주기별, 개수별 등 일부 자동 커밋 상황에서 생길 수 있습니다. 유실은 중복보다도 최악의 상황이기에 지양하는 편이 좋습니다. at-least-once: 데이터 중복 시나리오 만약 컨슈머가 데이터를에 대한 로직 처리 시점 이후에 오프셋 커밋을 한다면, 메시지는 최소 한번(at-least-once) 처리된다고 의미할 수 있습니다. 이 경우 로직 처리를 완료한 뒤, 오프셋 커밋을 못한 채 컨슈머측 장애가 생겨 재시도하게 된다면 데이터 중복 처리가 생길 수 있습니다. 보통은 이 방식을 선호하고, 수동 커밋으로 로직이 끝난 직후 오프셋 커밋을 하며 중복 처리에 대해서는 애플리케이션 로직 단에서 멱등성을 보장하는 식으로 해결합니다. 멱등성이란 여러번 작업해도 동일한 결과를 불러오는 특성을 뜻하며, 주로 upsert를 통해 여러번 업데이트 되어도 동일한 결과가 나오게 하거나, 이벤트에 고유한 Idempotency-Key를 담아서 Redis 등과 연계에 중복 처리 유무를 확인하는 식으로 구현할 수 있습니다. PG 연동 등을 해보신 분은 Exactly-Once 처리를 위해 Idempotency-Key를 요구하는 api를 보신적이 있을겁니다. 재처리 전략 카프카 컨슈머 쪽에서 메시지 처리 로직 중 예외를 만나면 어떤 식으로 처리할 지에 대해 WAS 단에서 설계해둘 수 있습니다. 주요 재처리 전략을 소개합니다. Backoff 메시지 처리 실패 시 즉시 재시도하는 것이 아니라, 일정한 간격을 두고 재시도하도록 하는 방식입니다. 고정된 간격으로 재시도하는 FixedBackoff나 지수적으로 간격을 늘리는 ExponentialBackOff 전략 등을 채택할 수 있습니다. 실패 시 컨테이너 중지 메시지 처리 도중 특정 예외가 발생하면 컨슈머 컨테이너 자체를 중지시켜 더 이상의 메시지 소비를 멈추는 방식입니다. 이렇게 된 경우 고 수동으로 컨테이너를 재기동하기 전까지 컨슈머는 작동을 멈추고 이후 토픽도 소비하지 않습니다. 데이터 불일치나 비즈니스 로직 오류처럼 “치명적이고 자동 복구 불가능한 상황”에서 시스템을 보호하기 위해 사용합니다. Dead Letter Queue 특정 메시지를 여러 번 재시도했음에도 실패하면, 해당 메시지를 원래 토픽 대신 별도의 Dead Letter Topic으로 보내 보관하는 방식입니다. 이 경우 컨테이너 중지 방식과 다르게 다음 메시지를 처리할 수 있으면서 데이터의 정합성을 보장할 수 있습니다. DLQ에 전송된 메시지에 대해서는 별도의 처리 파이프라인을 만들어주어야합니다. 보통 에러 상황에 해당할 테니 버그가 고쳐진 후 다시 재처리를 하게 한다던가, 네트워크 장애가 해소된 후 재처리를 하게 한다던가의 매니징을 할 수 있게 됩니다. 출처 https://developer.confluent.io/courses/kafka-connect/error-handling-and-dead-letter-queues/ https://medium.com/techwasti/idempotent-kafka-consumer-442f9aec991e
2025년 09월 08일 -
4. [Spring] 카프카 의존성 - Spring Cloud Stream vs Spring Kafka
스프링에서 카프카 Producer과 Consumer를 사용하기 위해서는 대게 2가지 방법이 있습니다. 바로 Spring Cloud Stream 과 Spring Kafka입니다. 두 기술은 서로 철학과 장단이 다른데, 이름에서 볼 수 있듯 Spring Cloud Stream은 추상화 레벨이 높고, Spring Kafka는 Kafka라는 이름이 직접 들어가는 만큼 더 세밀한 카프카 지향적인 제어가 가능합니다. 이번엔 두 기술을 비교해보며 각각의 철학 및 사용법을 확인해보고 장단점을 알아보겠습니다. 스프링 클라우드 스트림 (Spring Cloud Stream) 개념 및 철학 스프링 클라우드 스트림은 확장성이 뛰어난 이벤트 기반 마이크로서비스를 구축하기 위한 프레임워크입니다. Kafka 뿐만 아니라 RabbitMQ, AWS SNS 등등 여러 메시지 큐와의 통합을 지원합니다. 이 기술은 구체적 기술 명세가 은닉되고 Binder라는 추상화계층을 두어 이를 활용합니다. 바인더가 포함된 SCSt 그림의 MiddleWare에 해당하는 부분이 Kafka 등의 메시지 큐 구현에 해당합니다. 따라서 실제 서비스 코드에는 카프카 등의 개념 자체가 등장하지 않죠. Kafka의 파티션, Consumer Group 같은 저수준 브로커 세부 사항은 직접 다루지 않아도 됩니다. Binder 측에서 Consumer Group, 파티션 수, Offset 관리 등을 처리해 줍니다. 이런 고수준의 추상화임에도 세밀한 제어가 가능한 이유는 바인더 쪽에 kafka라는 기술 명세에 대한 연결을 어떻게 처리할 것인지를 application.yml에서 상세하게 설정 가능하기 때문입니다. 어떻게 보면, Kafka 같은 구체적 기술 명세에 대한 엔지니어링을 application.yml 에 다 집어넣어서 응집도 높게 처리한다고 볼 수 있습니다. 반대로 말하면, 설정 파일로 엔지니어링을 해야 합니다. 호불호가 갈리는 포인트입니다. 애플리케이션에서의 코드 이런 식으로 컨슈머나 프로듀서를 등록합니다. 또 신기한 점은 컨슈머 및 프로듀서 측 코드만 봤을 때는 별도의 연결 관련 코드가 없습니다. 이 Bean이 어떤 토픽을 컨슈밍/프로듀싱하는 지, 어떤 컨슈머 그룹인 지 등에 대한 정보를 확인할 수 없죠. application.yml 설정 파일 예시입니다. topic이라는 이름 대신 destination 을 쓰는 등의 모습에서 추상화된 개념을 활용함을 알 수 있죠. definition: myProducer;myConsumer 이런 식으로 프로듀서 & 컨슈머 Class를 설정할 수 있습니다. spring.cloud.bindings 쪽에서 지정해놓은 consumer-test 등에 대한 상세 명세를 spring.cloud.stream.kafka.bindings 에서 kafka-specific 하게 처리할 수 있습니다. 정리 메시지 큐에 대한 고수준의 추상화를 통해서 기술 의존적이지 않게 개발할 수 있다는 장점이 있습니다. 다만, 특정 기술에 대한 세부적인 엔지니어링을 설정 파일에서 모두 해야 한다는 단점이 있습니다. 메시지 큐가 구현해야할 기능과 명세가 SQL 처럼 합의가 되었더라면 JPA 처럼 활용할 수 있겠지만, 아직은 메시지 큐 별로 제공하는 기능이 서로 다르고 복합적이여서 엔지니어링이 필연적입니다. 따라 조금은 불쾌한 개발 경험이 있을 수 있습니다. Spring Cloud Stream 도입하기 | 카카오페이 카카오페이에서 활용 중인 기술 스택입니다. 처음부터 다 분리하고 규격화하면서 개발하는 건 너무 힘든 일입니다. 개발하면서 공통 부분과 분리할 부분을 잘 찾은 다음에 분리해도 늦지 않고, 언제든 되돌아갈 준비가 되어 있는 게 좋습니다. Spring Cloud Stream은 그런 관점에서 좋은 대안이 될 수 있습니다. 쉽게 분리 및 결합이 가능하고 제거하기도 편합니다. spring-kafka는 low level로 개발하는 거라서 코딩 스타일 강제가 불가능합니다. 데이터 흐름을 코드 베이스로 파악해야 하는 어려움이 있습니다. 또한 잘못 코딩하게 되면 결합도가 높아집니다. Spring Cloud Stream은 데이터 흐름 제어나 코딩 스타일을 어느 정도 규격화 하는 것이 가능하고 코드 결합도도 낮출 수 있습니다 카카오페이에서 해당 기술 스택을 선택한 이유에 대해 조금 발췌해봤습니다. 어느정도 규모가 있는 서비스에서 개발자들의 코딩 스타일을 강제하여 서비스 개발에서 개발자의 엔지니어링 미스로 생기는 잘못된 설계를 통제할 수 있습니다. 마찬가지로 규모가 작은 서비스에서 Kafka를 사용하고는 싶지만, 인프라적 / 비즈니스적 여건이 안되거나 오버엔지니어링으로 판단 될 때, 확장성을 도모하며 선택할 수 있습니다. 스프링 카프카 (Spring Kafka) 개념 및 철학 스프링 카프카는 Spring의 핵심 개념을 적용하여 Kafka를 엔지니어링 할 수 있게 도와줍니다. application.yml에서 KafkaProperties를 설정하고, ProducerFactory 나 ConsumerFactory를 통해 원하는 설정의 프로듀서나 컨슈머를 만들 수 있습니다. DI를 통해 KafkaTemplate으로 Producer API를 제어할 수 있습니다. @KafkaListener 어노테이션으로 컨슈머에 대한 설정을 MVC에서 controller 정의 하듯 간편하게 할 수 있습니다. 애플리케이션에서의 코드 프로듀서 측 코드입니다. 메시지를 어떤 토픽에 어떻게 보낼 것인 지를 세밀하게 제어 가능합니다. 코드에 Kafka나 Topic 같은 개념이 투명하게 드러납니다. 컨슈머 측 코드입니다. 컨슈머 그룹이나 토픽, concurrency 같은 걸 개별 컨슈머 측에서도 제어할 수 있습니다. acknowledgment.acknowledge(); 을 통해 수동 커밋도 제어할 수 있습니다. Spring MVC와 굉장히 유사하죠? @KafkaListener 어노테이션이 달려 있는 메서드에 마치 MVC에서 HttpServletRequest 받는 것 처럼 상위 어뎁터가 메시지나 ack 객체를 담아 메서드를 호출해줍니다. 이를 통해 컨슈머측 정밀 제어할 수 있습니다. application.yml 쪽입니다. 이걸로 제어할 수 있긴 하지만 SpringKafka쪽에선 코드레벨에서 설정을 제어하는 걸 선호해서 그리 설정이 들어가지 않습니다. 보통 공통 설정을 처리합니다. 보통 이걸 받아서 팩토리 단에서 일부는 공용 설정으로, 일부는 더 구체적인 설정을 추가하여 처리합니다. 이런 식으로 기본 설정에 커스텀하여 배치 컨슈머 팩토리를 만들 수 있습니다. 이런 식으로 컨슈머 팩토리에 AckMode(수동 커밋이나 여러 자동 커밋 방식 중 설정)나 예외 처리, Retry 설정 등을 더하여 컨슈머측에서 쓰던 KafkaListener용 팩토리를 설정할 수 있습니다. 이런 식으로 여러 컨테이너 팩토리가 있으면 bean을 골라 쓸수도 있습니다. 정리 코드쪽만 잠깐 보셔도 아시다시피 스프링 클라우드 스트림보다는 훨씬 운용 난이도 및 자유도가 높습니다. Kafka에 대한 이해도가 꽤나 높은 조직이고, 서비스 아키텍처가 잘 모듈화되어 카프카와 관한 로직을 격벽을 잘 쳐서 결합도 낮게 관리할 수 있다면 좋은 선택일 수 있습니다. 다만 카프카 데이터가 어떻게 흘러가는 지 보려면 코드레벨에서의 탐색이 필요합니다. 응집도 높은 모듈을 구현해서 쓴다면 큰 문제가 없겠지만, 토픽이나 설정, 프로듀서, 리스너가 여러 곳에 기준없이 산재된다면 코드가 굉장히 더러워질 수 있습니다. 비교 분석 | 항목 | Spring Cloud Stream | Spring Kafka | | ----- | --------------------------- | --------------------------------------- | | 철학 | 고수준 추상화, 다양한 메시징 시스템 지원 | Kafka 중심 저수준 제어 | | 코드 특징 | 추상화 API 사용, 브로커 구현 노출 없음 | KafkaTemplate/Listener로 직접 제어, 세밀 설정 가능 | | 설정 위치 | application.yml 중심 | 코드 중심, 일부 공통 설정만 yml | | 세밀 제어 | Binder 통해 가능, 설정 의존 | 코드에서 직접 제어 가능 (Ack, Retry 등) | | 장점 | 기술 의존 낮음, 코드 결합도 낮음, 규격화 가능 | Kafka 기능 완전 제어, 흐름 코드 기반 확인 가능 | | 단점 | 설정 많으면 복잡 | 코드 산재 시 복잡, Kafka 이해 필요 | | 적합 상황 | 개발 스타일 통제, 확장성 필요 | Kafka 전문 지식 있는 팀, 모듈화 구조 | 둘의 특성을 표로 정리해봤습니다. 사실 철학과 Spec이 차이나긴 하지만 개념적으로는 메시지 큐 및 카프카를 기저에 두고 있기 때문에 익숙해지면 어느 것이든 좋은 선택일 수 있습니다. 조직 및 프로젝트의 성향에 맞춰 잘 선택하시면 될 것 같습니다. 출처 https://docs.spring.io/spring-kafka/reference/ https://docs.spring.io/spring-cloud-stream/reference/ https://github.com/HyunSangHan/fastcampus-kafka-message-queue
2025년 09월 10일