Data Concurrency Optimization with Kafka – Request-Reply Pattern

Kafka 기반 대규모 데이터 동시성 최적화: Request-Reply 패턴 활용 사례

🛠 Kafka와 Request-Reply로 푼 대규모 동시성 이슈 해결기

배달의민족은 수십만 개의 가게 주문 처리 과정에서 발생하는 대규모 동시성 문제를 Kafka와 Request-Reply 패턴을 이용해 해결했습니다. 이 글은 해당 장애 분석, 구조 설계, 코드 구현까지의 전 과정을 개발자 친화적으로 설명합니다.


🚨 장애 상황: 수십만 건의 실시간 데이터 변경

배민의 시스템은 주문량이 폭증하거나 기상 악화 등 트리거가 발생하면, 특정 가게의 배달 반경을 실시간으로 축소시켜 유입되는 주문을 줄입니다.

하지만 이때 수십만 개 가게에 대해 동시에 반경 변경이 발생하고, 동일한 칼럼(데이터 열)을 수정하게 되면 다음과 같은 문제가 발생합니다:

  • 💥 Deadlock (교착 상태): 서로 락을 잡은 상태에서 아무도 작업을 끝내지 못하는 상태

  • Lock Timeout: 락이 걸린 상태에서 시간이 지나도 작업이 안 되면 에러 발생

  • ⚠️ Race Condition (경쟁 조건): 여러 쓰레드가 동시에 접근해 순서가 꼬이며 잘못된 결과가 나오는 문제


🔄 순서가 중요한 이유

예를 들어,

  1. 라이더 부족 트리거 발생 → 반경 3km로 축소

  2. 시스템 장애 트리거 발생 → 반경 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

항목
KafkaTemplate
ReplyingKafkaTemplate

✅ 주요 목적

단방향 메시지 전송

요청-응답 구조 구현

🔄 메시지 흐름

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&lt;String, V, R&gt; replyingKafkaTemplate;private final String requestTopic;public RequestDispatcher(ReplyingKafkaTemplate&lt;String, V, R&gt; replyingKafkaTemplate, String requestTopic) {    this.requestTopic = requestTopic;    this.replyingKafkaTemplate = replyingKafkaTemplate;}public CompletableFuture&lt;Void&gt; send(V request) {    return replyingKafkaTemplate.send(requestTopic, request.getKey(), request) //ListenableFuture&lt;SendResult&lt;...&gt;&gt;            .completable() // CompletableFuture&lt;SendResult&lt;...&gt;&gt;            .thenRun(() -&gt; {});}
    
  
}

장점:

  • 설정 관리 간단

  • 공통 코드를 도출해 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 추가하기

  • 카프카 헤더에 직접 Reply Topic 지정 / @sendto value 지정

  • ListenerContainer 구성시 응답 메시지를 돌려줄 ReplyTemplate 정의

위 구조 : 아래의 파란색 눈금에 관련된 응답 메시지 발행


⚖️ 분산 락 vs Kafka Request-Reply 비교

항목
분산 락
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 헤더에 들어 있는 correlationIdreplyTo 필드를 이용해 명확한 응답 전용 라우팅을 설계할 수 있었다면 이러한 브로드캐스팅-필터링 구조는 피할 수 있지 않았을까 하는 의문이 듭니다.

Last updated