Data Concurrency Optimization with Kafka – Request-Reply Pattern
Kafka 기반 대규모 데이터 동시성 최적화: Request-Reply 패턴 활용 사례
🛠 Kafka와 Request-Reply로 푼 대규모 동시성 이슈 해결기
배달의민족은 수십만 개의 가게 주문 처리 과정에서 발생하는 대규모 동시성 문제를 Kafka와 Request-Reply 패턴을 이용해 해결했습니다. 이 글은 해당 장애 분석, 구조 설계, 코드 구현까지의 전 과정을 개발자 친화적으로 설명합니다.
🚨 장애 상황: 수십만 건의 실시간 데이터 변경
배민의 시스템은 주문량이 폭증하거나 기상 악화 등 트리거가 발생하면, 특정 가게의 배달 반경을 실시간으로 축소시켜 유입되는 주문을 줄입니다.
하지만 이때 수십만 개 가게에 대해 동시에 반경 변경이 발생하고, 동일한 칼럼(데이터 열)을 수정하게 되면 다음과 같은 문제가 발생합니다:
💥 Deadlock (교착 상태): 서로 락을 잡은 상태에서 아무도 작업을 끝내지 못하는 상태
⏳ Lock Timeout: 락이 걸린 상태에서 시간이 지나도 작업이 안 되면 에러 발생
⚠️ Race Condition (경쟁 조건): 여러 쓰레드가 동시에 접근해 순서가 꼬이며 잘못된 결과가 나오는 문제
🔄 순서가 중요한 이유
예를 들어,
라이더 부족 트리거 발생 → 반경 3km로 축소
시스템 장애 트리거 발생 → 반경 1km로 더 축소
이렇게 순서가 지켜져야 하는데, 1번만 적용되고 2번이 무시되면 시스템이 장애 상태임에도 주문을 계속 받게 되는 오류가 발생합니다.
💡 순서 보장이란?
여러 요청이 있을 때 “처리 순서”가 예상대로 유지되는 것을 의미합니다. 장애 시에는 더 강력한 제한이 나중에 적용되어야 합니다.
🔐 분산 락? 해결되지 않았다
초기에는 Pessimistic Lock
(비관적 락)이나 Redis 분산 락
을 사용하려 했습니다.
💡 락(Lock)이란?
여러 프로세스가 동시에 데이터를 수정하지 못하게 막는 장치입니다.
하지만 다음 문제들이 있었습니다:
락 경합 → 성능 저하
락을 걸어도 정확한 순서 보장이 안 됨
수평 확장 불가 (서버를 여러 대 늘려도 처리량이 증가하지 않음)
응답 결과를 받을 수 없음
📦 Kafka의 특성 활용
Kafka는 동일한 Key를 사용하면 같은 Partition으로 메시지를 보냅니다. 이 Partition 내부에서는 순서가 보장됩니다.
💡 Kafka란?
분산 메시지 큐 시스템으로, 데이터를 보내는 쪽(Producer)과 받는 쪽(Consumer)을 분리시켜 대규모 트래픽을 처리할 수 있습니다.
💡 Partition이란?
Kafka에서 토픽을 나누는 단위입니다. 동일한 Key가 항상 같은 Partition으로 가면 순서가 유지됩니다.
📬 Request-Reply 패턴 도입
Kafka는 기본적으로 단방향이기 때문에 처리 결과를 받을 수 없습니다. 이를 해결하기 위해 Request-Reply
패턴을 도입합니다.
💡 Request-Reply란?
요청자가 메시지를 보내고, 응답자는 처리 후 응답 메시지를 돌려주는 구조입니다. 요청자 → 응답자 → 다시 요청자.
Spring Kafka에서는 이를 위해 ReplyingKafkaTemplate
클래스를 제공합니다.
ReplyingKafkaTemplate(Requester) 요소
KafkaProducer
메시지
전송
ReplyContainer
전송한 메시지에 대한
응답을 수신
Kafka Broker(Request Channel / Reply Channel) 요소
Request Topic
Reply Topic
Consumer Group(Replier) 요소
Consumer
✅ 주요 목적
단방향 메시지 전송
요청-응답 구조 구현
🔄 메시지 흐름
Producer → Topic
Producer → Topic → Consumer → 응답 Topic → Producer
📥 응답 수신 기능
없음
있음 (CompletableFuture로 응답 대기)
🧠 내부 구성
KafkaProducer 만 포함
KafkaProducer + ReplyListenerContainer 포함
🧩 사용 예
이벤트 발행, 로그 전송, 알림 등
주문 처리 결과 요청, 동기적 응답이 필요한 경우
🧵 비동기 처리
가능 (ListenableFuture)
가능 (RequestReplyFuture, 내부적으로 CompletableFuture 사용)
⚙️ 설정 복잡도
낮음 (간단히 사용 가능)
높음 (응답 토픽, 컨테이너 설정 필요)
🧠 Correlation ID로 요청-응답 매핑
Kafka는 비동기 처리이기 때문에, 어떤 응답이 어떤 요청에 대한 것인지 구분이 필요합니다.
이를 위해 correlationId
라는 고유 식별자(요청의 Key)를 사용합니다.
// 요청 시
correlationId = UUID.randomUUID();
futureMap.put(correlationId, new CompletableFuture<>());
응답 수신 시:
// 응답에 포함된 correlationId로 future를 찾아 응답 완료 처리
CompletableFuture<?> future = futureMap.remove(correlationId);
future.complete(response);
💡 CompletableFuture란?
비동기 결과를 기다리는 객체입니다. 응답이 오면 future.complete()으로 값을 채워넣습니다.
🧪 단일 토픽 운영 전략
RequestDispatcher 클래스 작성
Request-Topic과 Reply-Topic을 도메인 기준으로 단일 토픽으로 운영했습니다.
@Component
public class RequestDispatcher<V extends Request<?>, R extends Reply<?>> {
private final ReplyingKafkaTemplate<String, V, R> replyingKafkaTemplate;private final String requestTopic;public RequestDispatcher(ReplyingKafkaTemplate<String, V, R> replyingKafkaTemplate, String requestTopic) { this.requestTopic = requestTopic; this.replyingKafkaTemplate = replyingKafkaTemplate;}public CompletableFuture<Void> send(V request) { return replyingKafkaTemplate.send(requestTopic, request.getKey(), request) //ListenableFuture<SendResult<...>> .completable() // CompletableFuture<SendResult<...>> .thenRun(() -> {});}
}
장점:
설정 관리 간단
공통 코드를 도출해 Requester 재사용 가능
기능 증설/확장 용이
단점:
컨슈머가 필요 없는 메시지도 구독하게 되므로 필터링 필요
💡 토픽(Topic)이란?
Kafka에서 메시지를 분류하는 논리적 단위입니다.
🧵 Reply 메시지 처리 전략: sharedReplyTopic
모든 인스턴스가 reply-topic을 구독하면 자신과 관련 없는 응답도 수신하게 됩니다.
해결책:
각 인스턴스는 고유한 Consumer Group ID를 갖도록 설정
응답에 대한 요청이 없을때 처리하지 않고 넘어가는 이점
sharedReplyTopic = true
설정 시요청이 누락된 응답에 대해
DEBUG
로그만 남기고 무시
Consumer Group ID 는 UUID를 활용
중복을 최대한 제거할 수 있었음
💡 Consumer Group ID란?
Kafka에서 메시지를 처리하는 컨슈머 그룹을 식별하는 ID입니다. 각 그룹은 메시지를 중복 없이 처리합니다.
응답 메시지 발행 정리
Spring Kafka의 @SendTo
활용
@SendTo
활용요청 메시지를 수신한
카프카 리스너에 @SendTo 추가
하기카프카 헤더
에 직접 Reply Topic 지정 / @sendto value 지정ListenerContainer 구성시
응답 메시지를 돌려줄 ReplyTemplate
정의
위 구조 : 아래의 파란색 눈금에 관련된 응답 메시지 발행
⚖️ 분산 락 vs Kafka Request-Reply 비교
처리 방식
동기적 (blocking)
비동기 (non-blocking)
순서 보장
어렵고 직접 구현 필요
Kafka가 자동 보장
확장성
락 경합 ↑, 성능 ↓
파티션/브로커 수평 확장 가능
결과 응답
별도 로직 필요
CompletableFuture로 간편 수신
🏁 기존 구조 vs 개선 구조
🏁 기존 구조 vs 개선 구조
🧱 기존 구조
각 요청이 서버에서 DB를 직접 수정
락 충돌/데이터 꼬임 빈번
응답 결과 없음
🚀 개선 구조 (Kafka 기반)
모든 요청을 Kafka에 전송
순서 보장 + 병렬 처리 가능
결과는 Reply-Topic을 통해 응답
응답 수신 → 클라이언트에 정확히 전달
✨ 실무 인사이트 요약
Kafka의 Key-Partition 구조는 동시성 문제와 순서 보장에 매우 유리
ReplyingKafkaTemplate
을 이용한 Request-Reply 패턴은 비동기 결과 확인에 최적단일 토픽 운영 전략 + Consumer Group 분리 + sharedReplyTopic 설정은 확장성과 안정성을 동시에 확보하는 키
✅ 마무리하며
이번 사례는 단순 코드 개선이 아니라, 시스템 아키텍처를 이벤트 기반 메시지 처리 구조로 전환한 대표적인 예입니다.
“장애는 코드가 아니라 아키텍처로 막는다.”
Kafka + Request-Reply 패턴은 다른 실시간 시스템에서도 충분히 적용할 수 있습니다. 유사한 장애 상황이 있다면 꼭 이 구조를 고려해보시길 추천드립니다.
느낀점: 확장성과 안정성은 얻었지만, 과연 최선이었을까?
이번 구조는 Kafka의 파티셔닝과 Request-Reply 패턴을 조합하여 동시성과 순서를 해결했다는 점에서는 매우 인상적이었습니다. 특히 Kafka의 특성을 잘 활용해 분산락이나 DB 중심의 동기 처리보다 훨씬 확장 가능한 시스템을 만들었다는 점은 분명히 배울 점이었습니다.
하지만 개인적으로는 이 구조가 약간의 오버엔지니어링이라고 느껴졌습니다. 그 이유는 다음과 같습니다:
모든 컨슈머 그룹이 리플라이 토픽을 계속 pull한다는 점
현재 구조에서는 sharedReplyTopic = true
설정을 통해, **모든 컨슈머 그룹이 같은 reply topic을 구독(poll)**하고,
자신의 요청이 아닌 응답이면 그냥 무시하는 방식으로 동작합니다.
이 말은 곧, N개의 Consumer Group이 모두 같은 메시지를 읽고, 그 중 하나만 처리하고, 나머지는 "내 것 아님"이라며 버린다는 뜻입니다.
→ 비효율적입니다.
더 나은 구조는 없을까?
Kafka의 구조상 파티션 기반 순서를 보장하기 위해 단일 리플라이 토픽을 유지하는 전략은 이해됩니다.
하지만, 응답 메시지를 처리하는 측에서 라우팅이 가능하거나,
Kafka 헤더에 들어 있는 correlationId
나 replyTo
필드를 이용해 명확한 응답 전용 라우팅을 설계할 수 있었다면
이러한 브로드캐스팅-필터링 구조는 피할 수 있지 않았을까 하는 의문이 듭니다.
Last updated