본문으로 건너뛰기

Outbox 패턴으로 결제 시스템의 일관성 지키기

· 약 15분
lcomment
Server Engineer @ aptplay

근데 AWS SQS를 곁들인

시작에 앞서

 안녕하세요! 에이피티플레이(APTPLAY)에서 서버 개발을 담당하고 있는 고현석 입니다🫡

 지난 2025년 12월, 어시스트핏 CRM에 앱 결제가 도입되었습니다. 운동 시설에서 온라인 결제는 회원에게는 언제 어디서나 간편하게 결제할 수 있다는 편의성을 주고, 센터에게는 매출과 운영 효율을 높여줍니다.

 위와 같이 간단한 설정만으로 해당 운동 시설의 상품을 구매할 수 있습니다. 하지만 사용하는 입장과 다르게 실제로 돈이 오고 가는 결제 시스템 구축에 대하여 개발팀에서는 고민해야 할 부분이 많았습니다. 이번 포스팅에서는 많은 고민 중에서 일관성(Consistency)에 대해 다루어 보려고 합니다.

결제 시스템의 일관성

결제 시스템에서는 흔히 세 가지를 중요시 합니다.

  • 일관성(Consistency): 유저가 결제를 시도했을 때 시스템의 모든 요소가 성공 또는 실패라는 동일한 상태를 인식하고, 데이터의 정합성을 유지해야 한다.
  • 신뢰성(Reliability): 장애/지연/재시도가 있어도 결제 프로세스가 결국 성공하거나(정상 완료), 실패하더라도 안전하게 처리(취소/환불)돼야 한다.
  • 장애 허용성(Fault Tolerance): 일부 컴포넌트가 장애를 일으켜도 전체 결제 플로우가 치명적으로 멈추지 않고, 제한된 기능으로라도 계속 동작하거나 안전하게 중단돼야 한다.

메인 도메인이나 상황 등을 고려한 요구사항은 제각각이지만, 결제 시스템은 어떠한 방식으로든 위 세 가지 요소를 고려하여 설계돼야 합니다. 그럼 이제 어시스트핏의 결제 플로우와 함께 문제가 발생할 수 있는 부분을 이야기 해보겠습니다.

결제 플로우

어시스트핏은 모놀리식 아키텍처로 구성돼 있지만, CRM 내부 비즈니스를 담당하는 서버와 결제를 포함한 외부와의 통신을 담당하는 서버가 분리돼 있는데요, 다음은 어시스트핏의 결제 플로우 입니다.

(설명의 편의를 위해 CRM 서버와 결제 서버로 지칭 하겠습니다.)

일관성이 깨지는 예시

장애 상황에 대한 고려는 모든 시스템 설계에서 중요하겠지만, 결제 시스템은 돈과 관련돼 있기에 더욱 민감할 수밖에 없습니다. 따라서 결제의 일관성과 신뢰성이 깨지지 않도록 조심해야 하는데, 문제가 발생할 수 있는 상황은 대략 다음과 같습니다.

  • CRM 서버나 결제 서버에 당장 회복할 수 없는 문제가 발생했을 때
  • 결제 서버에서 원장 쌓기 등의 트랜잭션이 실패했을 때
  • CRM 서버에서 상품 지급에 실패했을 때

요구사항 분석

 대부분의 결제 시스템에서는 성능, 장애 격리, 재처리, 시스템 간 낮은 결합 등을 위해 kafka 등을 활용하여 비동기 아키텍처를 설계합니다. 하지만 소규모 시스템이나 스타트업에서는 상황이 다릅니다. 초기 단계에서는 트래픽 규모가 크지 않고, 서비스 도메인도 빠르게 변경되며, 운영 인력과 인프라 비용 또한 제한적인 경우가 많습니다.

 이런 환경에서 Kafka와 같은 메시지 브로커를 도입하는 것은 기술적 이점에 비해 운영 복잡도와 비용이 과도하게 증가할 수 있습니다. 또한 비동기 아키텍처는 장애 허용성과 확장성 측면에서는 강력하지만, 그만큼 정합성 관리, 재처리 전략, 메시지 중복/유실 대응, 모니터링 체계 등 추가로 고려해야 할 요소가 많아집니다. 이는 오히려 시스템 전체의 이해도를 낮추고, 개발 및 운영 속도를 저하시킬 수 있습니다.

 따라서 모든 결제 시스템이 처음부터 이벤트 기반 아키텍처를 선택해야 하는 것은 아니며, 현재 서비스의 규모와 조직의 성숙도에 맞는 요구사항을 먼저 정의하는 것이 중요합니다. 다음은 어시스트핏의 규모에 맞게 정의된 요구사항 입니다.

  • 동기 API를 통한 추적 용이한 구조여야 한다
  • 결제 실패 시, 반드시 일관성을 보장해야 한다.
  • 재처리가 가능하되, 멱등성을 보장해야 한다.
  • 과도한 인프라에 의존 없이 점진적으로 확장하는 구조여야 한다.

 이를 바탕으로 개선된 결제 플로우는 다음과 같습니다.

(결제 실패에 대한 플로우만 표현했습니다)

 앞서 정리한 요구사항을 바탕으로 결제 플로우를 개선하면서, 단순히 “결제가 된다 / 안 된다”를 넘어서 실패 상황에서도 시스템이 어떻게 수습되는지가 중요해졌습니다. 특히 결제 승인 이후의 후속 트랜잭션 처리 실패는 동기 처리만으로는 한계가 있었고, 재처리와 장애 격리를 고려하면 비동기 처리의 도입이 불가피한 지점이 존재했습니다. 다만, 앞서 언급했듯이 무거운 메시지 브로커를 도입하기에는 운영 부담이 컸습니다.

 이러한 배경에서 트랜잭션 정합성을 유지하면서도, 필요한 부분만 비동기로 분리할 수 있는 방법을 고민하게 되었고, 그 결과로 선택한 것이 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern)과 AWS SQS를 활용한 구조입니다.

 이제부터는 결제 플로우 안에서 이 두 가지를 어떤 문제를 해결하기 위해, 그리고 어떻게 활용했는지를 구체적으로 살펴보겠습니다.

결제 플로우 일관성 보장하기

enqueue가 끝이 아니라고?

 개선된 결제 플로우에서는 여러 후속 처리가 이어집니다. 예를 들어 결제가 성공하면 결제 상태를 저장하고, 상품 지급이나 이용권 생성과 같은 처리를 수행하거나, 실패 시에는 결제 취소를 요청해야 합니다. 기존에는 이러한 흐름을 하나의 서비스 메서드 안에서 동기적으로 처리하고 있었습니다. 관련된 트랜잭션이 실패하면 매시지 큐에 메시지 전송을 수행하는 방식이었습니다.

@Transactional
public PaymentOrderResult confirmPayment(
PaymentConfirmCommand param,
PaymentResult payment
) {
try {
/* 상품 지급 처리 */
updatePaymentState(paymentResult.pgOrderId(), PaymentStateEventStatus.PAYMENT_SUCCESS);

return paymentResult;
} catch (RuntimeException e) {
final var event = /* 결제 취소 이벤트 생성 */
paymentsCancelEventService.eventPublish(event);

throw new RuntimeException(event.cancelReason().getDescription(), e);
}
}
@RequiredArgsConstructor
@EventHandler
public class PaymentsCancelEventPublishListener {
private final MessageQueue messageQueue;

@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void sendMessage(PaymentsOutboxEvent event) {
messageQueue.send(queueName, event);
}
}

 하지만 이 구조에서는 한 가지 문제가 발생할 수 있습니다. 문제가 발생하여 트랜잭션 롤백에 성공했지만, 이후 메시지 전송(enqueue) 동작이 실패하는 경우입니다. 이 경우 결제는 완료된 상태로 남아 있지만, 결제 취소나 후속 처리가 실행되지 않아 시스템 간 상태 불일치가 발생할 수 있습니다. 결제 도메인에서는 이러한 불일치가 곧 신뢰성 문제로 이어집니다.

 그래서 결제 상태 변경 이후 발생하는 실패 상황에서도 보상 처리가 유실되지 않도록 보장할 수 있는 구조가 필요했고, 그 해결책으로 트랜잭셔널 아웃박스 패턴을 적용하게 되었습니다.

트랜잭셔널 아웃박스 패턴

 트랜잭셔널 아웃박스(Transactional Outbox) 패턴은 DB 트랜잭션과 메시지 발행 사이의 불일치 문제를 해결하기 위한 설계 패턴입니다. 이벤트를 즉시 외부 시스템으로 발행하지 않고, 발행해야 할 이벤트를 outbox 테이블에 DB 트랜잭션으로 먼저 기록하는 방식입니다. 이후 별도의 퍼블리셔가 outbox를 읽어 메시지 시스템으로 전달함으로써, 비즈니스 상태 변경과 이벤트 발행 사이의 정합성을 보장합니다.

 이 패턴의 핵심은 "이벤트를 반드시 남기고, 반드시 처리되게 만든다" 는 점에 있으며, 특히 결제, 정산, 취소와 같이 실패 비용이 큰 도메인에서 안정적인 비동기 처리를 가능하게 해줍니다.

 다음은 트랜잭셔널 아웃박스 패턴을 적용한 코드입니다.

@Transactional
public PaymentOrderResult confirmPayment(
PaymentConfirmCommand param,
PaymentResult payment
) {
try {
/* 상품 지급 처리 */
updatePaymentState(paymentResult.pgOrderId(), PaymentStateEventStatus.PAYMENT_SUCCESS);

return paymentResult;
} catch (Exception e) {
final var event = /* 결제 취소 이벤트 생성 */
paymentsOutboxEventService.eventPublish(event);

throw new RuntimeException(event.cancelReason().getDescription(), e);
}
}
@RequiredArgsConstructor
@Service
public class PaymentsOutboxEventService {
private final ApplicationEventPublisher eventPublisher;

public void eventPublish(PaymentsOutboxEvent event) {
eventPublisher.publishEvent(outboxEvent);
}
}
@RequiredArgsConstructor
@EventHandler
public class PaymentsOutboxEventRecordListener {
private final PaymentsOutboxCommander paymentsOutboxCommander;

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Order(1)
@EventListener
public void recordMessage(PaymentsOutboxEvent event) {
paymentsOutboxRepository.save(event);
}
}
@RequiredArgsConstructor
@EventHandler
public class PaymentsOutboxEventPublishListener {
private final MessageQueue messageQueue;
private final PaymentsOutboxCommander paymentsOutboxCommander;

@Async("outboxAsyncExecutor")
@Order(2)
@EventListener
public void sendMessage(PaymentsOutboxEvent event) {
try {
messageQueue.send(queueName, event);
} catch (Exception e) {
paymentsOutboxRepository.findById(event.getId()).ifPresent(
ev -> paymentsOutboxRepository.save(ev.markFailed())
);
}
}
}

  위와 같이 구성했을 때 다음과 같은 순서로 작업되며, enqueue 동작이 실패하더라도 취소 이벤트가 유실되지 않습니다.

  1. confirmPayment에서 예외 발생 → catch문 진입 (트랜잭션 진행 중)
  2. publishEvent() 실행
  3. @Order(1) recordMessage가 REQUIRES_NEW로 outbox 저장 후 커밋
  4. @Order(2) sendMessage는 비동기로 실행됨
  5. catch문에서 RuntimeException 던짐 → confirmPayment 종료 시 트랜잭션 롤백
  6. 별도 스레드에서 sendMessage 수행 → 큐 전송 성공/실패, 실패 시 FAILED 저장

Outbox 상태값과 Graceful Shutdown

 위의 코드에서 잠깐 확인할 수 있었듯이, 저희는 Outbox 이벤트를 상태값으로 관리합니다.

public enum PaymentsOutboxStatus {
PENDING,
SENT,
FAILED
}
  • PENDING: 이벤트를 생성하고 outbox 테이블에 저장하면 기본 상태
  • SENT: outbox 이벤트가 큐로 전달되고, 컨슈머가 이를 받아 취소 처리를 정상 완료한 뒤 최종 상태로 전환
  • FAILED: 일시적 네트워크 장애, 타임아웃 등으로 SQS 전송 실패 상태

 여기서 주목해야 할 상태값은 PENDING 입니다. FAILED의 경우, AWS 콘솔 혹은 관리 도구를 활용하여 다시 발행하면 되는데, PENDING 상태는 어떤 이벤트인지 의문이 생깁니다. 보통 enqueue 동작은 비동기로 수행되기에 최상위 트랜잭션이 롤백(rollback) 된 후에 수행될텐데, enqueue를 실행하는 프로세스가 shutdown 되는 상황에서 발생할 수 있습니다. 이러한 상황들을 방지하기 위해 우리는 application.yml에 아래와 같이 설정해 놓았을 것입니다.

server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 120s

 하지만 PaymentsOutboxEventPublishListener의 sendMessage() 메서드는 비동기로 수행되고, 비동기 작업 수행은 @Async에 지정된 OutboxAsyncExecutor가 담당합니다. 따라서 async thread가 죽지 않기 위해서는 Executor를 Bean으로 등록할 때 아래와 같은 설정도 추가로 필요합니다.

executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(120);

SQS "잘" 활용하기

 앞서 말씀드린 것처럼, 저희는 메시지 큐로 AWS SQS(Simple Queue Service) 를 활용했습니다. 결제 시스템에서 비동기 처리는 중요하지만, 모든 상황에서 전용 메시지 브로커를 운영하는 것이 정답은 아닙니다. Kafka, RabbitMQ와 같은 메시지 브로커는 높은 처리량과 다양한 고급 기능을 제공하지만, 그만큼 인프라 구성, 운영, 모니터링에 대한 부담도 함께 증가합니다. 특히 초기 단계의 서비스에서는 트래픽 규모가 크지 않고, 파티션 관리, 리밸런싱, 브로커 장애 대응과 같은 요소들이 오히려 시스템 복잡도를 불필요하게 높일 수 있습니다.

 반면 SQS는 별도의 메시지 브로커를 직접 운영할 필요가 없는 완전 관리형 서비스로, 큐 생성만으로 즉시 사용할 수 있고 서버 증설이나 장애 대응을 AWS에 위임할 수 있다는 장점이 있습니다. 인프라 비용 또한 사용량 기반으로 책정되기 때문에, 초기 트래픽이 적은 환경에서는 비용 예측과 관리가 상대적으로 용이합니다. (심지어 한달에 100만건은 무료로 제공해주고 있습니다. 👀)

 또한 저희 결제 시스템의 요구사항은 메시지 유실 없이 전달될 것, 재처리가 가능할 것, 그리고 높은 처리량이나 순서 보장이 필수가 아닌 것과 같은 수준이었기 때문에 AWS SQS, 그 중에서 Standard Queue를 활용하기로 결정했습니다.

SQS 설정과 고려해야 할 점

 SQS는 비교적 간단한 설정만으로도 바로 활용할 수 있는 메시지 큐입니다. 하지만 운영 환경, 특히 결제 시스템처럼 실패 비용이 큰 도메인에서 SQS를 사용하려면 단순히 “큐를 붙였다” 수준을 넘어 여러 요소를 함께 고려해야 합니다. 메시지 포맷, 처리 완료 기준, 종료 시점 동작까지 함께 고려한 설정이 필요합니다.

1. AWS SDK 설정

 시작에 앞서 Spring에서 AWS SQS를 다루기 위해 AWS SDK 의존성을 추가해줘야 합니다. AWS SDK는 v1, v2가 존재하는데 지난 2024년, v1에 대한 지원을 중단한다고 공지되었습니다.

 그리고 v1에는 블로킹 I/O 중심 설계라는 점에서 한계를 드러내고 있습니다. v2의 Async Client는 Netty 기반 non-blocking I/O를 사용하고 내부적으로 CompletableFuture를 표준 비동기 추상화로 제공합니다. 그 결과, 적은 수의 스레드로도 많은 동시 요청을 안정적으로 처리할 수 있습니다. 이러한 점들을 종합적으로 살펴보았을 떄, 안정적으로 오래 활용됐던 v1보다 v2가 장기적인 운영 측면에서 적합하다고 판단하였습니다.

implementation("io.awspring.cloud:spring-cloud-aws-starter-sqs:3.0.3")
implementation("software.amazon.awssdk:netty-nio-client:2.21.12")

2. 메시지 포맷과 변환 규칙을 명확히 한다

 SQS는 메시지 바디를 단순 문자열로 취급합니다. 따라서 애플리케이션 레벨에서 메시지 포맷과 변환 규칙을 명확히 정의하지 않으면, 환경이나 버전에 따라 직렬화 방식이 달라지거나 Producer와 Consumer 간 메시지 해석 방식이 어긋나고 DLQ에 쌓인 메시지를 사람이 해석하기 어려워지는 문제가 발생할 수 있습니다.

  이를 방지하기 위해 저희는 SQS로 전달되는 모든 메시지를 JSON 문자열로 직렬화하고, Producer와 Consumer가 동일한 메시지 변환 규칙을 사용하도록 구성했습니다. 먼저, Jackson 기반 컨버터를 명시적으로 설정하여 메시지가 항상 JSON 문자열로 변환되도록 했습니다.

@Bean
public SqsMessagingMessageConverter sqsMessagingMessageConverter() {
final var jacksonConverter = new MappingJackson2MessageConverter();
jacksonConverter.setObjectMapper(objectMapper);
jacksonConverter.setSerializedPayloadClass(String.class);
jacksonConverter.setStrictContentTypeMatch(false);

final var sqsConverter = new SqsMessagingMessageConverter();
sqsConverter.setPayloadMessageConverter(jacksonConverter);
return sqsConverter;
}

 이 컨버터를 SqsTemplate(Producer)와 SQS Listener(Consumer) 양쪽에 모두 적용했습니다.

3. ACK 전략

 결제 취소 이벤트는 메시지를 받았다는 사실보다 실제로 취소 처리가 완료되었는지가 중요합니다. 그래서 리스너는 자동 ACK가 아닌, MANUAL ACK 방식을 사용했습니다. 모든 처리가 성공한 경우에만 ACK 처리하기 떄문에 처리 중 애플리케이션이 종료되거나 예외가 발생하더라도 메시지가 유실되지 않고 재처리 할 수 있습니다.

4. 동시성 제한

 Standard Queue는 높은 처리량을 지원하지만, 결제 시스템에서는 무작정 병렬 처리를 늘리는 것이 항상 좋은 선택은 아닙니다. 결제 시스템은 트래픽이 높은 도메인이 아니기도 하고, 초기 단계에서는 처리량보다 안정성과 추적 가능성이 더 중요하다고 판단했습니다. maxConcurrentMessages(동시에 처리되는 메시지 수)와 스레드 풀 사이즈에 대하여 낮은 값으로 제한하였습니다.

5. 가시성 타임아웃과 중복 처리 전제

 Standard Queue는 at-least-once 전달을 보장하므로 중복 메시지 수신 가능성을 전제로 설계해야 합니다. 이를 위해 가시성 타임아웃을 처리 시간보다 충분히 길게 설정했고, 처리 중 실패 시 일정 시간이 지나면 재처리될 수 있도록 구성했습니다.

 이와 함께, 중복 메시지에 대한 멱등성 처리는 애플리케이션 레벨에서 별도로 고려했습니다. 이 부분은 다음 섹션에서 자세히 다룰 예정입니다.

6. Graceful Shutdown

 운영 환경에서는 배포나 스케일링으로 인해 애플리케이션이 종료되는 상황을 피할 수 없습니다. 어떤 설정을 통해 Graceful한 Shutdown을 할 수 있을지 spring-cloud-aws-sqs 라이브러리의 내부 코드를 살펴보았습니다.

public class SqsMessageListenerContainer<T> extends AbstractPipelineMessageListenerContainer<T, SqsContainerOptions, SqsContainerOptionsBuilder> {

// 생략

@Override
protected void doStop() {
LifecycleHandler.get().stop(this.messageSources, this.messageSink);
shutdownComponentsTaskExecutor();
logger.debug("Container {} stopped", getId());
}

private void shutdownComponentsTaskExecutor() {
if (!this.componentsTaskExecutor.equals(getContainerOptions().getComponentsTaskExecutor())) {
LifecycleHandler.get().dispose(getComponentsTaskExecutor());
}
if (this.acknowledgementResultTaskExecutor != null && !this.acknowledgementResultTaskExecutor.equals(getContainerOptions().getAcknowledgementResultTaskExecutor())) {
LifecycleHandler.get().dispose(getAcknowledgementResultTaskExecutor());
}
}
}

 위는 SqsMessageListenerContainer 내부 코드 입니다. doStop()을 호출할 때 LifecycleHandler를 통해 ComponentsTaskExecutorAcknowledgementResultTaskExecutor를 종료시키는 것을 확인할 수 있습니다. (Executor를 따로 설정하지 않았다면 내부적으로 디폴트 Executor를 주입해줍니다.) 다음은 LifecycleHandler 내부 코드입니다.

public class LifecycleHandler {

private static final LifecycleHandler INSTANCE = new LifecycleHandler();

// 생략

public void dispose(Object destroyable) {
if (destroyable instanceof DisposableBean) {
try {
((DisposableBean) destroyable).destroy();
}
catch (Exception e) {
throw new IllegalStateException("Error destroying disposable " + destroyable);
}
}
}
}

 위를 통해 Executor의 Graceful Shutdown 설정을 통해 ComponentsTaskExecutorAcknowledgementResultTaskExecutor의 Graceful Shutdown을 설정할 수 있다는 점을 알 수 있었습니다.

 이번엔 좀 더 내부로 들어가 보겠습니다. SqsMessageListenerContainer가 상속 받고 있는 AbstractPipelineMessageListenerContainer는 MessageSource를 Collection으로 들고 있고, MessageSource는 SQS 원본 메시지를 Message 타입으로 변환하는 작업 담당합니다. 우리가 SqsMessageListenerContainerFactory를 Bean으로 등록할 때 설정하는 listenerShutdownTimeout 값은 AbstractContainerOptions의 생성자에서 사용됩니다.

public abstract class AbstractContainerOptions<O extends ContainerOptions<O, B>, B extends ContainerOptionsBuilder<B, O>> implements ContainerOptions<O, B> {

// 생략

private final Duration listenerShutdownTimeout;

@Override
public Duration getListenerShutdownTimeout() {
return this.listenerShutdownTimeout;
}

// 생략
}

 AbstractContainerOptions의 getListenerShutdownTimeout() 메서드는 AbstractMessageConvertingMessageSource를 상속하고 있는 AbstractPollingMessageSource가 활용하고 있습니다.

public abstract class AbstractPollingMessageSource<T, S> extends AbstractMessageConvertingMessageSource<T, S> implements PollingMessageSource<T>, IdentifiableContainerComponent {

// 생략

@Override
protected void configureMessageSource(ContainerOptions<?, ?> containerOptions) {
this.shutdownTimeout = containerOptions.getListenerShutdownTimeout();
this.pollBackOffPolicy = containerOptions.getPollBackOffPolicy();
doConfigure(containerOptions);
}

// 생략

@Override
public void stop() {
if (!isRunning()) {
logger.debug("{} for queue {} not running", getClass().getSimpleName(), this.pollingEndpointName);
}
synchronized (this.lifecycleMonitor) {
logger.debug("Stopping {} for queue {}", getClass().getSimpleName(), this.pollingEndpointName);
this.running = false;
if (!waitExistingTasksToFinish()) {
logger.warn("Tasks did not finish in {} seconds for queue {}, proceeding with shutdown",
this.shutdownTimeout.getSeconds(), this.pollingEndpointName);
lingFutures.forEach(pollingFuture -> pollingFuture.cancel(true));
}
doStop();
this.acknowledgmentProcessor.stop();
logger.debug("{} for queue {} stopped", getClass().getSimpleName(), this.pollingEndpointName);
}
}

protected void doStop() {
}

private boolean waitExistingTasksToFinish() {
if (this.shutdownTimeout.isZero()) {
logger.debug("Shutdown timeout set to zero for queue {} - not waiting for tasks to finish",
this.pollingEndpointName);
return false;
}
return this.backPressureHandler.drain(this.shutdownTimeout);
}
}

 위 코드를 통해 listenerShutdownTimeout는 컨테이너가 종료(stop)될 때 폴링을 멈추기 전에 남아있는 in-flight 작업이 다 끝날 때까지 기다리는 최대 시간이라는 사실을 알 수 있습니다.

 따라서 Executor 설정과 SqsMessageListenerContainerFactory의 listenerShutdownTimeout 설정을 통해 Shutdown 시점에도 이미 수신한 메시지는 가능한 한 처리 완료 후 종료되고 처리되지 못한 메시지는 SQS를 통해 자연스럽게 재처리 될 수 있도록 하였습니다.

 위의 사항들을 모두 고려하여 다음과 같이 설정할 수 있습니다. (아래 코드는 실제 운영 코드가 아닌 예시 코드입니다.)

@Bean
public SqsMessageListenerContainerFactory<Object> paymentListenerFactory(
@Qualifier("paymentSqsAsyncClient") SqsAsyncClient sqsAsyncClient,
@Qualifier("paymentSqsTaskExecutor") TaskExecutor paymentSqsTaskExecutor,
SqsMessagingMessageConverter sqsMessagingMessageConverter
) {
return SqsMessageListenerContainerFactory.builder()
.sqsAsyncClient(sqsAsyncClient)
.configure(options -> options
.messageConverter(sqsMessagingMessageConverter)
.acknowledgementMode(AcknowledgementMode.MANUAL)
.maxConcurrentMessages(2)
.maxMessagesPerPoll(2)
.pollTimeout(Duration.ofSeconds(20))
.messageVisibility(Duration.ofSeconds(60))
.listenerShutdownTimeout(Duration.ofSeconds(120))
.componentsTaskExecutor(paymentSqsTaskExecutor)
)
.build();
}

@Bean(name = "paymentSqsTaskExecutor")
public ThreadPoolTaskExecutor paymentSqsTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(0);
executor.setThreadNamePrefix("sqs-listener-");
executor.setThreadFactory(new MessageExecutionThreadFactory("sqs-listener-"));
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(120);
executor.initialize();

return executor;
}
@Bean
public MessageQueue
paymentMessageQueue(@Qualifier("paymentSqsTemplate") SqsTemplate sqsTemplate) {
return new SqsMessageQueue(sqsTemplate);
}

@Bean
public SqsTemplate paymentSqsTemplate(
@Qualifier("paymentSqsAsyncClient") SqsAsyncClient sqsAsyncClient,
SqsMessagingMessageConverter sqsMessagingMessageConverter
) {
return SqsTemplate.builder()
.sqsAsyncClient(sqsAsyncClient)
.messageConverter(sqsMessagingMessageConverter)
.build();
}

@Bean
public SqsAsyncClient paymentSqsAsyncClient() {
return SqsAsyncClient.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)
))
.httpClientBuilder(NettyNioAsyncHttpClient.builder()
.connectionTimeout(Duration.ofSeconds(30))
.readTimeout(Duration.ofSeconds(30))
.maxConcurrency(50)
)
.overrideConfiguration(ClientOverrideConfiguration.builder()
.apiCallTimeout(Duration.ofSeconds(60))
.apiCallAttemptTimeout(Duration.ofSeconds(30))
.retryPolicy(RetryPolicy.builder()
.numRetries(3)
.build())
.build())
.build();
}

이벤트 멱등성 보장하기

앞선 섹션에서 살펴본 것처럼, Standard Queue 기반의 SQS는 at-least-once 전달을 보장합니다. 즉, 메시지는 유실되지 않지만 같은 메시지가 한 번 이상 전달될 수 있다는 전제를 가지고 있습니다.

  • 메시지를 처리 중이었지만 ACK 전에 애플리케이션이 종료된 경우
  • 가시성 타임아웃이 만료되어 메시지가 다시 전달된 경우
  • 일시적인 네트워크 오류로 메시지 삭제 요청이 실패한 경우

 이는 SQS의 단점이라기보다는, 분산 시스템에서 신뢰성을 확보하기 위해 선택한 설계 방향에 가깝습니다. 하지만 결제 시스템에서는 이 특성이 그대로 문제가 될 수 있기 떄문에 이벤트 멱등성(idempotency)은 선택이 아니라 필수 조건이 됩니다.

 저희는 결제의 orderId를 uuid로 생성하고 있고, 이를 통해 멱등성을 보장하기로 했습니다. 다음은 Consumer에서 멱등성을 검사하는 예시 코드 입니다.

@Slf4j
@RequiredArgsConstructor
@EventConsumer
public class PaymentCancelEventConsumer {

// 생략

public void handlePaymentCancelEvent(
final PaymentsOutboxEvent outboxEvent,
final Acknowledgement acknowledgement
) throws JsonProcessingException {
// 생략

if (paymentOrderReader.existsCancelPaymentByOrderId(paymentOrder.getOrderId())) {
log.error("Idempotency: Payment already canceled. orderId={}", paymentOrder.getOrderId());

sqsAcknowledgeEventService.publishEvent(acknowledgement);
return;
}

// 생략
}
}

데드락도 신경 써야한다고?

 다시 AbstractPipelineMessageListenerContainer 내부 코드를 잠시 살펴보겠습니다.

public abstract class AbstractPipelineMessageListenerContainer<T, O extends ContainerOptions<O, B>, B extends ContainerOptionsBuilder<B, O>> extends AbstractMessageListenerContainer<T, O, B> {
//생략

private TaskExecutor resolveComponentsTaskExecutor() {
return getContainerOptions().getComponentsTaskExecutor() != null
? validateCustomExecutor(getContainerOptions().getComponentsTaskExecutor())
: createTaskExecutor();
}

protected TaskExecutor createTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int poolSize = getContainerOptions().getMaxConcurrentMessages() * this.messageSources.size();
executor.setMaxPoolSize(poolSize);
executor.setCorePoolSize(poolSize);
// Necessary due to a small racing condition between releasing the permit and releasing the thread.
executor.setQueueCapacity(poolSize);
executor.setAllowCoreThreadTimeOut(true);
executor.setThreadFactory(createThreadFactory());
executor.afterPropertiesSet();
return executor;
}

// 생략
}

 내부 코드를 통해 AbstractPipelineMessageListenerContainer는 커스텀 TaskExecutor를 설정해주지 않았다면 TaskExecutor를 내부적으로 생성해주고, 생성할 때 MaxConcurrentMessages 값을 기준으로 Pool Size를 설정해주는 것을 알 수 있습니다.

 스레드 풀을 통해 트랜잭션이 병렬적으로 수행되고, connection pool size가 적당하지 않으면 데드락이 발생할 수 있습니다. 따라서 튜닝이 필요하다. Spring Boot 2.x 버전부터는 디폴트 커넥션 풀로 Hikari CP를 활용하고 있고, Hikari CP 공식문서에 커넥션 풀 튜닝에 대해 아주 친절하게 가이드 하고 있습니다.

### Tn × (Cm − 1) + 1

- Tn: 동시에 처리되는 최대 스레드 개수
- Cm: 스레드 1개 처리 시 필요한 최대 커넥션 수

마무리하며

 이번 글에서는 어시스트핏 CRM에 앱 결제 기능을 도입하면서, 사용자 화면 뒤에서 결제 시스템이 어떤 책임을 지고 동작해야 하는지, 그 과정에서 어떤 고민을 했는지를 정리해보았습니다.

 결제 기능은 사용자 입장에서는 간단한 클릭 한 번의 경험이지만, 서비스를 운영하는 입장에서는 데이터의 정합성, 장애 상황에서의 복구, 중복 처리와 같은 문제를 항상 함께 고려해야 하는 영역이기도 합니다. 특히 실제 금전이 오가는 시스템인 만큼, 정상 상황뿐 아니라 실패했을 때 어떻게 수습할 것인가에 대한 설계가 중요했습니다.

 그 중에서도 이번 포스팅에서는 일관성(Consistency)을 중심으로, SQS 기반의 비동기 처리, 트랜잭셔널 아웃박스 패턴, graceful shutdown, 그리고 이벤트 멱등성까지 결제 시스템을 운영 환경에서 안정적으로 유지하기 위해 선택한 방법들을 공유했습니다.

  긴 글 읽어주셔서 감사합니다. 🙇‍♂️

Reference