주문 도메인에서 아웃박스 패턴과 polling 스케줄러 도입기

2026. 7. 2. 20:23·루퍼스 4기

주문 생성 기능에서 사용자에게 주문 완료 알림을 발송해야 하는 로직이 추가된다면 

주문 생성 로직은 다음과 같은 순서로 이루어진다.

  • 재고 차감
  • 쿠폰 사용
  • 주문 생성
  • 알림 발송

알림 서버의 요청 처리 시간이 3초 소요된다고 가정하면,

주문 생성 과정에서 알림 발송을 동기적으로 처리할 때 사용자는 주문이 완료되기까지 3초를 더 기다려야 한다.

이처럼 주문의 핵심 기능이 부가 기능에 의해 영향을 받는 것은 바람직하지 않다.

 

여기서 재고 차감, 쿠폰 사용, 주문 생성은 주문을 완료하기 위해 반드시 성공해야 하는 핵심 비즈니스 로직이지만, 알림 발송은 주문 완료와 동시에 수행될 필요 없이 사용자에게 한 번만 발송되면 된다. 

사용자는 주문이 정상적으로 완료되면 주문 알림은 몇 초 정도 늦게 도착하더라도 큰 문제가 되지 않는다.

 

그렇다면 주문 생성에서 알림 서비스를 별도의 이벤트로 분리하는 것을 고려해 볼 수 있다.

 

주문 서비스는 주문 생성 이후 OrderCreated 이벤트만 발행하고, 알림 서비스는 해당 이벤트를 소비하여 독립적으로 사용자 알림을 처리하는 구조로 변경할 수 있다.

 

하지만 여기에도 문제가 있다.

만약 주문은 성공적으로 생성되었는데 이벤트 발행 과정에서 장애가 발생한다면 어떻게 될까?

사용자는 주문은 정상적으로 완료했지만 끝내 알림을 받지 못하는 상황이 발생할 수 있다.

그러면 주문 생성과 이벤트 발행 성공을 같은 트랜잭션에서 보장해야 하는데 어떻게 할 수 있을까? 

 

이처럼 데이터베이스에는 주문이 저장되었지만 이벤트는 발행되지 않는 문제를 해결하기 위해 사용하는 것이 바로 아웃박스(Outbox) 패턴이다.

아웃박스 패턴

아웃박스 패턴은 주문 데이터와 이벤트를 하나의 트랜잭션으로 함께 저장한다.

 

이후 별도의 프로세스가 아웃박스 테이블을 읽어 이벤트를 발행하기 때문에, 이벤트 발행에 일시적인 장애가 발생하더라도 재시도를 통해 결국 이벤트가 발행된다는 것을 보장할 수 있다.

 

즉, 주문 생성의 성능을 유지하면서도 이벤트가 유실되지 않는다는 보장을 제공하는 것이 아웃박스 패턴의 핵심이다.

/* 주문 생성 로직 */
@Transactional
public OrderInfo createOrder(String orderNumber, Long userId, List<OrderItemInput> items, Long userCouponId) {
    List<OrderLine> lines = new ArrayList<>();
    for (OrderItemInput input : OrderItemInput.merge(items)) {
        ProductStockModel stock = productStockService.decrease(input.stockId(), input.quantity());
        lines.add(new OrderLine(stock.getId(), stock.getProduct().getId(),
                stock.getProduct().getName(), stock.getPrice(), input.quantity()));
    }
    long originalTotal = lines.stream().mapToLong(OrderLine::amount).sum();
    UserCouponService.UseResult amounts = userCouponService.use(userCouponId, userId, originalTotal);
    try {
        OrderInfo info = OrderInfo.from(orderService.placeOrder(new OrderModel(orderNumber, userId, userCouponId), lines,
                new Money(amounts.originalAmount()), new Money(amounts.discountAmount())));
        /* 아웃박스 이벤트 발행 */
        eventPublisher.publishEvent(new OrderPlacedEvent(info.id(), info.orderNumber(), info.userId(), info.totalAmount()));
        return info;
    } catch (DataIntegrityViolationException e) {
        throw new CoreException(ErrorType.CONFLICT, "이미 처리된 주문입니다.");
    }
}

@EventListener
public void handleOrderPlaced(OrderPlacedEvent event) {
    try {
        String payload = objectMapper.writeValueAsString(event);
        outboxEventRepository.save(new OutboxEvent(
                OrderPlacedEvent.class.getName(), orderPlacedTopic, event.orderNumber(), payload));
    } catch (JsonProcessingException e) {
        throw new CoreException(ErrorType.INTERNAL_ERROR, "주문 생성 이벤트 직렬화에 실패했습니다.");
    }
}

아웃박스 테이블에 직접 데이터를 저장할 수도 있지만, 주문 서비스가 아웃박스 구현을 알 필요는 없다.

따라서 이벤트를 발행한다는 의도를 명확하게 표현하기 위해 ApplicationEventPublisher를 사용했고, 실제 아웃박스 저장은 이벤트 리스너가 담당하도록 분리했다.

 

ApplicationEventPublisher.publishEvent()를 호출하면 ApplicationEventMulticaster가 해당 이벤트 타입을 처리할 수 있는 @EventListener를 찾아 호출한다.

 

@EventListener 자체는 호출한 스레드에서 즉시 실행되며 @Transactional 메서드 안에서 publishEvent()를 호출하면 현재 트랜잭션 안에서 리스너가 실행되고 같은 호출 흐름에서 실행되기 때문에 주문 생성과 알림 발송을 하나의 트랜잭션으로 묶을 수 있다. 

 

또한 해당 이벤트를 처리하는 리스너가 하나도 등록되어 있지 않더라도 예외나 기본 로그는 발생하지 않는다. 이벤트는 단순히 무시되므로, 이벤트가 정상적으로 발행되고 처리되는지 테스트를 통해 검증하는 것이 중요하다.

 

만약 이런 이벤트 발행을 트랜잭션 외부에서 실행하고자 한다면 @TransactionalEventListener를 사용해야 한다. 

@TransactionalEventListener(
    phase = TransactionPhase.AFTER_COMMIT
)
public void publishOutbox(...) {

}
Phase 실행 시점 사용 사례
BEFORE_COMMIT 트랜잭션 커밋 직전 커밋 전에 마지막 검증이나 후처리가 필요한 경우
AFTER_COMMIT (기본값) 트랜잭션이 정상적으로 커밋된 후 알림 발송, 메시지 발행, 캐시 갱신 등 커밋이 완료되어야 하는 작업
AFTER_ROLLBACK 트랜잭션이 롤백된 후 실패 로그 기록, 보상 작업(Compensation), 모니터링
AFTER_COMPLETION 커밋 또는 롤백 여부와 관계없이 트랜잭션 종료 후 리소스 정리, 공통 후처리

phase 옵션을 바꿔서 사용할 수 있다. 

이벤트 전달 방식 : Polling VS CDC

아웃박스 패턴을 적용하면 “DB 트랜잭션과 이벤트 발행의 일관성” 문제는 해결할 수 있지만 어떻게 외부 시스템으로 전달할 수 있을까? 

 

1. Polling 방식 

첫 번째로 Polling 방식은 일정 주기로 outbox 테이블을 조회해서 미처리 이벤트를 가져와 처리하는 방식이다.

핵심은 DB를 처리 안된 행이 있으면 이벤트를 꺼내 발행한다. 

2. CDC (Change Data Capture)

두 번째로 CDC (Change Data Capture) 방식은 DB 변경 로그(binlog, WAL)를 읽어서 변경을 감지하는 방식이다.

즉, DB 변경로그 (binlog, WAL)를 읽어서 변경을 감지하여 이벤트를 발행에 목적을 둔다. 

이 두 가지 방식 중에서 나는 Polling 방식을 선택했다. 선택의 이유는 명확하다. 초기 단계에서는 구조의 복잡도를 높이기보다는 안정적으로 동작하는 단순한 구조가 더 중요했기 때문이다.

 

CDC 방식은 확장성과 실시간성 측면에서 분명한 장점을 가진다. DB 변경 로그(binlog, WAL)를 기반으로 거의 실시간으로 이벤트를 전달할 수 있고, Kafka와 같은 스트리밍 인프라와 결합했을 때 높은 확장성을 확보할 수 있다. 하지만 그만큼 Kafka, Debezium과 같은 추가 인프라가 필요하고, 로깅 추적 및 운영 복잡도 역시 크게 증가한다.

 

반면 Polling 방식은 Spring과 DB만으로도 충분히 구현할 수 있다. 아웃박스 패턴의 핵심이 “트랜잭션 정합성을 보장한 이후 안정적으로 이벤트를 전달하는 것”이라는 점을 고려하면, 초기 단계에서는 과하지 않은 선택이라고 판단했다.

Polling 방식 구현
outbox.relay.fixed-delay-ms=3
batchSize=100
SEND_TIMEOUT_SECONDS=5

@Scheduled(fixedDelayString = "${outbox.relay.fixed-delay-ms}")
@Transactional
public void relay() {
    List<OutboxEvent> pendingEvents = outboxEventRepository.findPending(batchSize);
    log.info("주문 outbox 이벤트 스케줄러 진행중");
    for (OutboxEvent event : pendingEvents) {
        try {
            Class<?> payloadType = Class.forName(event.getEventType());
            Object payload = objectMapper.readValue(event.getPayload(), payloadType);
            // 타임아웃에 걸린다면 예외상황으로 빠진다.
            kafkaTemplate.send(event.getTopic(), event.getMessageKey(), payload)
                    .get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            event.markSent();
        } catch (Exception e) {
            log.warn("outbox 이벤트 발행 실패 [id={}, eventType={}]: {}", event.getId(), event.getEventType(), e.getMessage());
        }
    }
}

스케줄러는 아웃박스 테이블에 들어간 데이터를 조회해서, 이를 카프카 프로듀서의 send() 함수를 통해 브로커로 전달하는 것까지 책임진다.

 

send()는 호출 즉시 결과를 리턴하지 않는다. 대신 Future <RecordMetadata>를 반환하는데, 실제로 브로커가 메시지를 잘 받았는지 확인하려면 이 Future에 get()을 호출해서 응답이 올 때까지 기다려야 한다. 즉 메시지를 보냈다와 브로커가 응답했다 사이에는 한 단계가 더 있는 셈이다.

 

문제는 이 get()이 설정값이 없으면 무한정 대기할 수 있다는 점이다. 브로커와의 네트워크가 끊기거나 응답이 계속 지연되면, 스케줄러 스레드가 그대로 블로킹 돼버릴 수 있다. 그래서 get(timeout, TimeUnit) 형태로 대기 타임아웃을 걸어주는데, 여기서 헷갈리기 쉬운 부분이 하나 있다.

 

get()에 거는 타임아웃(사용자 타임아웃)과 카프카 내부에서 재시도 실패를 판단하는 타임아웃(delivery.timeout.ms, 요청 타임아웃, 내부 타임아웃)은 서로 별개로 동작한다.

사용자 타임아웃 (get 함수 안 설정) 애플리케이션 코드가 "이 시간 안에 응답 안 오면 나는 더 이상 안 기다리겠다"고 정하는 값. get()이 이 시간을 넘기면 TimeoutException이 던져지고, 스케줄러는 이 메시지를 실패로 처리하고 넘어간다.
요청 타임아웃 (request.timeout.ms) 프로듀서가 브로커로 보낸 개별 요청 하나에 대한 응답 대기 시간(기본 30초). 이 시간 안에 응답이 없으면 그 요청은 실패로 간주되어 재시도 대상이 된다. 단, 브로커가 명시적 에러 응답을 빨리 주면 이 시간을 다 기다리지 않고도 즉시 재시도가 발동한다.
내부 타임아웃 (delivery.timeout.ms) 프로듀서 내부에서 재시도를 포함해 "이 메시지 전송을 최종적으로 포기하는" 시점을 정하는 값(기본 2분).

이 둘은 완전히 다른 층위에서 동작하기 때문에, get()이 타임아웃으로 예외를 던졌다고 해서 카프카 프로듀서가 해당 메시지 전송을 포기한 게 아닐 수 있다.

 

프로듀서는 여전히 내부적으로 재시도를 이어가고 있을 수 있고, 애플리케이션은 이미 그 결과를 기다리지 않고 다음 배치로 넘어간 상태일 수 있다. 이 간극을 인지하지 못하면, 분명 실패 처리했는데 나중에 보니 브로커에는 메시지가 들어가 있는 상황이 생길 수 있다.

카프카 응답의 전달 기준 — acks

이제 카프카가 언제 응답을 보냈다고 판단하는지, 그 기준을 살펴보자.

값 동작 특징
0 응답을 기다리지 않음 가장 빠르지만 전송 실패해도 알 수 없음. 메시지 유실 가능성 높음
1 리더 파티션에만 기록되면 응답 리더는 받았지만 팔로워 복제 전에 리더가 죽으면 유실 가능
all (-1)  리더 + ISR(In-Sync Replicas) 전체에 기록되면 응답 가장 안전. 대신 지연시간(latency) 증가

acks옵션은 프로듀서가 메시지 전송을 성공으로 판단하기 위해 몇 개의 브로커로부터 확인 응답을 받아야 하는지를 정한다.

acks=0이면 브로커 응답 자체를 기다리지 않으므로 실패를 감지하지 못하고, 카프카 내부 재시도 옵션도 발동하지 않는다.

그런데 이 acks 값을 사용자가 건드리지 않아도 강제로 바꿔버리는 옵션이 하나 있다. 바로 enable.idempotence다.

이 옵션은 리더와 ISR 전체에 기록이 확인되어야만 재시도로 인한 중복을 멱등하게 걸러낼 수 있기 때문에 재처리 시 이미 처리된 메시지를 중복으로 거를 수 있게 내부에서 처리하는 옵션으로 true로 활성화하면 acks가 자동으로 all로 설정된다.

단, enable.idempotence=true인 상태에서 acks를 0이나 1로 명시적으로 지정하면 자동 조정이 아니라 설정 오류 예외가 발생하며 애플리케이션 기동 자체가 실패한다. 자동으로 all이 된다는 얘기는 acks를 아예 지정하지 않았을 때의 얘기고, 서로 충돌하는 값을 명시하면 그냥 에러가 난다.

여기서 한 가지 짚고 넘어가야 할 점은, enable.idempotence가 막아주는 중복은 어디까지나 카프카 프로듀서 내부 재시도로 인한 브로커 레벨의 중복이라는 것이다. 예를 들어 네트워크 오류로 응답을 못 받아서 프로듀서가 같은 메시지를 다시 보내는 경우, 브로커는 이를 감지해 한 번만 기록한다.

 

하지만 애플리케이션 코드에서 실수로 send()를 두 번 호출하는 것처럼, 애플리케이션 레벨에서 발생하는 중복까지는 막아주지 않는다.  즉, idempotence 옵션 하나만 믿고 중복 전송은 신경 안 써도 된다고 생각하면 안 되고, 아웃박스 패턴에서는 별도로 발행 여부를 기록/확인하는 로직이 여전히 필요하다.

 

결론적으로 polling 서버를 구현하면서 내가 설정한 프로듀서 카프카 옵션은 내부 재전송 없이 polling 주기만큼 재시도하게끔 설정했고 request.timeout 옵션은 사용자 타임아웃 옵션보다 약간 빠르게 조정했다. 

spring:
  kafka:
    producer:
      acks: all
      retries: 0
      properties:
        request.timeout.ms: 4500 # 사용자 지정 타임아웃보다 빠르게 실패 처리
Polling에서 왜 비동기로 결과를 받지 않는지
kafkaTemplate.send(event.getTopic(), event.getMessageKey(), payload)
.whenComplete((result, ex) -> {
    if (ex == null) {
        event.markSent();
    } else {
        // 실패 처리 로직
        log.error("메시지 발행 실패: topic={}, key={}", event.getTopic(), event.getMessageKey(), ex);
    }
});

 

카프카에 결과를 받는 방법 중에 whenComplete 함수를 사용해 비동기로 요청을 보낸 뒤, 결과가 오면 그때 처리하는 방법도 존재한다.

 

하지만 이 방식을 사용하지 않은 이유는 스케줄러의 동작 방식과 맞물려 있다.

 

@Scheduled fixedDelay는 이전 작업이 완료된 시점부터 지정한 시간만큼 지난 뒤 다음 작업을 실행한다.

그런데 비동기로 호출하면 send()를 호출한 직후 스케줄러 메서드 자체는 바로 종료된 것으로 처리되고, 실제 markSent() 호출(브로커 응답 확인 후 콜백 안에서 실행됨)은 아직 일어나지 않은 상태일 수 있다.

 

이 상태에서 다음 스케줄러 사이클이 시작되면, 아직 미발행 상태로 남아있는 같은 아웃박스 레코드를 다시 조회해서 중복으로 발행할 위험이 생긴다.

 

반면 동기로 결과를 기다리면, markSent()까지 완료된 뒤에야 스케줄러 메서드가 종료되므로 다음 사이클은 항상 이전 배치의 발행 상태가 정확히 반영된 상태에서 시작된다. 이 안정성을 위해 동기 방식을 선택했다.

 

하지만 현재 코드에서는 get으로 하나씩 기다리는 것은 비 효율 적이다. 최악의 경우 타임아웃 * 전체 개수만큼 기다리게 된다. 

@Scheduled(fixedDelayString = "${outbox.relay.fixed-delay-ms}")
@Transactional
public void relay() {
    List<OutboxEvent> pendingEvents = outboxEventRepository.findPending(batchSize);
    log.info("주문 outbox 이벤트 스케줄러 시작 [count={}]", pendingEvents.size());

    // 전송은 전부 먼저 날리고(논블로킹), 결과 확인만 순서대로 함
    // → worst case가 N x 타임아웃이 아니라 가장 느린 건 1개 분량으로 줄어듦
    List<Map.Entry<OutboxEvent, CompletableFuture<SendResult<Object, Object>>>> sendings = new ArrayList<>();
    for (OutboxEvent event : pendingEvents) {
        try {
            Class<?> payloadType = Class.forName(event.getEventType());
            Object payload = objectMapper.readValue(event.getPayload(), payloadType);
            sendings.add(Map.entry(event, kafkaTemplate.send(event.getTopic(), event.getMessageKey(), payload)));
        } catch (Exception e) {
            log.warn("outbox 이벤트 발행 실패 [id={}, eventType={}]: {}", event.getId(), event.getEventType(), e.getMessage());
        }
    }

    for (Map.Entry<OutboxEvent, CompletableFuture<SendResult<Object, Object>>> entry : sendings) {
        OutboxEvent event = entry.getKey();
        try {
            // 타임아웃에 걸린다면 예외상황으로 빠진다.
            entry.getValue().get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            event.markSent();
        } catch (Exception e) {
            log.warn("outbox 이벤트 발행 실패 [id={}, eventType={}]: {}", event.getId(), event.getEventType(), e.getMessage());
        }
    }
    log.info("주문 outbox 이벤트 스케줄러 완료");
}

결과적으로 비동기 요청 후 결괏값을 확인하는 방식을 택했다.

markSent()는 여전히 스케줄러 메서드가 끝나기 전, 동기적으로 완료되므로 중복 발행 위험은 동기 방식과 동일하게 방지되고, 동시에 전송 자체는 병렬로 처리되어 순수 동기 방식 대비 처리 시간을 크게 줄일 수 있었다.

 

다만 이 방식도 완벽한 해결책은 아니다. 앞서 살펴봤듯 request.timeout.ms가 지나 실패로 판단됐더라도 브로커에는 실제로 메시지가 기록돼 있을 가능성이 있고, 이 경우 다음 스케줄러 사이클에서 같은 메시지가 재발행될 수 있다. 즉 이 구조는 중복 발행을 완전히 제거하는 게 아니라 확률을 크게 낮추는 개선에 가깝다.

그래서 컨슈머 쪽에서 메시지 ID 기반의 멱등 처리를 함께 두는 것을 전제로 설계했다.

 

저작자표시 (새창열림)

'루퍼스 4기' 카테고리의 다른 글

주문상품 랭킹 시스템의 장애 대응 설계기  (0) 2026.07.17
주문 폭주를 막는 대기열, 배치 사이즈는 어떻게 정해지나  (0) 2026.07.10
주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다.  (0) 2026.06.26
인덱스 적용부터 반정규화까지, 조회 성능 개선 실험  (0) 2026.06.19
루퍼스 4주차 WIL (동시성 문제 해결)  (0) 2026.06.14
'루퍼스 4기' 카테고리의 다른 글
  • 주문상품 랭킹 시스템의 장애 대응 설계기
  • 주문 폭주를 막는 대기열, 배치 사이즈는 어떻게 정해지나
  • 주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다.
  • 인덱스 적용부터 반정규화까지, 조회 성능 개선 실험
LIMTAEYANG
LIMTAEYANG
오늘 할 코딩을 내일로 미루지 말자
  • LIMTAEYANG
    lty's Blog
    LIMTAEYANG
  • 전체
    오늘
    어제
    • 분류 전체보기 (80)
      • 웹개발 (23)
        • JAVA (1)
        • Spring (6)
        • JavaScript (7)
        • css (2)
        • Vue (1)
      • 클라우드 (4)
        • AWS (2)
        • MSA (0)
        • Docker (2)
      • 운영 및 배포 (4)
      • Backend (6)
      • Database (2)
      • 명령어 (2)
        • Linux (2)
      • 형상관리 (8)
        • github & git (8)
      • 알고리즘 (1)
      • 개인 프로젝트 (2)
      • CS (11)
        • 소프트웨어 공학 (10)
        • 운영체제 (1)
      • 정보처리기사 (2)
      • 취미활동 (2)
        • 등산 (1)
        • 여행 (0)
        • 글쓰기 (1)
      • 회고 (0)
      • 루퍼스 4기 (13)
  • 블로그 메뉴

    • 홈
    • 개인프로젝트
    • 프로그래밍 언어
    • 운영체제
    • 형상관리
    • 자기개발
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    테스팅
    재고 동시성 차감
    TDD
    CDC 방식
    환경변수
    4주차 WIL
    비관적 락
    reset
    prefilight
    Jenkins
    아웃박스 테이블 polling 방식
    대기열 배치사이즈
    루프팩백엔드4기
    되돌리기
    설계ERD
    CI/CD
    Callback 장애 대응
    대기열 TPS 테스트
    소프트웨어
    설계
    배포 자동화
    낙관적 락
    DDD 주문설계
    PG 정합성
    원자적 연산
    루퍼스 백엔드 코스
    LOOPPAK
    루퍼스 4기 수료 후기
    2주차 회고
    랭킹 레디스 장애 대응
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
LIMTAEYANG
주문 도메인에서 아웃박스 패턴과 polling 스케줄러 도입기
상단으로

티스토리툴바