Transactional Outbox Pattern으로 이벤트 발행 원자성 확보하기

2025. 9. 7. 02:42·Project - AlgoMarket
본 게시글은 개인 프로젝트[AlgoMarket]를 개발하며 작성한 기록입니다.
 

GitHub - ymkim97/algo-market: 개인 프로젝트 - 알고리즘 온라인 저지 서비스

개인 프로젝트 - 알고리즘 온라인 저지 서비스. Contribute to ymkim97/algo-market development by creating an account on GitHub.

github.com

AlgoMarket의 채점 과정

온라인 저지 서비스인 AlgoMarket은 사용자, 문제, 제출 등을 관리하는 1. 문제 서버(Java, SpringBoot) 와 실제 사용자가 제출한 코드를 컴파일 후 실행하여 채점하는 2. 채점 서버(Python)로 설계했습니다.

이렇게 MSA, Message Driven 형태를 띄우고 있는 설계로 이벤트와 메시지를 통해서 통신하도록 했습니다.

이때 메시지 큐로 Amazon SQS를 사용하고 있습니다.

 

사용자가 코드를 작성하고 제출 버튼을 누르면 문제 서버에서 MySQL에 해당 제출에 대한 정보를 영속화한 이후, 채점에 필요한 정보들을 메시지에 담아 SQS에 produce 하고, 채점 서버가 메시지를 consume 하여 채점을 진행하고 결과를 다시 SQS에 produce 합니다.

문제 서버는 채점 결과 메시지를 consume 하여 이전에 영속했던 제출 정보에 결과, 실행 시간, 사용 메모리량을 반영합니다.

 

채점 Flow

 

이러한 설계 덕분에 채점은 비동기적으로 처리되며, 사용자는 제출이 모두 끝날 때까지 한 페이지에서 기다릴 필요 없습니다.

하지만 문제 서버와 외부 인스턴스는 하나의 물리적인 트랜잭션으로 묶을 수 없습니다.

Spring에서 제공하는 @Transactional을 이용한다면 논리적으로 [제출 정보 저장 + 채점 Message Produce]를 하나로 묶을 수 있겠으나, 이런 외부 요청을 포함하면 DB Connection starvation을 일으킬 수 있는 위험성이 존재합니다.


보장되지 않은 데이터 정합성

1. 초기 구현

@Service
@RequiredArgsConstructor
public class SubmissionService implements SubmissionHandler {

    ...

    @Override
    @Transactional
    public SubmitResponse submit(SubmitRequest submitRequest, String username) {
    	
       // 1. 도메인 로직
       Problem problem = problemRepository.findById(submitRequest.problemId())
          .orElseThrow(() -> new NotFoundException("존재하지 않는 문제입니다: " + submitRequest.problemId()));

       Submission submission = Submission.submit(submitRequest, username);
       submission = submissionRepository.save(submission);

       SubmittedEvent submittedEvent = SubmittedEvent.of(submitRequest, username, submission.getId(), problem.getTimeLimitSec(), problem.getMemoryLimitMb());

      // 2. SQS 어댑터가 message를 produce 하도록 이벤트 발행
       eventPublisher.publishEvent(submittedEvent);

       return SubmitResponse.from(submission);
    }
    
    ...
    
}
@Component
@RequiredArgsConstructor
public class SqsSubmissionEventProducer implements SubmissionEventHandler {

    ...

    @Override
    @Async("threadPoolExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void produce(SubmittedEvent submittedEvent) throws JsonProcessingException {
       String message = objectMapper.writeValueAsString(submittedEvent);

       sqsTemplate.send(to -> to
          .queue(queueName)
          .payload(message)
          .messageGroupId("submits")
          .messageDeduplicationId(submittedEvent.submissionId().toString())
       );
    }
}

 

1. 도메인 로직

2. Spring의 ApplicationEventPublisher 를 통해 이벤트 발행

3. DB Commit

4. 이벤트 리스너 (Adapter 에 위치)가 동작하여 SQS에 메시지 produce

 

이벤트 리스너가 Commit 이후에 동작할 수 있는 이유는 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 덕분입니다.

여기서 4번의 성공 유무에 따라 발생할 수 있는 케이스가 있습니다.

 

1) 1~3번 성공, 4번 성공

4번이 성공하기 전에 1~3 번도 성공되었음이 보장되기 때문에 (Spring에서 보장됨) 사용자의 문제 풀이에 대한 제출이 성공적으로 SQS로 보내집니다.

 

1) 1~3번 성공, 4번 실패

4번이 실패하면서 문제가 생깁니다.

1~3번에서 제출 정보가 Commit 되었지만 SQS로 메시지를 보내지 못한 상황입니다.

제출(Submission) 엔티티는 초기에 Judging 이라는 상태를 가지는데, 사용자에게 무한정으로 Judging 상태가 노출되며 평생 채점이 되지 않습니다.


Transactional Messaging

위에서 보신것과 같이 비동기 메시징을 활용한 서비스에서 비즈니스 로직이 실행되었을 때, 이를 표현하는 이벤트까지 온전하게 발행되어야 한다는 것을 알 수 있습니다.

만약 이벤트 발행이 이루어지지 않는다면 서비스의 데이터 정합성이 깨지고 사용자에게는 알 수 없는 결과나 버그를 반환하게 됩니다.

서비스 로직과 이벤트 발행을 원자적(atomically)으로 묶어 실행하는 방식을 트랜잭셔널 메시징(Trasactional Messaging)이라 하며, 이를 구현하는 방법은 크게 두 가지가 있습니다.

 

변경 데이터 캡쳐 (Change Data Capture)

줄여서 CDC라고도 많이 불리는 Change Data Capture는 시스템의 데이터에 발생한 변경 사항(삽입, 갱신, 삭제 등)을 캡처하는 데이터 통합 패턴입니다.

이러한 변경 사항은 일반적으로 리스트 형태로 표현되며, 이를 CDC 피드(CDC feed)라고 합니다.

전체 데이터셋을 읽는 대신 CDC 피드를 기반으로 처리하면 데이터를 훨씬 더 빠르게 가공하고 처리할 수 있습니다.

SQL Server, MySQL, Oracle과 같은 트랜잭셔널 데이터베이스는 CDC 피드를 생성합니다.

CDC 자체는 패턴이기 때문에 이를 구현하는 솔루션이 존재합니다. 대표적으로는 Apache Kafka Connect의 source connector인 Debezium이 있습니다.

솔루션이 개발되기 전에 사용되었던 방식으로는 Log-based CDC, Query-based CDC, Trigger-based CDC 등이 있었습니다.

 

트랜잭셔널 아웃박스 패턴 (Transactional Outbox Pattern)

https://microservices.io/patterns/data/transactional-outbox.html

트랜잭셔널 아웃박스(Transactional Outbox) 패턴은 서비스 내부의 데이터베이스 변경과 이벤트 발행을 원자적으로 처리하기 위한 아키텍처 패턴입니다.

데이터베이스 저장과 메시지 브로커 발행은 서로 다른 시스템에 대한 작업이므로, 트랜잭션이 분리되어 실패 시 불일치 문제가 생깁니다.

이를 해결하기 위해 서비스의 트랜잭션 커밋 이후 이벤트를 바로 브로커에 발행하지 않고, 먼저 DB의 Outbox 테이블에 이벤트를 함께 기록합니다.

그 후 Message Relay가 Outbox에서 해당 이벤트를 읽어 메시지 브로커에 publish 합니다.

그럼 이때 Outbox 테이블에서 Message(Row)를 어떻게 읽어서 가져올지에 대한 방식을 생각해볼 수 있습니다.

 

Polling

주기적으로 polling 하면서 메시지를 읽어오는 방식입니다.

DB를 MySQL로 사용한다고 가정한다면, 설정했던 주기(ex. 10초)마다 SELECT ... 쿼리를 날려 가져와서 이벤트를 publish 하도록 합니다.

모든 이벤트를 먼저 Outbox에 기록하는 경우에는 새로 생성되었거나, 메시지 전송에 실패한 row들을 읽어와서 처리합니다. 이때 status 기록용으로 별도의 column을 두는 것이죠.

 

CDC 기반

위에서 설명했던 CDC로, 데이터베이스 트랜잭션 로그를 읽어 변경 사항을 감지하여 처리합니다.

이전에 설명한 CDC와 차이점으로는 Outbox라는 별도의 테이블을 둔다는 것이고 해당 테이블을 CDC로 읽는 것입니다.


Transactional Outbox Pattern을 선택한 이유

두 방법은 DB를 이용한다는 공통점이 있습니다.

결국 DB의 물리 트랜잭션을 이용하여 발생할 수 있는 데이터 정합성 문제를 극복한다는 것을 알 수 있습니다.

먼저 각 장단점을 파악해 보았습니다.

 

CDC 장점:

  • 애플리케이션 로직을 수정할 필요 없음 (DB 로그 기반)
  • 기존 테이블 스키마와 서비스 코드를 거의 건드리지 않고 적용 가능
  • 고성능: 로그 기반 스트리밍이라 실시간 처리 가능

CDC 단점:

  • DB 로그 형식에 의존 → DB 종류/버전에 종속적일 수 있음
  • CDC는 데이터베이스 로그를 기반으로 작동하므로, 데이터 변경이 빈번하거나 많은 경우 데이터베이스에 부하를 줄 수 있음
  • CDC 솔루션을 구축하고 지속적으로 관리하는 데 비용이 발생
  • 러닝 커브 존재

Transactional Outbox Pattern 장점:

  • 이벤트 스키마를 애플리케이션 로직과 직접적으로 설계 가능
  • 이벤트 발행 시점/구조를 애플리케이션이 제어 가능
  • 별도 솔루션이 필요 없어 비용이 발생하지 않음

Transactional Outbox Pattern 단점:

  • Polling으로 구현할 시 주기적으로 쿼리를 날려 DB에 부하를 줄 가능성 존재
  • Polling으로 구현할 시 애플리케이션에 배치 로직을 직접 작성해야 함

대량 트래픽을 처리하는데에 있어서는 CDC가 좋아보이나 현재 프로젝트의 크기나 상황에서는 Transactional Outbox Pattern이면 충분할 것이라고 판단되었습니다.

CDC를 도입하기에는 Kafka와 같은 다른 tool도 도입해야하는데, 이는 배보다 배꼽이 더 큰 격이라고 생각했습니다.

혹시라도 해당 프로젝트를 운영하다가 해당 부분에서 성능적인 병목 현상이 일어나면 바꿔보는 방향으로 가기로 했습니다.

무신사로도 알려진 29CM에서 2023년도에 Transactional Outbox Pattern을 구현한 사례를 보아서는 웬만하면 괜찮을 것 같다는 결론을 내렸습니다.

https://medium.com/@greg.shiny82/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%94%EB%84%90-%EC%95%84%EC%9B%83%EB%B0%95%EC%8A%A4-%ED%8C%A8%ED%84%B4%EC%9D%98-%EC%8B%A4%EC%A0%9C-%EA%B5%AC%ED%98%84-%EC%82%AC%EB%A1%80-29cm-0f822fc23edb


Transactional Outbox Pattern 구현하기

2. 패턴 적용 구현

먼저 Service 코드는 위의 초기 구현과 동일합니다.

@Service
@RequiredArgsConstructor
public class SubmissionService implements SubmissionHandler {

    ...

    @Override
    @Transactional
    public SubmitResponse submit(SubmitRequest submitRequest, String username) {
    	
       // 1. 도메인 로직
       Problem problem = problemRepository.findById(submitRequest.problemId())
          .orElseThrow(() -> new NotFoundException("존재하지 않는 문제입니다: " + submitRequest.problemId()));

       Submission submission = Submission.submit(submitRequest, username);
       submission = submissionRepository.save(submission);

       SubmittedEvent submittedEvent = SubmittedEvent.of(submitRequest, username, submission.getId(), problem.getTimeLimitSec(), problem.getMemoryLimitMb());

      // 2. SQS 어댑터가 message를 produce 하도록 이벤트 발행
       eventPublisher.publishEvent(submittedEvent);

       return SubmitResponse.from(submission);
    }
    
    ...
    
}
@Slf4j
@Component
@RequiredArgsConstructor
public class SqsSubmissionEventProducer {

	...

	@Async("threadPoolExecutor")
	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void produce(SubmittedEvent submittedEvent) throws JsonProcessingException {
		String message = objectMapper.writeValueAsString(submittedEvent);

		// 1
		sqsTemplate.send(to -> to
			.queue(queueName)
			.payload(message)
			.messageGroupId(submittedEvent.username())
			.messageDeduplicationId(submittedEvent.submissionId().toString())
		);

		// 2
		outboxCleanupHandler.deleteSubmittedEvent(submittedEvent);
		log.info("Successfully sent SubmittedEvent to SQS and removed from outbox: {}", submittedEvent.submissionId());
	}
}

SQS로 메시지를 produce 하는 부분으로, Service 로직의 Commit 이후에 실행됩니다.

SqsTemplate을 통한 produce를 성공하고 나면 Outbox에서 해당 이벤트를 삭제합니다. 하지만 여기서도 문제가 발생하여 특정 case가 발생할 수 있습니다.

1번 과정을 성공한 후 2번의 실패(Outbox row 삭제 실패)입니다.

이렇게 된다면 문제 서버 입장에서는 메시지가 다시 전송되어야 한다는 것으로 보여 재시도하게 됩니다.

그렇기 때문에 해당 패턴은 Exactly-Once가 아닌 At-Least-Once delivery가 됩니다. 이 부분에 대해서는 다음 절에서 더 자세히 다루겠습니다.

 

@Slf4j
@Service
@RequiredArgsConstructor
public class OutboxService implements OutboxRetryHandler, OutboxCleanupHandler {

    ...
 
    // 1. 스케쥴러로 호출되는 재시도 로직
    @Override
    @Transactional
    public void retryPendingEvents() {
       int retryThresholdMinutes = 1;
       LocalDateTime threshold = LocalDateTime.now().minusMinutes(retryThresholdMinutes);
       
       List<Outbox> publishFailedMessages = outboxRepository.findTop100ByTimeStampBeforeOrderByTimeStampAsc(threshold);

       if (publishFailedMessages.isEmpty()) {
          return;
       }

       log.info("Found {} publish failed outbox messages older than {} minutes", publishFailedMessages.size(), retryThresholdMinutes);

       for (Outbox message : publishFailedMessages) {
          try {
             retryMessage(message);
          } catch (Exception e) {
             log.error("Failed to retry outbox message: {}", message.getId(), e);
          }
       }
    }

    // 2. 서비스 로직 commit 이후 실행
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    protected void saveSubmittedEvent(SubmittedEvent submittedEvent) {
       if (outboxRepository.existsByAggregateId(submittedEvent.submissionId())) {
          return;
       }

       Outbox outbox = Outbox.create(submittedEvent.submissionId(), "Submission", submittedEvent, objectMapper);
       Outbox savedOutBox = outboxRepository.save(outbox);

       log.info("SubmittedEvent saved to outbox with eventId: {}", savedOutBox.getId());
    }
    
    ...
    
}

위에서 본 AFTER_COMMIT과는 다르게 Service 로직의 commit 전에 이벤트를 리스닝하여 Outbox에 이벤트를 저장합니다.

대부분의 경우에는 SQS로 메시지 전송이 성공할 것이고, 실패했을 때를 제외하면 Outbox 엔티티는 순식간에 생성되었다가 삭제됩니다.


어떻게 Exactly-Once Delivery를 보장할까?

메시지 전송을 성공하지만 Outbox의 row를 실패하면서 중복된 메시지를 전송할 수 있습니다.

똑같은 이벤트에 대해서 결과가 달라지지 않도록 이벤트 소비 로직을 Idempotent(멱등) 하도록 구현하면 결과 데이터의 정합성을 지킬 수 있습니다.

AlgoMarket 경우에는 중복 채점 이벤트로 인해 두 가지의 case에서 문제가 발생할 수 있습니다.

  1. 채점 서버가 채점 완료한 제출을 다시 채점 - 불필요한 자원을 소모하는 것으로, 사용자가 많을수록 영향이 클 수 있다고 생각합니다.
  2. 채점 완료 후 측정된 실행 시간 및 메모리 사용량이 변경됨 - AlgoMarket은 다른 온라인 저지와 같이 채점이 ACCEPTED 되는 경우 최대 실행 시간과 최대 메모리 사용량을 저장하여 반환하는데, 채점이 다시 돌면서 최근 데이터로 변경될 수 있다는 점입니다.

채점 서버에서 Redis나 캐시 등을 이용하여 최근에 채점된 제출에 대한 id를 저장하며 중복을 확인하는 해결책이 있습니다.

또한 SQS 자체를 FIFO 큐를 사용하고 있는데, 해당 큐 자체는 MessageDuplicationId를 이용하여 Exactly-Once를 보장하고 있습니다. 물론 채점 서버가 아닌 SQS 큐에만 적용됩니다.

그래도 이는 중복 이벤트 처리를 어느정도 완화해주고 있다고 생각합니다.


마치며

해당 프로젝트를 진행하면서 이벤트를 적극적으로 사용하다가, 저의 코드 리뷰를 담당해주고 있는 CodeRabbit이 PR에서 남겨준 코멘트를 통해 공부하고 적용해 볼 수 있었던 기회였습니다.

무의식적으로 happy path를 떠오르기 때문에 먼저 생각해내지 못해냈던 것 같습니다.

특히 MSA 아키텍처 설계에는 이와 같은 서버간의 데이터 정합성, 장애처리 등이 정말 중요하다는 것을 느꼈습니다.

서비스 간의 느슨한 결합으로 유연한 배포, 높은 확장성, 장애 전파에 대한 차단 등이 장점이지만 그만큼 단점과 위험성도 존재하여 트레이드 오프를 잘 이해하고 대처해야 한다는 것을 배웠습니다.

읽어주셔서 감사합니다.

'Project - AlgoMarket' 카테고리의 다른 글

Online Judge 채점 서버의 제출 코드 실행 시간 측정하기  (1) 2025.08.29
'Project - AlgoMarket' 카테고리의 다른 글
  • Online Judge 채점 서버의 제출 코드 실행 시간 측정하기
김앵맹 - Backend
김앵맹 - Backend
  • 김앵맹 - Backend
    ymkim.log
    김앵맹 - Backend
  • 전체
    오늘
    어제
    • 분류 전체보기 (7)
      • 나의 생각 (3)
      • 회고록 (1)
      • Project - AlgoMarket (2)
      • Project - TnT (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
    • LinkedIn
  • 공지사항

  • 인기 글

  • 태그

    TNT
    yapp
    취준
    log4j2
    Algorithm
    MySQL
    Java
    Python
    회고
    OOP
    logging
    TSID
    MSA
    db
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
김앵맹 - Backend
Transactional Outbox Pattern으로 이벤트 발행 원자성 확보하기
상단으로

티스토리툴바