올리브영 테크블로그 포스팅 비동기 요청-응답 패턴으로 풀어낸 발주 서비스 개발기
Tech

비동기 요청-응답 패턴으로 풀어낸 발주 서비스 개발기

ReplyingKafkaTemplate를 쓰지 못한 이유

2025.06.30

안녕하세요. 올리브영 SCM 백엔드 개발을 담당하고 있는 승쭈누입니다.
매일 반복되는 백오피스 업무 속에서, 우리는 종종 시스템의 한계에 부딪히곤 합니다. 특히 대량의 데이터를 처리해야 하는 경우, 느린 응답 속도는 사용자들의 업무 피로도를 높이고 비효율을 초래하죠. 하지만 백오피스 개발은 단순히 기능을 구현하는 것을 넘어, 현업의 고충을 해결하고 비즈니스 효율을 극대화하는 가치 있는 작업입니다.

이번 글에서는 올리브영의 핵심 도메인 중 하나인 '발주 서비스'를 개선하며 겪었던 아키텍처적 도전과제와 이를 해결하기 위한 여정을 다룹니다. 기존 동기식 처리 방식의 한계를 넘어, Kafka 기반의 비동기 요청-응답 아키텍처를 도입하며 마주했던 고민들, 그리고 최종적으로 올리브영 환경에 최적화된 솔루션을 찾아가는 과정을 상세히 소개하고자 합니다. 이 글이 백오피스 시스템의 성능과 안정성 고민에 대한 실질적인 해답을 찾는 데 도움이 되기를 바랍니다.

발주란?
고객이 온라인 몰에서 상품을 주문하듯, 올리브영이 협력사 또는 자사 물류센터로 상품을 주문하는 B2B 개념의 주문입니다.
대표적으로, 협력사에 상품을 주문하여 센터로 입고시키는 협력사 발주와 센터에 입고된 상품을 주문하여 각 매장으로 입고시키는 매장 발주 등이 있습니다.

이를 통해, 전국 올리브영 매장으로 상품을 원활히 공급하고 있습니다.


📦 신규 발주 서비스 왜 개발했나요?


기존 발주 시스템의 느린 처리 속도 문제와 더불어 비즈니스 구조의 다각화로 인해 새로운 요구사항이 등장했습니다.

기존 문제: 동기식 발주 처리

올리브영의 발주 업무는 ‘올리브원’이라 하는 백오피스 서비스에서 처리되고 있습니다. 발주를 담당하는 각 사용자가 올리브원에 접속하여 아래와 같은 프로세스로 발주 처리를 진행합니다.

올리브영 기존 발주 프로세스
올리브영 기존 발주 프로세스
  1. 담당자가 발주 요청 Excel 파일을 올리브원으로 업로드합니다.
  2. 파일을 DTO 리스트로 변환합니다.
  3. DTO 리스트를 순회하며 유효성 검증을 수행합니다.
  4. 유효한 발주 건에 대해 발주 처리합니다.
  5. 발주 처리 결과에 대해 이력을 저장합니다.
  6. 처리된 발주 전표번호와 함께 결과를 반환합니다.

기존 서비스는 발주를 동기식으로 처리했기 때문에, 발주량이 많아질수록 응답이 지연되어 다른 작업을 수행하기 어려웠습니다. 처리에 최대 수 분이 소요되기도 하여 사용자의 업무 생산성이 크게 저하되었어요. 또한 서버 메모리 자원도 장시간 점유되면서, 간헐적인 발주 처리 장애가 발생하는 등 성능과 안정성 측면 모두 개선이 필요한 상황이었습니다.

새로운 요구사항: 신규 백오피스 오픈

올리브영의 백오피스 서비스는 업무 영역에 따라 점차 세분화되고 신규 개발되고 있으며, 올해 상반기에는 PB 상품 관리 전용 백오피스인 ‘브랜드원’이 새롭게 런칭되었는데요.


브랜드원에서 발주 처리
브랜드원에서 발주 처리

브랜드원 측에서는 기존 백오피스인 ‘올리브원’처럼 자체 화면에서 직접 발주를 처리할 수 있기를 원했습니다. 이 요구는 향후 새롭게 도입될 다른 백오피스들에서도 반복될 가능성이 높다고 판단했기 때문에, 브랜드원 전용 발주 API를 따로 개발하기보다는, 올리브원을 포함한 모든 백오피스에서 공통으로 활용할 수 있는 신규 발주 서비스를 구축하기로 결정했습니다.

다만 기존 발주 API의 구조를 그대로 계승할 경우, 과거의 문제점들이 신규 서비스로 그대로 전파될 수 있기 때문에, 이러한 한계를 근본적으로 해결할 수 있도록 아키텍처를 새롭게 설계하고자 했습니다.




🧑🏻‍💻 기존 문제를 어떻게 해결하지?


병목 지점 찾기: 유효성 검증검증검증

먼저 기존 문제를 해결하기 위해 프로세스 내 병목 지점을 파악해보았습니다. 5,000개의 상품을 발주 처리하는 상황을 가정했을 때, 각 단계별 처리 시간은 다음과 같았습니다.

발주 프로세스 단계별 처리 시간
발주 프로세스 단계별 처리 시간

분석 결과, 가장 오래 지연되는 단계는 유효성 검증 프로세스였습니다. 매장 발주 처리 과정에서는 상품 하나당 수십 개의 유효성 검증을 거쳐야 하는데요, 이러한 구조적 특성으로 인해 전체 처리 시간이 길어질 수밖에 없었습니다.

유효성 검증 리스트
유효성 검증 리스트


개선 1: 요청 그룹화

처리 시간을 단축하기 위해 유효성 검증 항목을 점검하던 중, 불필요한 중복 검증이 발생하고 있다는 점을 발견했습니다. 문제의 원인은 엑셀 양식의 구조에 있었는데, 동일한 매장 코드나 발주 일자 등의 값이 모든 상품 행마다 반복 입력되면서, 같은 값에 대한 검증이 매번 반복되고 있었던 것입니다.

이 문제를 해결하기 위해 요청 DTO 구조를 개선했습니다. 기존의 '단일 레코드 기반' 구조에서 '그룹 기반' 구조로 변경하여, 헤더 레벨(물류센터, 발주일자 등)과 상품 리스트 레벨로 분리하고, 헤더 레벨 단위로 그룹화하여 중복 검증을 최소화하는 방향으로 변경했습니다. 아래 그림은 엑셀 데이터를 물류센터와 발주일자 기준으로 그룹화하여 DTO로 변환하는 개념적인 예시를 보여줍니다.

그룹화된 발주 요청 DTO 변환 예시
그룹화된 발주 요청 DTO 변환 예시

이러한 그룹화 구조 개선을 통해, 예를 들어 물류센터 유효성 검증이나 발주일자 유효성 검증처럼 헤더 레벨에서 반복되던 유효성 검증을 그룹당 한 번만 수행하도록 변경할 수 있었습니다. 덕분에 불필요한 중복 검증을 줄이고 리드타임을 단축하는 데는 성공했지만, 전체 발주 처리 시간이 여전히 길다는 근본적인 문제는 해결하기 어려웠습니다.

개선 2: Kafka 기반 비동기화

이에 따라 서비스 아키텍처 자체를 변경하기로 결정했습니다.

핵심은, 유효성 검증부터 발주 처리까지의 과정을 비동기 프로세스로 분리한 것입니다. 요청이 들어오면 Kafka를 통해 메시지를 발행하고, 발주 처리는 후속 단계에서 별도로 진행되도록 설계했습니다. 요청 시점에는 곧바로 응답을 반환하므로, 사용자 입장에서는 지연 없이 작업을 계속 이어갈 수 있습니다.

올리브영 비동기 발주 서비스
올리브영 비동기 발주 서비스

또한, 발주 담당자가 엑셀 파일을 업로드하면, 발주 데이터를 요청 DTO로 변환하는 과정까지는 각 백오피스에서 처리하도록 했습니다. 이후 비동기 프로세스를 거치면서, 발주 요청 즉시 응답을 받을 수 있도록 하여 기존의 지연 문제를 효과적으로 해소할 수 있었습니다.

📮 근데, 데이터 파이프라인으로 왜 Kafka를 선택했나요?

  1. 메시지 유실 방지
- Kafka는 replication과 log 기반 메시지 저장 방식을 통해 메시지의 내구성과 고가용성을 보장합니다. 발주 서비스는 단 한 건의 메시지 유실도 치명적인 비즈니스 문제로 이어질 수 있기에, Kafka의 강력한 메시지 내구성 보장이 매우 중요했습니다. 덕분에 발주 시스템에서 가장 중요한 문제인 메시지 유실을 원천 차단할 수 있었습니다
  1. 재처리 자동화
- Kafka는 메시지 오프셋을 조정함으로써 특정 시점으로 되돌아가서 메시지를 다시 처리할 수 있습니다. 예를 들어 발주 처리 중 시스템 장애가 발생하면, 장애 발생 직전 오프셋부터 다시 재처리를 시작해 누락된 발주 건들을 복구할 수 있습니다.
  1. 분산 처리를 통한 처리 성능 제고
- Kafka는 메시지를 여러 partition으로 분산 저장하는 수평 확장 구조라, 각 partition에 독립적인 Consumer를 할당해 병렬 처리할 수 있습니다. 이를 통해 발주량이 증가해도 Consumer 수를 늘려 처리 성능을 선형적으로 확장할 수 있습니다.

하지만 이 설계에는 한 가지 근본적인 문제가 존재했습니다.

비동기로 처리된 발주 결과를 사용자에게 어떻게 전달할 수 있을까요?

API는 요청을 수신한 시점에 바로 응답을 반환하기 때문에, 이후 비동기적으로 처리된 결과를 함께 전달할 방법이 없었습니다. 그러나 사용자 입장에서는 발주가 언제 처리되었는지, 그리고 성공적으로 완료되었는지 확인할 수 있는 수단이 반드시 필요했습니다.


🧑🏻‍💻비동기 요청-응답 아키텍처를 적용해보자


요청-응답 아키텍처

이에 대한 해결책은 Enterprise Integration Patterns(EIP)에서 찾을 수 있었습니다.
EIP는 기업 시스템 간 데이터 및 기능 통합을 위한 표준화된 아키텍처 패턴을 정리한 것이라고 볼 수 있는데요.
소개된 수많은 아키텍처 중 요청-응답 아키텍처를 참고하여 문제의 해결 실마리를 찾을 수 있었습니다.

요청-응답 아키텍처란, 요청과 응답을 각각의 채널로 분리하여 처리하는 구조를 의미합니다. 이 패턴을 발주 서비스에 적용하면, 비동기 처리를 하면서도 요청자가 발주 결과를 수신할 수 있을 거라 기대했습니다.

그래서 이 아키텍처를 실제 서비스에 도입해 보기로 했고, 이후 여러 번의 설계 시도를 거쳤습니다. 두 번의 실패를 딛고, 최종적으로 올리브영 발주 서비스에 가장 잘 맞는 형태의 요청-응답 아키텍처를 완성할 수 있었습니다.

이제부터 그 과정을 순서대로 소개하려고 합니다.

  • 첫 번째 설계: API Polling 기반 아키텍처
    • 첫 번째 설계의 한계
  • 두 번째 설계: ReplyingKafkaTemplate 기반 아키텍처
    • 두 번째 설계의 한계
  • 최종 설계: API + Kafka를 결합한 하이브리드 요청-응답 아키텍처


첫 번째 설계: API Polling 기반 아키텍처

첫 설계안은 Microsoft Azure의 아키텍처를 참고하여, API Polling 기반 비동기 아키텍처로 설계했습니다.

API Polling 기반 아키텍처
1차 설계: API Polling 기반 아키텍처
  1. 클라이언트가 발주 API를 호출합니다.
  2. 서버는 요청 이력을 생성하고, 이력 ID를 포함한 메시지를 Kafka로 발행합니다. (컨슈머에 있던 이력 적재 단계를 앞으로 배치함)
  3. 서버는 클라이언트에게 요청 이력 ID를 응답합니다.
  4. 발주 처리 Consumer가 요청 메시지를 수신하여 발주 처리를 수행합니다.
  5. 발주 처리 결과를 요청 이력에 업데이트합니다. (예외 발생 시, 데드레터 메시지 처리 및 알림)
  6. 클라이언트는 요청 이력 결과 조회 API를 통해 처리 결과를 확인합니다.

이 구조를 택한 이유는 요청자로 하여금, 최소한의 의존성을 갖도록 하기 위함이었습니다.
EIP는 메시징 기반 아키텍처를 소개하는데, 이는 별도 메시지 파이프라인이 필요한 구성입니다. 반면 Azure에서 제시하는 아키텍처는 API만으로도 구성 가능해 큰 장점으로 다가왔습니다. 아키텍처 구조도 복잡하지 않아, 브랜드원 개발자에게 발주 서비스 구조를 설명하기가 쉬웠습니다.

물론, 이 구조는 클라이언트가 결과가 나올 때까지 주기적으로 API를 호출(polling) 해야 하는 단점이 있었습니다. 호출 주기에 따라 반영 속도가 지연되거나 발주 서비스에 부하를 줄 수도 있었습니다.

그럼에도 불구하고, 트래픽을 감안했을 때, 실행 불가능한 구조는 아니었기에 충분히 운용 가능한 설계라고 생각했습니다.

⚠️ 첫 번째 설계의 한계: 중복 메시지 처리

하지만, Kafka의 at-least-once 전달 보장 특성 때문에 구조 변경이 불가피해졌습니다.

Kafka는 배포, 스케일 인/아웃 등으로 Consumer Group의 리밸런싱이 발생할 수 있고, 이로 인해 요청 메시지가 재처리될 수 있습니다.
발주 처리는 멱등(idempotent)하지 않아서 동일한 메시지가 두 번 처리되면, 같은 상품이 두 번 발주되어 중대한 문제가 발생할 수 있습니다.

보통, 중복 처리를 방지하려면 두 가지 선택지가 있습니다.

  1. 처리를 재수행해도 멱등적인 결과를 낼 수 있도록 발주 처리 로직을 수정한다.
  2. 중복 자체를 차단, 한 번 처리한 요청은 다시 처리되지 않도록 메시지를 필터링한다.

첫 번째 방식은, 발주 처리 로직 자체를 수정해야 하는데, 기존 구조를 깨야하기 어려움이 있었습니다. 이에, 중복을 차단하는 두 번째 방식을 택하게 됩니다.

두 번째 방법의 차단 방법은 대표적으로 Database의 Unique 제약 조건을 사용한 것인데요. 발주 처리의 경우 구조상 이 제약 조건을 사용할 수 없었습니다. 이를 위해, 이력 적재 단계를 다시 Producer(API 호출부) → Consumer로 이전했습니다.
요청 메시지의 토픽(구분자), 파티션, 오프셋 값을 기준으로 Unique Index 기준을 설정하고, 이를 구성한 요청 이력을 생성합니다. 이렇게 구성하게 되면, 동일한 Index를 가진 메시지가 처리될 경우, 예외가 발생하여 커밋되지 못하도록 처리할 수 있습니다. 이를 통해, 동일 요청 메시지가 재처리되지 않도록 하였습니다.

변경된 1차 설계
변경된 1차 설계

그러나, 이 구조 변경은 요청 이력 ID를 API 응답에서 반환할 수 없게 만들었고, 그에 따라 기존에 사용하던 요청 이력 조회 API를 통한 결과 확인도 불가능해졌습니다. 즉, 클라이언트 입장에서 요청과 응답을 연결하는 식별자가 사라진 상황이 되었습니다.

위 이유로, API 기반 폴링 구조는 포기하고 다시 설계를 진행했습니다.

두 번째 설계: ReplyingKafkaTemplate 기반 아키텍처

두 번째 설계에서 검토한 기술은 Kafka의 공식 템플릿인 ReplyingKafkaTemplate입니다. 이 템플릿은 request-reply 시맨틱을 지원하며, Kafka 기반의 요청-응답 구조를 간편하게 구현할 수 있도록 도와줍니다.

특히 sendAndReceive 메서드를 사용하면, 마치 WebClient로 API를 호출하듯 요청에 대한 응답을 RequestReplyFuture 형태로 수신할 수 있습니다.

public RequestReplyFuture<K, V, R> sendAndReceive(ProducerRecord<K, V> record);

public RequestReplyFuture<K, V, R> sendAndReceive(ProducerRecord<K, V> record, @Nullable Duration replyTimeout);

이를 활용하면 Kafka 기반의 요청-응답 아키텍처를 손쉽게 구성할 수 있을 것으로 기대했습니다. 실제로 아래와 같이 아키텍처를 설계해보았습니다.

ReplyingKafkaTemplate 기반 아키텍처
2차 설계: ReplyingKafkaTemplate 기반 아키텍처

이 방식은 첫 번째 설계보다 훨씬 단순하고 명확해 보였습니다. 다만, 각 백오피스가 이 템플릿을 사용하려면 Kafka 관련 의존성과 설정을 직접 포함해야 하며, 무엇보다 ReplyingKafkaTemplate의 동작 방식에 대한 충분한 이해 없이 도입하는 것은 위험할 수 있었습니다.

따라서, 실제로 이 설계가 현실적인지, 그리고 안정적으로 동작할 수 있는지 직접 동작 방식부터 살펴보며 검토해보기로 했습니다.

ReplyingKafkaTemplate 톺아보기

ReplyingKafkaTemplate가 어떻게 요청-응답 패턴을 구현할 수 있는지, 하나씩 살펴보겠습니다.

1. 요청 메시지 발행 - ReplyingKafkaTemplate 사용

먼저, 해당 템플릿을 활용해 요청을 발행하는 ReplyingKafkaProducer 서비스를 구성해보겠습니다. 이 서비스는 sendAndReceive 메서드를 통해 요청을 전송하고, 그에 대한 응답을 RequestReplyFuture 형태로 비동기 수신이 가능합니다.

/* ReplyingKafkaProducer */

private final ReplyingKafkaTemplate replyingKafkaTemplate;

...

public RequestReplyFuture<ConsumerRecord> sendAndReceive(...) {

	...

	RecordHeaders headers = new RecordHeaders();
	// 응답 토픽 설정
	headers.add(KafkaHeaders.REPLY_TOPIC, replyTopic.getBytes(StandardCharsets.UTF_8));

	// 요청 메시지 생성
	ProducerRecord producerRecord = new ProducerRecord(...);

	return replyingKafkaTemplate.sendAndReceive(producerRecord, Duration.ofSeconds(REPLY_TIMEOUT));
}

sendAndReceive 메서드를 통해 요청 메시지를 전송할 때, 반드시 메시지 헤더에 다음 두 가지 항목을 설정해야 합니다:

  • KafkaHeaders.REPLY_TOPIC
    • 응답 메시지를 받기 위한 응답 토픽입니다.
    • 이 토픽은 replyContainer 빈을 설정할 때에도 동일하게 구독 설정이 되어 있어야 합니다.
  • KafkaHeaders.CORRELATION_ID (⭐️ 아주 중요!)
    • 요청과 응답을 연결하는 식별자입니다.
    • 이 값은 템플릿 내부에서 기본적으로 UUID로 자동 설정되며, 요청과 응답의 연결 고리를 담당하는 핵심 요소입니다.

2. 응답 메시지 발행 - @SendTo 사용

요청 메시지를 컨슈머가 수신하면, 해당 메시지에 대한 처리를 수행한 후 응답 메시지를 전송해야 합니다. 이 과정은 @SendTo 어노테이션을 활용해 아래와 같이 간단하게 구현할 수 있습니다.

/* PurchaseOrderListener */

@KafkaListener(
	topics = ["purchase-order.topic"],
	containerFactory = "..."
)
@SendTo
fun onMessage(...): ReplyMessage { ... }

요청 메시지 헤더에 담긴 CORRELATION_ID 값은 응답 메시지로 복제된 후, 응답 토픽으로 함께 전송되게 됩니다.

Tip
Message<?> 또는 Collection<Message<?>>를 응답 포맷으로 설정한 경우, 응답 메시지 헤더를 명시적으로 설정해야 합니다.

Tip
@SendTo가 동작하기 위해선 KafkaListenerContainerReplyKafkaTemplate 설정이 추가되어 있어야 합니다.

3. 응답 메시지 수신 및 연결 - Reply Consumer

응답 메시지가 발행되면, ReplyingKafkaTemplate 내부의 replyContainer가 해당 메시지를 수신합니다. 이때 메시지의 CORRELATION_ID를 기준으로, 요청 당시 생성한 RequestReplyFuture를 찾아 응답 데이터를 설정함으로써 요청-응답 흐름이 완성됩니다.

/* ReplyingKafkaTemplate */

public void onMessage(List<ConsumerRecord<K, R>> data) {
    data.forEach(record -> {
       Header correlationHeader = record.headers().lastHeader(this.correlationHeaderName);

		...

       else {
          // 요청 future 가져옵니다.
          RequestReplyFuture<K, V, R> future = this.futures.remove(correlationId);

          if (future == null) {
             logLateArrival(record, correlationId);
          }
          else {
	         ...
             future.set(record);
          }
       }
    });
}

🎁🎁🎁 ReplyingKafkaTemplate에 대해 조금 더 알고싶다면 펼쳐보세요. 🎁🎁🎁

ReplyingKafkaTemplate 구성 시 고려사항

ReplyingKafkaTemplate의 내부 replyContainer에 다음과 같은 설정을 권장합니다:

  1. 서버 인스턴스별 고유한 group.id 설정

    • 서버 인스턴스마다 고유한 group.id(예: UUID)를 부여해야 리밸런싱을 방지할 수 있습니다.
    • 동일한 group.id를 사용하는 경우, 배포나 오토 스케일링 시 컨슈머 리밸런싱이 발생하게 됩니다.
  2. auto.offset.reset=latest 설정

    • earliest로 설정 시, 서버 구동 이전의 응답 메시지까지 모두 컨슘하게 되어 불필요한 처리 비용이 발생합니다.
    • latest로 설정하면, 구동 이후에 도착한 응답 메시지만 처리할 수 있습니다.
  3. Transactional Sementic과 병행 사용 불가

    • 위 시맨틱을 위해 isolation.level=read_committed로 설정되어 있는 경우, 요청을 전송했지만 커밋되지 않은 메시지이므로 수신할 수 없습니다.
    • 위 시맨틱을 같이 사용해야 한다면, ReplyingKafkaTemplate 외 다른 방식으로 구현해야 합니다.

공유 응답 토픽 (Shared Reply Topic)

ReplyingKafkaTemplate은 요청자 서버 내에서 요청과 응답을 모두 처리하는 구조입니다. 따라서 요청자 서버 수가 늘어날수록 요청 및 응답 토픽에 붙는 프로듀서와 컨슈머 수도 증가하게 됩니다.

그렇다면 만약, 여러 요청자 서버가 동작하는 상황에서 각 요청에 대한 응답이 정확히 해당 요청자 서버로 전달되도록 하려면 어떻게 해야 할까요? 예를 들어, 아래와 같은 상황을 보겠습니다.

2개의 요청자 서버가 요청하는 경우
2개의 요청자 서버가 요청하는 경우

위와 같이, A 요청자 서버에서 발주 요청 메시지를 발행한 경우, 응답자 서버에선 발주 처리를 수행할 것이고, 처리 결과가 담긴 응답 메시지는 응답 토픽으로 발행될 것입니다.
응답 토픽을 구독하는 replyContainerA 요청자 서버 뿐만 아니라, B 요청자 서버도 있기 떄문에, 메시지는 B 요청자에게도 갈 수 있습니다.
그러면, A 요청자 서버는 응답 메시지를 못 받게 되지 않을까요?

이 문제는 서버 인스턴스별 group.id 를 동일하게 구성한 여부에 따라 나뉘게 됩니다.

1. 동일한 group.id 로 구성한 경우

이 경우, 응답 메시지가 엉뚱한 요청자에게 갈 수 있습니다. 이를 방지할 수 있는 방법은 TopicPartitionOffset 설정을 통해 각 인스턴스 별로 서로 다른 파티션을 각각 할당해주는 것입니다.

요청자 수만큼 토픽 파티션을 각각 구독합니다
요청자 수만큼 토픽 파티션을 각각 구독합니다

2. 서로 다른 group.id를 구성한 경우

이 경우, 응답 메시지는 모든 요청자에게 수신됩니다. 따라서, 요청자는 요청에 대한 응답 메시지를 반드시 수신받을 수 있습니다.

단일 공유 응답 토픽으로 모든 요청 서버 인스턴스가 구독합니다
단일 공유 응답 토픽으로 모든 요청 서버 인스턴스가 구독합니다
그러나, 요청하지 않은 요청자도 불필요한 응답 메시지를 수신받기 때문에, 이를 무시 처리하기 위해 처리가 필요한데요. 이런 무시 처리를 하기 위해 사용하는 것이 `공유 응답 토픽` 설정입니다.

ReplyingKafkaTemplate 빈을 설정할 때, sharedReplyTopic 설정을 활성화 하면 공유 응답 토픽을 사용할 수 있습니다.

/* KafkaConfig */

@Bean
public ReplyingKafkaTemplate replyingKafkaTemplate(
   ProducerFactory producerFactory,
   ConcurrentMessageListenerContainer replyContainer
) {
   ReplyingKafkaTemplate template = new ReplyingKafkaTemplate<>(producerFactory, replyContainer);
   template.setSharedReplyTopic(true);
   return template;
}

sharedReplyTopic 활성화 시, 해당 메시지는 Debug 로그만 남기고 무시됩니다.

/* ReplyingKafkaTemplate */

protected void logLateArrival(ConsumerRecord<K, R> record, CorrelationKey correlationId) {
   if (this.sharedReplyTopic) {
	   this.logger.debug(() -> missingCorrelationLogMessage(record, correlationId));
   }
   else {
	   this.logger.error(() -> missingCorrelationLogMessage(record, correlationId));
   }
}

⚠️ 두 번째 설계의 한계: 메시지 유실로 인한 재처리 가능성

ReplyingKafkaTemplate은 요청 메시지 발행 시, 요청 Future를 내부 Map에 저장하고, 응답 메시지가 도착하면 Map으로부터 해당 Future를 가져와 처리하는 구조입니다.

/* ReplyingKafkaTemplate.java */

private final ConcurrentMap<CorrelationKey, RequestReplyFuture<K, V, R>> futures = new ConcurrentHashMap<>();

...

@Override
public RequestReplyFuture<K, V, R> sendAndReceive(ProducerRecord<K, V> record, @Nullable Duration replyTimeout) {

	...

    RequestReplyFuture<K, V, R> future = new RequestReplyFuture<>();
    // 요청 future를 내부 Map에 저장
    this.futures.put(correlationId, future);

	...

    return future;
}

이는 요청 정보를 메모리에 올려놓고 사용하는 것이기 때문에 유실 가능성이 있습니다.

  1. 응답 메시지가 replyTimeout 이내 수신되지 못한 경우
    • 내부 스케쥴러에 의해 Map에서 요청 future는 삭제됩니다.
    • 발주 서비스 특성 상, 발주량이 큰 요청을 처리 시, 타임아웃을 넘길 가능성이 있기 때문에 요청을 유실할 수 있습니다.
  2. 배포 또는 스케일인과 같은 이벤트 시, Graceful Shutdown에도 응답 메시지를 수신하지 못한 경우
    • 요청 future는 결국 삭제되고, 동일한 리스크를 갖습니다.

이 상황이 발생되면, 클라이언트는 요청에 대한 응답을 수신받지 못했기 때문에 다시 같은 발주 요청을 수행할 가능성이 있습니다.
이는 곧 중복 발주가 될 수 있어, 2차 아키텍처도 도입하지 않는 것으로 결정했습니다.

최종 설계: API + Kafka를 결합한 하이브리드 요청-응답 아키텍처

최종적으로 설계한 아키텍처는 요청은 API로 응답은 Kafka Consumer로 분리하여 아래와 같이 구현했습니다.

API + Kafka 기반 아키텍처
최종 설계: API + Kafka 기반 아키텍처

대체적으로 ReplyingKafkaTemplate의 구조를 답습하였고, 일부 커스터마이징을 진행했습니다. 요청-응답 간 연결 역할을 하던 CORRELATION_ID를 참고하여, 응답 메시지 내 요청 메시지의 식별자를 두어 연결했습니다.

  • requestKey : 요청 키
  • source: 출처 (e.g. 브랜드원, 올리브원)

이 식별자는 브랜드원 DB에도 저장되어 있기 때문에 영속적입니다. 2차 설계처럼 휘발되지 않고 응답 메시지별로 요청에 대한 처리 결과를 연결하여 반영할 수 있습니다.

응답 메시지 컨슈머 컨테이너에 대한 설정은 ReplyingKafkaTemplatereplyContainer처럼 제약이 있지 않습니다. 동일한 Consumer Group을 사용할 수 있고, 오프셋을 earliest로 설정해 처리 유실을 방지할 수 있으며, Transactional Sementic 또한 자유롭게 사용할 수 있습니다.

예외 처리 전략: DltListener + Reply Message

메시지 처리 과정에서 예외가 발생할 경우, Kafka에서는 보통 죽은 메시지로 판단하여 DLT(Dead Letter Topic)로 전송하고, 그 다음 오프셋의 메시지를 처리합니다. 그런데, 요청-응답 패턴을 사용하는 이 구조에서 발주 처리 시 예상치 못한 예외가 발생하면 어떻게 될까요?
요청자는 API로 요청해서 성공 응답을 이미 받은 상태이기 때문에, 응답이 올 것을 기대하지만, 실제로 응답은 오지 않아 무한 대기하게 되는 상황이 발생할 수 있습니다. 이를 방지하기 위해서는 어떻게 할 수 있을까요?

Timeout 설정하기

ReplyingKafkaTemplatereplyTimeout을 두어 요청 별로 무한 대기를 방지할 수 있는 기능이 있습니다. 현재 설계에서도 요청 타임아웃을 설정할 수 있지만, 발주 처리 프로세스가 요청 발주량에 선형적으로 처리 시간이 늘어나다보니, 발주량이 많은 요청의 경우, 처리가 잘 수행되더라도 설정한 타임아웃으로 인해 실패된 것으로 오인할 수 있기에 좋은 방법은 아니라고 생각했습니다.

DLT 구독하기

DLT 구독해서 실패 메시지 수신하기
DLT 구독해서 실패 메시지 수신하기

정말 간단한 접근으로는 DLT도 요청자 서버에서 구독하면 됩니다. 그럼 예외가 발생하더라도 DLT로 인입된 경우, 요청이 실패됨을 판단할 수 있습니다.
다만, 이 방법은 요청자가 모든 API에 대해서 응답 토픽과 DLT 따로 구독해야만 하기 때문에, 관리 포인트가 증가하는 문제가 있습니다.

실패 응답 메시지 발행하기

그래서 설계한 전략은 DLT Listener에서 실패 응답 메시지를 만들어 응답 토픽으로 발행하는 것입니다.

실패 응답 메시지를 발행해서 Reply Topic으로 수신하기
실패 응답 메시지를 발행해서 Reply Topic으로 수신하기

Kafka는 Dead Letter 발생 시, 예외에 대한 정보와 Origin 정보, 그리고 기존 요청 메시지 헤더 정보까지 복사하여 Dead Letter 메시지 헤더에 담아 같이 전송해줍니다. 이를 활용해서 응답 메시지를 구성하고, 응답 토픽으로 발행하는 것이 가능합니다.

  • KafkaHeaders.REPLY_TOPIC (from. 요청 메시지 헤더)
  • KafkaHeaders.DLT_ORIGINAL_TOPIC: 예외 발생 토픽
  • KafkaHeaders.DLT_EXCEPTION_CAUSE_FQCN: 예외 사유 FQCN(Full Qualified Class Name)
  • KafkaHeaders.DLT_EXCEPTION_MESSAGE: 예외 사유 메시지

바로 아래 Slack 메시지는 위 정보를 활용하여 만든 것입니다. 아래 메시지를 통해서, 실시간으로 어떤 이슈로 Dead Letter가 발생했는지 알 수 있고, 메시지 하단 버튼을 통해 Kafka Admin 페이지 또는 서비스의 데이터독 로그 페이지로 이동하여 빠르게 대응할 수 있습니다.

실패 메시지 예시
실패 메시지 예시


🍷 신규 발주 서비스 도입 후

이번 신규 서비스 개발을 통해, 아래와 같은 개선 성과를 낼 수 있었습니다.

  1. 신규 발주 서비스의 평균 처리 속도는 0.1초 수준이며, 백오피스 별 요청 응답 속도가 98.7% (218초 → 2.8초, 5,000건 기준) 향상되었습니다.
    신규 발주 서비스 평균 처리 시간
    신규 발주 서비스 평균 처리 시간
  2. 브랜드원을 비롯하여, 향후 생겨날 백오피스 서비스에 발주 기능을 유연하게 제공할 수 있게 되었습니다.



✍🏻 정리하며


이번 발주 서비스 개선 프로젝트를 통해, 기존 동기식 처리의 한계를 극복하고 Kafka 기반의 비동기 요청-응답 아키텍처로 성공적인 전환을 이뤄냈습니다. 이 과정에서 저희는 단순히 기능을 구현하는 것을 넘어, 중복 발주와 같은 치명적인 비즈니스 리스크를 차단하고, 향후 다양한 백오피스 환경에서도 안정적으로 발주를 처리할 수 있는 높은 서비스 확장성을 확보하고자 끊임없이 고민했습니다.

물론, 모든 문제를 해결할 수 있는 🚄은총알은 없다는 것을 다시 한번 느꼈습니다. 이번에 적용한 설계가 언제나 정답일거라 생각하지 않으며, 앞으로도 더 좋은 기술이나 방법이 있다면 적극적으로 적용하며 올리브영의 핵심 비즈니스를 뒷받침하는 안정적이고 효율적인 서비스를 만들도록 노력할 계획입니다.

이 글이 대규모 백오피스 시스템의 성능 및 안정성, 그리고 확장성 문제로 고민하는 다른 분들께도 작은 도움이 되었으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다!

올리브영 SCMAsync Request-ReplyKafka
올리브영 테크 블로그 작성 비동기 요청-응답 패턴으로 풀어낸 발주 서비스 개발기
👼🏻
승쭈누 |
Back-end Engineer
개발과 육아, 육아와 개발