이벤트 기반의 데이터 마이그레이션을 위한 Kafka Outbox Pattern 적용기

1. 배경
기존 서비스에서 신규 서비스로의 회원 데이터 이관을 위해, 이벤트 기반 아키텍처를 도입하였다. 회원이 처음 신규 시스템에 접근하고 이관에 동의하면, 회원 서비스는 MemberMigrated 이벤트를 발행한다. 이 이벤트를 트리거로 데이터 이관이 비동기적으로 수행되는 구조를 설계하였다.
2. 초기 설계
초기에는 다음과 같은 구조를 사용하였다.
- Kafka Consumer가 MemberMigrated 이벤트를 수신
- 내부 API 호출
- API는 즉시 202 Accepted 응답 반환
- 실제 데이터 migration은 비동기로 수행
- migration 완료 후 migrationDone 이벤트 발행
이벤트 기반이며, 데이터 마이그레이션이 필요하므로 API 응답을 항상 빠르게 처리하고 비동기적으로 데이터 마이그레이션을 처리하는 것이 좋다고 판단하여 이렇게 설계하였다. 이 구조는 API 응답을 빠르게 처리하고, 비동기 방식으로 시스템 부하를 분산할 수 있다는 장점이 있었다.
3. 문제점
하지만 위의 초기 설계에서는 문제점이 있다. Kafka Consumer 는 API 호출이 성공하여 202응답을 받으면 해당 이벤트가 정상적으로 처리되었다고 판단하고 offset 을 commit한다. 그러나 실제로 데이터 migration 처리는 비동기로 이루어지기 때문에, 이후 단계에서 실패하더라도 이를 Consumer가 인지할 수 없다. 결과적으로 다음과 같은 문제가 발생한다.
- 데이터 Migration 실패 여부를 추적할 수 없음
- 이벤트 재처리 불가능
- DLQ(Dead Letter Queue) 로의 전송 불가능
4. 개선 시도 : 동기 처리
이 문제를 해결하기 위해 초기 구조를 다음과 같이 변경했다.
- API 내부에서 데이터 Migration 을 동기적으로 수행한다.
- migration 성공 이후에 이벤트를 발행한다.
이 방식에서는 실제 데이터 처리 결과를 기준으로 이벤트가 발행되기 때문에 Consumer 는 처리 결과를 신뢰할 수 있다는 장점이 있다.
5. Dual Write Problem (이중 쓰기 문제) 의 발생
그렇지만 위의 개선시도에서, 또 다른 문제가 여전히 존재한다. 데이터를 적재하는 target Database 와 Kafka는 서로 다른 시스템이므로, 다음과 같은 불일치 상황이 발생할 수 있다.
Case 1
- DB 저장 성공
- Kafka publish 실패
-> 이벤트를 consume 하는 다른 서비스들은 이 결과를 받지 못한다.
Case 2
- Kafka publish 성공
- DB 저장 실패
-> 실제로는 존재하지 않는 데이터에 대한 이벤트가 발생할 수 있다.
이러한 문제를 Dual Write Problem 이라고 하며, 단순히 재시도 로직으로 이 문제를 해결하기 어렵다. 또한 데이터 정합성이 깨지는 원인이된다.
6. Outbox Pattern 으로 Dual Write Problem 해결하기
Dual Write Problem 문제를 해결하기 위한 방안으로, Outbox Pattern 을 도입했다. Outbox Pattern의 핵심 아이디어는, 이벤트를 Kafka 에 직접 발행하지 않고, 데이터베이스에 함께 상태를 저장한다는 것이다. 즉 데이터 마이그레이션과 kafka event 발행을 하나의 트랜잭션으로 묶는 효과를 얻을 수 있다. 이렇게 하면 Database 데이터 적재와 이벤트 생성이 보장된다는 일관성을 확보할 수 있다. Outbox table 을 Database 에 생성하면, 이후 CDC 또는 polling을 통해 outbox table 의 레코드를 읽어 kafka 로 이벤트를 발행한다. 이러한 Outbox Pattern 에도 단점은 존재한다. CDC 또는 polling 구조가 필요하므로 아키텍처와 인프라의 복잡도가 증가한다. 또한 이벤트가 중복으로 발행될 수 있기 때문에 Idempotent 처리가 필요하다.
7. Outbox Pattern 도입하기
7.1 Outbox Table 설계 (PostgreSQL 기준)
Outbox Pattern의 핵심은 이벤트를 DB에 저장하는 것이다.PostgreSQL 기준으로 Outbox Table 은 다음과 같이 설계할 수 있다.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) DEFAULT 'READY',
created_at TIMESTAMP DEFAULT NOW()
);
- aggregate_type: 도메인 타입 (예: Member)
- aggregate_id: 이벤트의 대상 식별자 (예: memberId). partition key로 활용할 수 있다.
- event_type: 이벤트 종류 (예: MemberMigrated)
- payload: 실제 이벤트 데이터 (JSON 형태)
- status: 처리 상태 (READY, SENT 등)
outbox table 에는 cdc connector 에서 접근하게 된다. 따라서 주로 kafka_user 를 새로 만들고 권한을 부여한다. CDC 를 활용하는 경우 Replication 권한 또는 WAL 접근 권한이 추가로 필요할 수 있다.
7.2 Polling vs CDC 방식의 비교
Outbox 테이블에 저장된 이벤트를 Kafka로 전달하는 방식은 크게 두 가지가 있다.
1. Polling 방식 : 애플리케이션 또는 별도 worker가 주기적으로 outbox 테이블을 조회한다.
장점
- 구현이 단순함
- 별도의 인프라가 필요하지 않다
단점
- 지연 발생
- DB 부하 증가
- 실시간성 감소
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
@Component
class OutboxPollingScheduler(
private val outboxRepository: OutboxRepository,
private val producer: OutboxKafkaProducer
) {
@Scheduled(fixedDelay = 1000) // 1초마다 polling
@Transactional
fun publishOutboxEvents() {
val events = outboxRepository.findTop100ByStatusOrderByCreatedAt()
events.forEach { event ->
try {
val topic = mapToTopic(event.eventType)
// application 에서 직접 topic 으로 이벤트를 발행한다.
producer.send(
topic = topic,
key = event.aggregateId,
payload = event.payload
)
event.status = OutboxStatus.SENT
} catch (e: Exception) {
event.status = OutboxStatus.FAILED
}
}
}
private fun mapToTopic(eventType: String): String {
return when (eventType) {
"MemberMigrated" -> "member.migrated"
"OrderCreated" -> "order.created"
else -> "unknown.event"
}
}
}
2. CDC (Change Data Capture) 방식

DB의 변경 로그(WAL)를 기반으로 이벤트를 감지하여 Kafka로 전달한다. 대표적으로 Debezium 을 사용한다. CDC란 데이터베이스의 변경 사항 (Insert/Update/Delete) 을 감지하여 외부 시스템으로 전달하는 기술이다. 즉, polling 에서는 어플리케이션이 직접 DB 를 주기적으로 조회 (polling) 해야 했지만, CDC 를 사용하면 DB 변경 자체를 이벤트로 활용할 수 있다.
장점
- 실시간에 가까운 처리
- DB 부하 최소화
- 확장성 우수
단점
- 초기 설정 복잡
- Kafka Connector 운영 필요
필자의 경우, 우선적으로 Debezium과 kafka connector 가 구축이 되어 있는 상황이었으며 data migration 특성상 db의 부하가 클 것이므로 부하를 최소화 하는 CDC 방식을 선택하게 되었다.
Q. CDC는 어떻게 동작할까 ?
CDC는 직접 table을 조회하지 않고, DB의 내부 로그 (WAL, binlog 등) 를 읽는다. PostgreSQL 기준으로 보면 INSERT 가 발생하면 WAL (Write Ahead Log)에 기록한다. CDC는 WAL 를 읽고 변경 이벤트를 Kafka로 전달한다. 따라서 DB 성능에 영향을 거의 주지 않게 되는 것이다.
7.3 Outbox Topic -> 실제 Topic 라우팅
먼저 outbox 에서 하나의 공통 outbox topic 으로 이벤트가 적재된다. 그 이후 서비스 별로 관심있는 이벤트가 다를 수 있으므로, topic 단위로 구독 구조를 분리하여 라우팅한다. Debezium 설정을 통해 event_type 기반으로 topic 자동 분기를 할 수 있다.