
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 자동 분기를 할 수 있다.
'Backend' 카테고리의 다른 글
| Keycloak SSO 연동 과정에서 이해한 인증과 인가 (0) | 2026.03.28 |
|---|---|
| 멱등성을 보장하는 시스템 개발하기 (6) | 2024.10.13 |
| [sqlalchemy] Entity.metadata.create_all() 자동으로 테이블 생성하기 (0) | 2024.08.08 |
| [GitHub] REMOTE HOST IDENTIFICATION HAS CHANGED 해결방법 (0) | 2023.03.26 |