[MSA] Transactional Outbox Pattern
안녕하세요! 글쓰는 개발자 stark입니다.
저는 최근 1년간 주로 MSA 프로젝트를 진행해 왔는데요 프로젝트를 설계하고 개발하면서 항상 같은 고민을 해왔습니다. 바로 MSA 서버 간 데이터 동기화 방법을 설계하는 것입니다.
MSA 서버 간 데이터 동기화를 진행한 이유
저는 MSA 프로젝트 설계할 때 아래와 같이 각 서버별로 전용 DB를 가지도록 설계하였습니다.
만약 유저에 대한 정보를 유저 DB만 가지고 있다면 아래와 같이 게시글 서버, 소셜 서버, 미디어 서버에서는 비즈니스 로직에서 유저의 정보가 필요할 때마다 유저 서버에 API요청을 보내서 유저의 데이터를 받아와야 할 것입니다. 이렇게 되면 서버 간의 강한 결합(의존성)과 장애 전파가 될 수 있다는 문제점을 가지게 됩니다.
만약 위의 상황을 아래와 같이 변경해서 유저정보 데이터를 각 서버의 DB에 동기화시켜 두면 각 서버에서는 유저 정보가 필요할 때마다 회원 서버에 API 요청을 해서 유저정보를 받을 필요 없이 본인의 DB에서 바로 조회해서 사용할 수 있습니다.
저는 DB 동기화를 진행해야 'MSA에서 한 서버가 고장 나도 다른 서버의 동작에는 문제가 없어야 한다.'라는 MSA의 원칙을 확실히 지킬 수 있을 것이라고 생각해서 이렇게 설계를 진행했습니다. 덕분에 유저 정보가 필요할 때마다 진행되었을 서버 간 api 요청(feign, grpc)을 없앨 수 있었으며 서버 간의 강한 결합과 api 요청으로 발생할 수 있었던 서버 간 장애 상황 전파도 없어지게 되었습니다.
다만 제가 한 실수는 동기화를 설계하는 것이 정말 쉬울 것이라고 판단했다는 것입니다. 이러한 생각은 실제 설계를 해보며 오류를 만나고 나서 와르르 무너졌습니다. 동기화를 설계할 때는 단순하게 서버 간의 문제가 하나도 없고 요청과 응답이 잘 되는 happy 한 상황만 생각해서는 안됩니다. 우리가 코드를 개발할 때 예외 상황을 엄청 열심히 고민하고 테스트를 작성할 때 bad 케이스에 대한 테스트를 만드는 것처럼 동기화 또한 서버 간 요청에서 발생할 수도 있는 모든 장애 상황에 대한 고려가 필요합니다.
특히 Spring 서버에서 데이터베이스에 유저 정보를 업데이트한 후 Kafka를 통해 이를 다른 서비스에 알리는 과정에서 여러 위험이 존재하는데 저는 여러 가지 방법을 찾던 도중 배달의 민족에서 세미나로 공유해 준 Transactional Outbox Pattern을 적용시켜서 설계를 진행해 보았습니다.
Transactional Outbox Pattern이 뭔데?
Transactional Outbox Pattern은 MSA에서 데이터베이스의 일관성과 메시지 발행의 신뢰성을 동시에 보장하기 위한 디자인 패턴입니다. 이 패턴은 서비스의 데이터 변경을 반영하는 이벤트를 메시지 브로커(Kafka 등)에 안전하게 발행하면서, 트랜잭션의 일관성을 유지하고 장애 상황에서도 데이터 손실을 방지하는 데 중요한 역할을 합니다. 이를 통해 데이터베이스 트랜잭션과 이벤트 발행을 안전하게 묶어서 관리할 수 있습니다.
이 패턴에서 사용되는 Outbox 테이블은 데이터베이스 변경사항과 관련된 메시지의 발행 상태를 관리하는 특별한 테이블입니다. 이 테이블의 주요 목적은 데이터베이스 업데이트와 메시지 발행 과정을 안전하게 연결하여 데이터 일관성을 보장하는 것입니다.
일반적으로 Outbox 테이블의 구조는 다음과 같이 설계됩니다.
필드 이름 | 데이터 타입 | 설명 |
id | BIGINT / UUID | 기본 키, 각 레코드의 고유 식별자 |
aggregate_type | VARCHAR(255) | 이벤트가 발생한 도메인 객체의 유형 (예: "User") |
aggregate_id | VARCHAR(255) | 이벤트가 발생한 특정 도메인 객체의 ID (예: 사용자 ID) |
event_type | VARCHAR(255) | 발생한 이벤트의 유형 (예: "UserCreated", "UserModified") |
payload | TEXT / JSON | 이벤트와 관련된 실제 데이터 (JSON 형식) |
timestamp | TIMESTAMP | 이벤트가 Outbox 테이블에 기록된 시간 |
status | VARCHAR(50) | 메시지의 처리 상태 "READY_TO_PUBLISH", "PUBLISHED", "MESSAGE_CONSUME", "FAILED" |
여기서 특히 중요한 것은 status 필드를 통해 메시지의 처리 여부를 관리하여, 장애 상황에서도 누락된 메시지를 쉽게 식별하고 재처리할 수 있다는 것입니다.
왜 이 패턴을 사용하는 걸까?
1. 데이터 일관성과 시스템 복원력 확보
- 데이터베이스와 이벤트 발행(Kafka 메시지 발행) 간의 트랜잭션 일관성을 보장합니다. 서비스가 데이터베이스에 데이터를 저장한 후 Kafka로 메시지를 발행하는 경우, 데이터베이스에 저장이 완료되었더라도 Kafka 발행이 실패하면 데이터 불일치가 발생할 수 있습니다. Transactional Outbox Pattern은 이러한 문제를 해결합니다. 데이터베이스의 Outbox 테이블에 이벤트 정보를 함께 저장함으로써 트랜잭션 내에서 이벤트를 안전하게 기록하고, Kafka 발행이 실패하더라도 후속 조치를 통해 일관된 데이터 상태를 유지할 수 있습니다.
2. 서비스 간 느슨한 결합(Loose Coupling)
- Kafka를 사용해 각 마이크로서비스 간 결합도를 줄이고, 느슨한 연결을 유지할 수 있습니다. 데이터 변경 이벤트가 Kafka 메시지로 발행됨으로써 각 서비스는 직접적으로 상호작용하지 않고, 메시지를 통해 비동기적으로 통신할 수 있습니다. 이를 통해 서비스 확장 및 유지보수가 용이해집니다.
3. 비동기적 확장성
- Kafka와 같은 메시지 브로커를 사용하여 비즈니스 로직에 의해 생성된 이벤트를 비동기적으로 처리함으로써 시스템의 확장성을 확보할 수 있습니다. Kafka는 대용량 데이터를 실시간으로 처리할 수 있기 때문에, Outbox Pattern을 통해 트랜잭션 일관성을 유지하면서도 비동기적으로 확장 가능한 이벤트 처리가 가능합니다.
4. 시스템 장애 시의 복구 능력
- 애플리케이션 서버가 트랜잭션 커밋 후 장애가 발생할 경우, 데이터베이스에는 데이터가 커밋되었지만 Kafka 발행은 실패할 수 있습니다. Outbox 테이블은 이러한 경우에도 이벤트를 안전하게 저장하고 있기 때문에, 장애 발생 후에도 Kafka로 이벤트를 재발행할 수 있습니다. 이는 시스템의 장애 복원력(Fault Tolerance)을 높이는 중요한 요소입니다.
5. Exactly Once 처리
- Kafka 메시지가 중복 발행될 가능성을 줄이고, 중복 처리가 발생하더라도 Idempotent한 소비 로직을 통해 데이터 상태에 영향을 미치지 않도록 설계할 수 있습니다. 이를 통해 데이터의 정확성을 보장합니다.
6. 비즈니스 로직과 Kafka 이벤트 발행의 분리
- 이 패턴은 비즈니스 로직과 메시지 브로커(Kafka) 이벤트 발행 간의 책임을 분리하는 것을 목적으로 합니다. 데이터베이스에 데이터와 함께 이벤트까지 한 번에 처리하는 대신, Outbox 테이블에 이벤트 정보를 기록하고, 별도의 배치 프로세스나 이벤트 발행 프로세스를 통해 Kafka로 메시지를 발행합니다. 이렇게 분리함으로써 로직의 복잡도를 줄이고, 애플리케이션의 유지보수를 용이하게 합니다.
7. 데이터 일관성 유지 및 재발행 관리
- Kafka에 발행되지 않은 이벤트(메시지)는 Outbox 테이블에 "발행 전" 상태로 기록되어 있습니다. 이를 기반으로 배치 작업이나 스케줄링 작업을 통해 실패한 이벤트를 지속적으로 재발행하며, 데이터 일관성을 유지합니다. Kafka의 일시적인 문제나 애플리케이션 장애가 발생하더라도 이러한 관리 작업을 통해 데이터의 안정성을 확보할 수 있습니다.
Outbox 테이블에 어떻게 데이터가 저장될까?
유저 정보가 변경되는 상황을 예로 들어 설명해보겠습니다.
마이크로서비스 아키텍처(MSA) 환경에서 유저 정보가 변경될 때, 이 변경사항을 데이터베이스에 저장하고 동시에 동기화 작업이 되어있는 외부 MSA 서버들에 모두 알려야 합니다. 이를 위해 Kafka 메시지 발행이 필요한데, 이 과정에 Transactional Outbox Pattern이 적용됩니다.
이 패턴의 구현을 위해, 트랜잭션이 적용된 서비스 메서드 내에서 주요 비즈니스 로직을 처리한 후, 메서드 종료 직전에 Spring의 내부 이벤트를 발행하게 됩니다. 이 이벤트는 outbox 테이블에 데이터 저장과 Kafka 메시지 발행 프로세스를 트리거하는 역할을 합니다.
이벤트 발행 후에는 이를 처리할 스프링 이벤트 리스너가 필요한데 Transactional Outbox Pattern을 위해서는 두 개의 Spring 이벤트 리스너를 작성해야만 합니다. 여기서 주목할 점은 @EventListener 어노테이션 대신 @TransactionalEventListener를 사용한다는 것입니다.
@TransactionalEventListener를 사용하면 이벤트 리스너에 TransactionPhase를 직접 설정할 수 있습니다. 저는 패턴을 적용하기 위해 BEFORE_COMMIT과 AFTER_COMMIT 리스너를 각각 지정해서 트랜잭션 처리 상황에 따라 각각 다른 리스너가 동작하도록 구성했습니다. 이를 통해 트랜잭션 단계에 따라 세밀하게 제어된 이벤트 처리가 가능해집니다.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleOutboxEvent(OutboxEvent event) {
// ...
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendToKafka(OutboxEvent event) {
// ...
}
TransactionPhase에 대해 조금 더 자세히 알아봅시다.
이벤트 리스너에 BEFORE_COMMIT을 사용하면 이벤트를 발행한 기존 메서드의 @Transactional이 커밋되기 직전에 같은 트랜잭션으로 묶여서 동작합니다. 이 이벤트 리스너 로직까지 성공적으로 완료되어야 전체 트랜잭션이 커밋됩니다. 따라서 만약 이 BEFORE_COMMIT 리스너에서 예외가 발생하면, 주 관심사인 서비스 메서드의 전체 트랜잭션이 함께 롤백됩니다.
이러한 특성 덕분에 Outbox 테이블에 데이터를 저장하는 등의 작업을 이 단계에서 수행하면, 메인 비즈니스 로직과 함께 원자적으로 처리할 수 있습니다. 즉, 모든 작업이 성공하거나 모두 실패하는 all-or-nothing 방식으로 동작하게 됩니다.
반면 AFTER_COMMIT을 사용한 이벤트 리스너는 트랜잭션이 성공적으로 커밋된 이후에 실행됩니다. 이는 주 트랜잭션과는 별개로 동작하므로, 이 리스너에서 발생하는 예외는 이미 커밋된 주 트랜잭션에 영향을 주지 않습니다. 이러한 특성 때문에 AFTER_COMMIT은 주로 외부 시스템과의 통신이나 추가적인 비동기 작업을 수행하는 데 적합합니다.
예를 들어, Kafka로 메시지를 전송하거나 이메일을 발송하는 등의 작업을 이 단계에서 수행할 수 있습니다. 주의할 점은 AFTER_COMMIT 리스너에서 오류가 발생해도 주 트랜잭션은 이미 커밋되었으므로, 이러한 오류 상황을 별도로 처리하고 관리해야 한다는 것입니다.
만약 지금처럼 두 가지 phase의 이벤트 리스너를 모두 사용하는 경우에는 BEFORE_COMMIT이 먼저 실행되고, 트랜잭션이 커밋된 후 AFTER_COMMIT이 실행됩니다.
diagram를 통해 outbox 테이블의 데이터 저장 흐름을 이해해보자.
유저 정보 수정 API 호출이 들어오면 서비스 레이어의 @Transactional이 걸린 '유저 정보 수정 및 저장' 메서드가 동작합니다. 이 메서드는 주요 비즈니스 로직을 수행한 후, 종료 직전에 Spring 이벤트를 발행합니다. 여기서 이벤트를 발행하는 주된 이유는 SRP(단일 책임 원칙)를 지키기 위해서입니다. 이벤트 발행을 통해 서비스 메서드가 가지는 역할과 책임을 '유저 정보 수정 + Outbox 저장 + Kafka 발행'에서 '유저 정보 수정'만으로 줄여 단일 책임을 가질 수 있게 됩니다.
before_commit 리스너의 동작 흐름을 diagram으로 이해해봅시다.
Spring 이벤트가 발행되면 작성된 2개의 event 리스너중 before_commit에 대한 리스너가 먼저 동작합니다. 왜냐하면 before_commit은 이벤트를 호출한 메서드가 commit되기 전에 동작하기 때문입니다. 이렇게 되면 주관심사 로직을 처리하고 이벤트를 발행한 메서드의 기존 트랜잭션과 before_commit 리스너가 호출하는 Outbox 데이터 추가 메서드의 트랜잭션이 연결되어 Outbox 테이블에 데이터를 잘 기록한뒤 전체 트랜잭션이 commit됩니다.
Outbox 테이블에는 아래와 같은 값이 기록됩니다.
필드 이름 | 값 |
id | 1 |
aggregate_type | "User" |
aggregate_id | "user-123" |
event_type | "UserUpdated" |
payload | {"userId": "user-123", "name": "John Doe", "email": "john.doe@example.com", "updatedFields": ["name", "email"]} |
timestamp | 2024-10-13 18:30:25.123 |
status | "READY_TO_PUBLISH" |
before_commit 리스너의 코드는 아래와 같은 식으로 작성했습니다.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handleOutboxEvent(OutboxEvent event) {
// outbox 테이블에 기록합니다.
memberOutboxUseCase.saveOutboxEvent(event);
}
다음으로 after_commit 리스너의 동작을 diagram으로 이해해봅시다.
before_commit 리스너가 잘 동작해서 Outbox테이블에 기록을 마치고 commit을 해서 주 트랜잭션(유저 정보 수정 + outbox 테이블에 기록)을 완료시킨 후에는 after_commit 리스너가 동작해서 Kafka 메시지 발행을 안전하게 처리한 뒤 Outbox 테이블의 status를 PUBLISHED(발행됨)로 업데이트합니다. (순서를 잘 지켜야 합니다.)
아래와 같이 Outbox 테이블의 status가 PUBLISHED로 업데이트 되어야 합니다.
필드 이름 | 값 |
id | 1 |
aggregate_type | "User" |
aggregate_id | "user-123" |
event_type | "UserUpdated" |
payload | {"userId": "user-123", "name": "John Doe", "email": "john.doe@example.com", "updatedFields": ["name", "email"]} |
timestamp | 2024-10-13 18:30:25.123 |
status | "PUBLISHED" |
After_commit 리스너의 코드는 아래와 같은 식으로 작성했습니다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendToKafka(OutboxEvent event) {
try {
// 메시지로 보낼 payload와 전송할 Kafka의 토픽 정보를 가져옵니다.
Long message = event.getPayload();
String topic = memberOutboxUseCase.getKafkaTopic(event);
// 추출한 토픽에 Kafka 메시지를 전송합니다.
kafkaProducerUseCase.sendWithRetry(topic, String.valueOf(message));
// Outbox 이벤트를 처리 대기 상태로 변경합니다.
memberOutboxUseCase.markOutboxEventPending(event);
} catch (Exception e) {
// exception: 예외 발생 시 Outbox 이벤트를 실패 상태로 변경하고 Span에 예외를 기록합니다.
memberOutboxUseCase.markOutboxEventFailed(event);
}
}
이러한 방식으로 Outbox 테이블은 데이터베이스 변경과 kafka 메시지 발행 사이의 안정적인 다리 역할을 합니다. 만약 Kafka 발행 과정에서 문제가 발생하더라도, Outbox 테이블에 기록된 정보를 바탕으로 추후에 재시도할 수 있어 데이터 일관성을 유지하는 데 크게 기여합니다. 이는 특히 MSA환경같이 네트워크 불안정이나 서버 중단 등 다양한 장애 상황이 발생할 수 있을때 굉장히 중요한 역할을 합니다.
Transactional Outbox Pattern을 쓰는 이유는 다음과 같은 장애 상황 때문이다.
바로 위에서 MSA 환경에서의 네트워크 불안정이나 서버 중간 같은 장애 상황때문에 Transactional Outbox Pattern을 사용한다고 소개드렸습니다. 그래서 MSA 환경에서 실제로 어떤 장애 상황들이 존재하는지 알고 넘어가는 것이 좋을것 같다는 생각이 들어 잠깐 장애 상황들을 정리해 보았습니다.
가장 흔히 발생할 수 있는 장애 상황은 Spring 서버가 데이터베이스에 유저 정보를 성공적으로 업데이트한 직후, 해당 변경사항을 Kafka에 발행하기 전에 갑자기 종료되는 경우입니다. 이 경우, 데이터베이스에는 새로운 유저 정보가 저장되어 있지만, 이 변경사항이 Kafka를 통해 다른 서비스에 전파되지 않아 심각한 데이터 불일치가 발생합니다.
Outbox 테이블을 사용하면 아래 diagram에서 빨간색으로 칠해둔 kafka에 메시지를 발행하는 after_commit 이벤트 리스너가 동작하기 전 또는 동작하는 도중 서버에 갑작스레 문제가 생겨 kafka에 메시지 발행을 하지 못했더라도 before_commit 리스너에서 이벤트에 대한 발행 내역을 Outbox 테이블에 기록했기 때문에 언제든지 Outbox 테이블에 저장된 이벤트 정보를 기반으로 kafka 메시지 발행을 재시도할 수 있습니다.
또 다른 중요한 장애 상황은 Kafka 브로커의 일시적인 장애입니다. 유저 정보를 성공적으로 업데이트하고 이를 Kafka에 발행하려는 시점에 Kafka 브로커가 응답하지 않는 경우, 변경된 유저 정보가 다른 서비스에 전파되지 못하는 문제가 발생합니다. Outbox 테이블이 없다면 이러한 상황에서 메시지 발행 재시도 로직을 복잡하게 구현해야 하며, 어떤 메시지가 실제로 발행되었고 어떤 것이 실패했는지 추적하기 어렵습니다. Outbox 테이블을 사용하면 발행에 실패한 메시지 정보를 안전하게 보관하고 있다가, Kafka가 다시 가용해졌을 때 순차적으로 발행을 재시도할 수 있습니다.
마지막으로 네트워크 문제로 인한 메시지 발행 실패는 분산 시스템에서 데이터 불일치를 초래할 수 있습니다. 예를 들어, 유저 정보 변경 메시지가 일부 Kafka 파티션에만 전달되는 경우, 시스템 전반의 데이터 동기화가 깨질 수 있습니다. Kafka는 재시도와 멱등성 보장 기능을 통해 이러한 문제를 일차적으로 해결하려 합니다.
Outbox 패턴은 이에 더해 추가적인 안전장치를 제공합니다. 이 패턴은 메시지 발행 시도를 데이터베이스에 기록하여, 어떤 메시지가 발행되었고 어떤 것이 실패했는지 추적할 수 있게 합니다. 이를 통해 장애 발생 시 누락된 메시지를 식별하고 재발행할 수 있는 기반을 마련합니다. 결과적으로 Outbox 패턴은 네트워크 문제로 인한 데이터 불일치 위험을 줄이고, 시스템의 전반적인 신뢰성을 높이는 데 기여합니다.
발행된 kafka 메시지는 각 서버에서 어떻게 consume해서 사용할까?
지금까지 우리는 Transactional Outbox Pattern을 사용하여 단일 서버 내에서 Kafka로 메시지를 발행하는 과정을 살펴보았습니다. 이제 Kafka 메시지가 성공적으로 발행된 이후의 과정, 즉 각 서버의 Consumer(Kafka 리스너)를 어떻게 설계하고 동작시켜야 하는지에 대해 알아보겠습니다.
Kafka에 메시지가 발행되면, 동기화가 필요한 서버에 구현된 Kafka 리스너가 이를 감지하고 메시지를 소비합니다. 그러나 여기서 주목해야 할 중요한 점이 있습니다. Outbox Pattern으로 Kafka 메시지를 발행한 서버 내부에도 Kafka 리스너가 존재해야 한다는 것입니다. 이는 단순히 외부 서버와의 동기화를 위해서가 아니라, 내부적인 상태 관리를 위해 필수적입니다.
내부 kafka 리스너의 동작을 diagram으로 이해해봅시다.
내부 Kafka 리스너의 주요 역할은 Kafka 메시지가 성공적으로 발행되었는지를 확인하고, 이 정보를 Outbox 테이블에 기록하는 것입니다. 외부 서버는 Outbox 테이블이 있는 데이터베이스에 직접 접근할 수 없기 때문에, 이러한 상태 업데이트는 반드시 Kafka 메시지를 발행한 서버 내부에서 이루어져야 합니다.
내부 Kafka 리스너가 동작한다는 것은 Outbox 패턴을 통해 Kafka 메시지가 성공적으로 발행되었음을 의미합니다. 따라서 Kafka 리스너 메서드가 호출되면, 해당 메시지와 연관된 Outbox 테이블의 레코드를 찾아 status 컬럼을 '발행됨' 또는 유사한 상태로 업데이트합니다. 이러한 메커니즘을 통해 시스템은 어떤 메시지가 성공적으로 처리되었는지 정확히 추적할 수 있으며, 필요한 경우 미처리된 메시지에 대한 재발행 등의 후속 조치를 효과적으로 취할 수 있습니다.
내부 kafka 리스너가 동작하면 Outbox 테이블의 status가 MESSAGE_CONSUME으로 업데이트됩니다.
필드 이름 | 값 |
id | 1 |
aggregate_type | "User" |
aggregate_id | "user-123" |
event_type | "UserUpdated" |
payload | {"userId": "user-123", "name": "John Doe", "email": "john.doe@example.com", "updatedFields": ["name", "email"]} |
timestamp | 2024-10-13 19:30:25.123 |
status | "MESSAGE_CONSUME" |
내부 kafka 리스너 코드는 아래와 같이 작성해주었습니다.
@KafkaListener(
topics = {"member-nickname-change-outbox"},
groupId = "member-group-nickname-change",
containerFactory = "kafkaListenerContainerFactory"
)
public void listenInternalNicknameChange(
ConsumerRecord<String, String> record,
Acknowledgment acknowledgment,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset
) throws JsonProcessingException {
// 1. record 값을 이벤트 객체로 변환한다.
String jsonValue = record.value();
MemberModifyEvent event = objectMapper.readValue(jsonValue, MemberModifyEvent.class);
// 2. outbox 테이블에 이벤트 처리 상태를 업데이트한다.
memberOutboxUseCase.markOutboxEventProcessed(event);
// 3. ack 처리
acknowledgment.acknowledge();
}
마지막으로 외부 kafka 리스너의 동작을 diagram으로 이해해봅시다.
내부 kafka 리스너와 동시에 외부 서버의 kafka 리스너는 message를 consume해서 사용하게 됩니다. 예를 들면 지금과 같이 데이터 동기화에 Transactional Outbox Pattern을 사용하는 경우에는 외부 Kafka 리스너가 발행된 message를 consume한 다음 최신 정보를 받아서 저장하기 위해 원본 데이터를 가진 서버에 Feign이나 gRPC 요청을 보냅니다. 이때 Kafka 메시지에는 ID만 포함(Zero Payload)하여 전송하고, 해당 ID를 이용해 최신 유저 정보를 요청하는 방식으로 데이터 동기화를 수행하면 항상 최신 데이터를 동기화 하는 것을 보장할 수 있습니다.
Transactional Outbox Pattern의 사용 사례
1. MSA 서버 간 유저 정보 동기화
- 유저 정보의 동기화가 필요한 경우, 이 패턴을 활용해 변경된 유저 정보를 다른 마이크로서비스와 동기화할 수 있습니다. 예를 들어, 유저 정보가 업데이트되면 트랜잭션 내에서 Spring 내부 이벤트를 발행합니다. BEFORE_COMMIT 단계의 이벤트 리스너에서 Outbox 테이블에 해당 변경 이벤트를 기록하고, AFTER_COMMIT 단계의 리스너에서 Kafka 메시지로 발행합니다. 이후 외부 Kafka 리스너가 이를 수신하고 최신 유저 정보를 가져가기 위해 Feign이나 gRPC 요청을 보냅니다. 이때 Kafka 메시지에는 ID만 포함(Zero Payload)하여 전송하고, 해당 ID를 이용해 최신 유저 정보를 요청하는 방식으로 데이터 동기화를 수행합니다.
2. 주문 서비스와 결제 서비스 간 이벤트 전파
- 주문 서비스에서 새로운 주문이 생성되면, 주문 생성 트랜잭션 내에서 Spring 내부 이벤트를 발행합니다. BEFORE_COMMIT 리스너에서 Outbox 테이블에 주문 생성 이벤트를 기록하고, AFTER_COMMIT 리스너에서 Kafka로 발행하여 결제 서비스에 알립니다. 결제 서비스는 이 이벤트를 수신하여 해당 주문에 대한 결제 프로세스를 진행할 수 있습니다. 이 과정에서 Transactional Outbox Pattern은 주문 생성 데이터와 이벤트 발행이 일관되게 처리되도록 보장합니다.
3. 비동기적 알림 시스템 구현
- 사용자가 특정 작업을 완료했을 때 알림(Notification)을 발송해야 하는 경우, 해당 작업의 트랜잭션 내에서 Spring 내부 이벤트를 발행합니다. BEFORE_COMMIT 리스너에서 Outbox 테이블에 알림 이벤트를 기록하고, AFTER_COMMIT 리스너에서 Kafka로 발행합니다. 알림 서비스는 이 Kafka 메시지를 소비하여 이메일이나 푸시 알림을 보냅니다. 이를 통해 비즈니스 로직과 알림 발송 로직을 명확히 분리하고, 비동기적으로 확장 가능한 알림 시스템을 구축할 수 있습니다.
4. 재고 관리 시스템에서의 이벤트 기반 업데이트
- 재고 관리 시스템에서 상품의 수량이 변경되면, 변경 트랜잭션 내에서 Spring 내부 이벤트를 발행합니다. BEFORE_COMMIT 리스너에서 Outbox 테이블에 상품 수량 변경 이벤트를 기록하고, AFTER_COMMIT 리스너에서 Kafka로 발행합니다. 주문 서비스 등 관련 서비스들은 이 Kafka 메시지를 수신하여 재고 상태에 따라 주문 가능 여부를 업데이트할 수 있습니다. 이를 통해 재고 변경과 주문 상태 간의 데이터 일관성을 유지할 수 있습니다.
마치며
이번 포스팅에서는 제가 1년간 MSA를 하면서 데이터 정합성을 어떻게 보장할지에 대해 공부한 내용을 간단히 정리해봤습니다.
MSA 프로젝트는 모놀리식 아키텍처에 비해 서버간 통신 장애나 데이터 정합성에 대해서 고려할 내용들이 정말 많았습니다. 코드를 작성할때 예외 처리를 잘 하는것이 중요한것처럼 MSA 아키텍처 설계에도 서버간 장애에 대한 처리와 데이터 싱크를 맞추는 것이 굉장히 중요하다는 것을 배울 수 있었던 것 같습니다.
저는 아직 많이 부족한 주니어 개발자이지만 Transactional Outbox Pattern을 설계해보면서 이 패턴을 좀 더 개선해서 누구나 쉽게 이해하고 더 편리하고 사용할 수 있도록 만들어보고자 하는 생각을 가지게 되었습니다. 지금까지는 선배님들께서 공유해주신 지혜 덕분에 쉽게 설계를 해왔지만 이제부터는 저 혼자만의 생각과 고민을 통해 아키텍처를 지속적으로 보강해 나가려고 합니다.
저는 여기서 글 작성을 마치며 추후 조금 더 좋은 방법과 개선점을 발견하면 새롭게 정리해서 공유해 드리겠습니다.
지금까지 긴 글 읽어주셔서 감사합니다!