분산 시스템, 특히 마이크로서비스 아키텍처(MSA)에서 자주 발생하는 고질적인 문제와 그 해결책인 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern)에 대해 알아보겠습니다.
마이크로서비스 환경에서는 하나의 서비스가 자신의 데이터베이스를 변경하는 것(Write)과 동시에, 다른 서비스에게 "나에게 변화가 생겼어!"라고 알리기 위해 메시지 브로커(Kafka, RabbitMQ 등)에 이벤트를 발행(Write)하는 경우가 흔합니다.
예를 들어, 상품 서비스가 신규 상품을 등록한 뒤, 검색 서비스나 알림 서비스가 이 사실을 알 수 있도록 ProductCreated 이벤트를 발행하는 상황을 가정해 보겠습니다.
아마 가장 직관적인 코드는 다음과 같을 것입니다.
// 위험한 방식의 예시 코드
@Transactional
public void createProduct(ProductCreateRequest request) {
// 1. DB에 상품 정보를 저장한다.
Product product = new Product(request.getName(), request.getPrice());
productRepository.save(product);
// 2. 외부에 이벤트를 발행한다.
eventPublisher.publish(new NewProductEvent(product.getId()));
}
이 코드는 단순해 보이지만 심각한 문제를 내포하고 있습니다. 바로 이중 쓰기(Dual Writing) 문제입니다.
위험한 이유는 두 개의 서로 다른 시스템(데이터베이스, 메시지 브로커)에 대한 쓰기 작업을 하나의 원자적인 트랜잭션으로 묶을 수 없기 때문입니다.
데이터베이스의 트랜잭션은 productRepository.save(product) 호출 이후, @Transactional 어노테이션에 의해 createProduct 메소드가 성공적으로 종료될 때 COMMIT이 실행됩니다.
이 과정을 간단한 의사 코드로 풀어서 살펴보겠습니다.
public void problematicTransactionLogic() {
try {
// 1. DB 트랜잭션 시작
database.transaction.begin();
// 2. 비즈니스 로직 수행 (DB 쓰기)
Product product = new Product("신규 상품");
productRepository.save(product);
// 3. 외부 시스템에 이벤트 발행
eventPublisher.publish(new NewProductEvent(product.getId()));
// 4. DB 트랜잭션 커밋
database.transaction.commit();
} catch (Exception e) {
// 5. 문제 발생 시 DB 롤백
database.transaction.rollback();
}
}
여기서 발생할 수 있는 두 가지 최악의 시나리오가 있습니다.
시나리오 A: DB 커밋 성공, 이벤트 발행 실패
productRepository.save(product)는 성공했습니다.eventPublisher.publish(...)가 네트워크 문제나 브로커 장애로 실패했습니다.database.transaction.rollback()이 호출됩니다.publish에서 따로 한다면, 상품은 DB에 있지만 이벤트는 발행되지 않아 아무도 모르는 유령 데이터가 됩니다.)시나리오 B: DB 커밋 실패, 이벤트 발행 성공
productRepository.save(product)는 성공했습니다.eventPublisher.publish(...)도 성공했습니다.database.transaction.commit() 시점에 DB 장애, 락(Lock) 문제 등으로 커밋이 실패하고 롤백됩니다.이처럼 두 작업 중 하나만 성공하는 상황은 서비스 전체의 데이터 정합성을 심각하게 훼손시킵니다.
이 문제를 해결하는 것이 바로 트랜잭셔널 아웃박스 패턴입니다.
핵심 아이디어는 간단합니다.
"외부 시스템(메시지 브로커)에 직접 이벤트를 발행하지 말고, '발행할 이벤트' 자체를 내 데이터베이스에 저장하자!"
즉, 비즈니스 데이터(상품)를 저장하는 작업과, 발행할 이벤트(상품 생성 이벤트)를 저장하는 작업을 하나의 DB 트랜잭션으로 묶어 원자성을 보장하는 것입니다.
이를 위해 OUTBOX라는 별도의 테이블을 만듭니다.
예시: 이벤트 저장을 위한 Outbox 테이블
CREATE TABLE product_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(255) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed BOOLEAN DEFAULT FALSE -- (추가) 처리 여부를 추적할 수 있습니다.
);
이제 아웃박스 패턴을 적용하여 코드를 수정해 보겠습니다.
// 트랜잭셔널 아웃박스 패턴이 적용된 코드
@Transactional
public void createProduct(ProductCreateRequest request) {
// 1. 비즈니스 데이터(상품) 저장
Product product = new Product(request.getName(), request.getPrice());
productRepository.save(product);
// 2. 발행할 이벤트를 Outbox 테이블에 저장
// (아직 진짜 발행(publish)한 것이 아님!)
ProductEvent event = new ProductEvent(product.getId(), "PRODUCT_CREATED", request);
productOutboxRepository.save(event);
// 3. 트랜잭션 커밋
// 이제 productRepository.save(product)와
// productOutboxRepository.save(event)는
// 같은 DB 트랜잭션으로 묶여 원자성을 보장받습니다.
}
이제 어떻게 될까요?
product 저장이 성공하고 event 저장도 성공하면, 트랜잭션이 커밋됩니다. (성공)product 저장은 성공했지만 event 저장이 실패하면, 트랜잭션이 롤백됩니다. (데이터 정합성 유지)product 저장, event 저장이 모두 성공했지만 커밋이 실패하면, 트랜잭션이 롤백됩니다. (데이터 정합성 유지)이로써 DB와 이벤트 발행 간의 원자성(Atomicity) 문제는 해결됩니다.
다만 이후 이벤트 발행 과정에서 중복 전송이 일어날 수 있으므로, 소비자 측에서 멱등성을 보장해야 완전한 정합성이 유지됩니다.
"좋아, 이제 이벤트가 DB에 저장된 건 알겠어. 그럼 이걸 언제, 누가 메시지 브로커로 보내주지?"
여기서부터는 별도의 비동기 프로세스가 필요합니다. 이 프로세스는 OUTBOX 테이블을 감시하다가, 새로 추가된 이벤트를 실제 메시지 브로커로 전달하는 '우체부' 역할을 합니다.
이 '우체부'를 구현하는 방식은 크게 두 가지입니다.
가장 구현하기 쉬운 방식입니다.
@Scheduled)가 주기적으로(예: 매 1초마다) OUTBOX 테이블을 SELECT 합니다.processed = false) 이벤트를 가져와 메시지 브로커로 발행합니다.OUTBOX 테이블에서 삭제하거나, processed = true로 업데이트합니다.더욱 효율적이고 세련된 방식입니다.
Binlog, PostgreSQL의 WAL 등이 트랜잭션 로그입니다.OUTBOX 테이블에 INSERT가 발생한 것을 '캡처'합니다.적어도 한 번 전송 (At-least-once Delivery)
아웃박스 패턴은 이벤트 발행 프로세스(우체부)가 브로커에게 이벤트를 '성공할 때까지' 재시도할 수 있게 해줍니다. (예: 발행 후 OUTBOX에서 삭제하기 직전에 '우체부' 프로세스가 죽는 경우, 재시작 시 동일 이벤트를 다시 발행함)
따라서 이 패턴은 "적어도 한 번" 이벤트가 발행되는 것을 보장합니다.
소비자의 멱등성 (Idempotency)
'적어도 한 번'이 보장된다는 것은, 네트워크 문제 등으로 인해 '중복 발행'이 발생할 수 있다는 의미이기도 합니다. 따라서 이 이벤트를 수신하는 소비자(Consumer)는 반드시 멱등성(Idempotent)을 갖도록 설계해야 합니다. 즉, 같은 이벤트를 여러 번 수신하더라도 단 한 번만 처리된 것과 동일한 결과를 내도록 만들어야 합니다.
OUTBOX 테이블에 저장한다.OUTBOX 테이블을 감시하여 실제 메시지 브로커로 이벤트를 전송한다.