- 일전의 공부한 내용을 실제로 구현해보면서 공부해보고자 시작한 프로젝트다.
- 이전 글
- 아직 공부 중인 개념입니다. 조금 틀려도 너른 이해 부탁드립니다!
- 이 글은 개인적인 의견을 다룹니다.
다루는 내용
- 도메인 로직을 다루면서 사용한 Kafka
도메인 로직을 다루면서 사용한 Kafka
목차
1. 서로 다른 도메인?
2. 고민한 아키텍쳐
3. Event, Outbox, ZeroPayload
4. Parallel Consumer
1. 서로 다른 도메인?
- 예약 시스템을 구성하면서 매장에서 기획하는 스케쥴과 사용자가 진행하는 예약은 다르다는 감각에서부터 시작했다.
- 실제로 이 둘은 서로 다른 Bounded Context에 속한다.
- 이를 구분하지 않으면 한 Aggregate가 다른 Aggregate를 생성하고 호출하는 Anti-Pattern이 발생할 수 있다.
- 또한 ‘예약 실패시 매장 스케쥴에 등록이 실패되어야 하는가?’ 라는 고민이 됐었다.
2. 고민한 아키텍쳐
1. 단순 Spring Event의 도입
- 장점
- 도입이 별도 인프라 없이 쉽다
- 도메인 간 직접 결합을 낮출 수 있다
- 확장성이 개선된다.
- 단점
- 같은 JVM 내에서만 가능하다
- 서버 재시작시 이벤트가 유실된다.
2. 별도 인프라 도입
- Redis Pub/Sub:
- 장점
- Redisson을 사용하면 쉽게 도입할 수 있다.
- 단점
- 여전히 메시지 보장성이 부족하다
- 장점
- Kafka:
- 장점
- 높은 처리량
- 순서 보장( 파티션이 같다면 )
- 메시지 영속성
- 단점
- 별도 인프라 구성
- 장점
결과적으로 위의 두 가지 모두를 선택했다. UseCase에서
flowchart LR
A[TimeTable 점유 완료]
B[Domain Event 발생]
C[Kafka 발행]
D[Reservation 생성]
A --> B --> C --> D
- Domain Event
- Domain Event의 장점
- Aggragate 간 강결합 제거
- 트랜잭션 분리
- Bounded Context 간 통신
- Domain Event의 특징
- 과거 시제로 명명한다.
- 불변 객체다.
- Event 처리에 최소 정보를 넘긴다.
- Domain Event의 장점
- DDD 관점에서 고려하면
- Domain이 비즈니스 로직 실행과 동시에 Event 발행의 주체가 된다.
- Domain 객체가 단순 데이터 덩어리가 아닌 자기 스스로 결정하는 행동을 가진 객체가 된다.
- Domain Event로 동작을 내부로 캡슐화가 가능하다.
그리하여 아래와 같은 flow로 표현할 수 있다.
flowchart LR
subgraph TimeTable_Aggregate
A1[스케쥴 점유 완료]
A2[DomainEven 발행]
A1 --> A2
end
subgraph Application_Event_Listener
B1[TransactionalEventListener로 Listen]
B2[Outbox 같은 트랜잭션에서 저장]
B1 --> B2
end
subgraph Kafka_Publisher
C1[AfterCommit Hook으로 Event Listen]
C2[Kafka 발행]
C1 --> C2
end
subgraph Reservation_Context
D1[Kafka Consume]
D2[Reservation 생성]
D1 --> D2
end
A2 --> B1
B2 --> C1
C2 --> D1
3. Event, Outbox, ZeroPayload
1. Event
- 우선 Domain Event를 Kafka Event로 변경하여 발행하는 과정이 필요했다. Domain Event 발행 자체가 Outbox를 발행하고 Kafka를 Publish하는 것과 UseCase를 분리하기 위함에서 시작됐었다.
- Kafka Event를 발행하면서 고민한 부분은 이벤트 버전 관리였다. 이벤트 자체는 같지만 내부 스키마에 따라 하위 호환을 보장해야 할 수도 있다는 고민이 들었다. 이에 따라 이벤트 버전을 명시하고 내리는 방향으로 진행했다.
- Topic 의 경우 도메인명으로 할지, 이벤트로 할지 고민했으나 결과적으로 이벤트로 진행했다. 이는 Kafka Consumer의 역할을 이벤트로 국한시켜 별도 분기 없이 처리하기 위함이었다. 이는 배포 단위와도 겹치는 내용이 될 가능성이 높기 때문에 최대한 공용으로 사용하지 않아 영향이 적은 방향으로 모색하였다.
2. Outbox
- Kafka Event 발행 시 ‘데이터베이스에 쓰기는 성공했지만 Kafka Event 발행에는 실패할 수도 있다.’는 고민에서 시작됐다.
sequenceDiagram
participant User
participant CreateTimeTableOccupancyService
participant TimeTableOccupiedDomainEventPublisher
participant DB
participant TimeTableOccupiedDomainEventListener
participant KafkaTemplate
participant Consumer
User->>CreateTimeTableOccupancyService: 테이블 점유 요청
activate CreateTimeTableOccupancyService
Note over CreateTimeTableOccupancyService: @Transactional 시작
CreateTimeTableOccupancyService->>DB: 1. TimeTable Occupancy 저장
CreateTimeTableOccupancyService->>CreateTimeTableOccupancyService: 2. TimeTableOccupiedDomainEvent 생성
CreateTimeTableOccupancyService->>TimeTableOccupiedDomainEventPublisher: DelegateReservation.command()
TimeTableOccupiedDomainEventPublisher->>TimeTableOccupiedDomainEventPublisher: applicationEventPublisher.publishEvent()
Note over TimeTableOccupiedDomainEventListener: @TransactionalEventListener(BEFORE_COMMIT)
TimeTableOccupiedDomainEventListener->>TimeTableOccupiedDomainEventListener: TimeTableOccupiedEvent 생성
TimeTableOccupiedDomainEventListener->>DB: OutBox 엔티티 저장 (status=PUBLISHED)
CreateTimeTableOccupancyService->>User: 점유 완료 응답
Note over CreateTimeTableOccupancyService: @Transactional 커밋
deactivate CreateTimeTableOccupancyService
Note over TimeTableOccupiedDomainEventListener: @TransactionalEventListener(AFTER_COMMIT)
activate TimeTableOccupiedDomainEventListener
TimeTableOccupiedDomainEventListener->>TimeTableOccupiedDomainEventListener: OutBox ID로 조회
alt Kafka 발행 성공 (10초 타임아웃)
TimeTableOccupiedDomainEventListener->>KafkaTemplate: send(topic, key, event)
TimeTableOccupiedDomainEventListener->>DB: outbox.succeeded() → PROCESSED
KafkaTemplate->>Consumer: TIME_TABLE_OCCUPIED 이벤트 전달
else Kafka 발행 실패
TimeTableOccupiedDomainEventListener->>DB: outbox.failed() → ERRORED
Note over TimeTableOccupiedDomainEventListener: 재시도 대기 (count++)
end
deactivate TimeTableOccupiedDomainEventListener
- 위와 같은 형태로 구현하였다.
- Spring TransactionalEventListener를 사용하여 ‘BEFORE_COMMIT’에서 Outbox 저장, ‘AFTER_COMMIT’에서 Kafka Event를 발행
- 비즈니스 로직과 Kafka 발행이 전혀 다른 트랜잭션에서 실행되도록 설계됐다.
- ACID를 보장할 수 있습니다. 비즈니스 데이터와 Outbox 이벤트가 동일 트랜잭션에서 저장됩니다.
- Kafka 장애가 비즈니스 로직과 전혀 무관한 트랜잭션에서 진행됩니다.
-
이벤트 유실을 방지하며, 중복 사안은 체크를 사전 체크를 통해서 멱등성을 보장합니다.
- 아래와 같은 flow로 진행됩니다.
flowchart TD
Start[Outbox 이벤트 생성] --> Published[상태: PUBLISHED]
Published --> Batch{배치 프로세서}
Batch --> Try[Kafka 전송 시도]
Try --> Success{전송 성공?}
Success -->|성공| Processed[상태: PROCESSED]
Success -->|실패| Failed[상태: ERRORED]
Failed --> Count{재시도 횟수 체크}
Count -->|3회 미만| Retry[지수 백오프 대기]
Count -->|3회 초과| DLQ[POISONED MESSAGE 처리]
Retry --> RetryDelay[30초/60초/120초 대기]
RetryDelay --> Try
Processed --> Cleanup{7일 경과?}
Cleanup -->|Yes| Delete[자동 삭제]
Cleanup -->|No| Keep[보관]
DLQ --> Manual[수동 개입 필요]
Manual --> Archive[아카이브 저장]
style Processed fill:#90EE90
style Failed fill:#FFB6C1
style DLQ fill:#FFA500
style Manual fill:#FF6B6B
이를 통해서 아래와 같은 사실을 알 수 있다. 1. Outbox의 상태 관리로 실패 처리를 진행할 수 있다. 2. 지수 백오프와 최대 재시도 횟수를 제한하고 반복하여 재실행할 수 있다. 3. 중복 방지를 위해서 멱등성 키로 Consumer 레벨에서 중복 처리를 진행할 수 있다.
3. ZeroPayload
- Payload 전략으로는
ZeroPayload,MinimalPayload,FullPayload,DelataPayload,ReferencePayload등이 있다.
- ZeroPayload: 메시지 본문 없이 키와 헤더만 사용한다.
- MinimalPayload: 식별자와 최소 필수 정보만 포함
- FullPayload: 이벤트 관련 모든 데이터를 메시지에 포함
- DeltaPayload: 변경된 필드만 전송(before/ after 변경분)
- ReferencePayload: 데이터 저장 위치(URL) 참조 정보만 전달
- 이와 같은 전략 중
ZeroPayload를 선택한 이유는 이벤트 코드를 최소화하고 Consumer가 RestAPI로 조회하여 늘 항상 최신 데이터를 조회하도록 유도할 수 있습니다. 마지막으로 Kafka의 네트워크 트래픽을 감소시킬 수 있다는 점도 한 몫했습니다. - 이러한 선택으로 스키마 변경에 유연해졌지만 RestAPI를 별도로 실행해야 한다는 단점이 생기기도 헀습니다.
4. Parallel Consumer
- 서로 독립된 이벤트에 대해서 파티셔닝을 진행하게 되는데 파티션 개수가 동시성을 결정한다.
- 기본 Consumer는 파티션당 1개의 쓰레드만 사용한다.
- Parallel Consumer는 하나의 파티션 당 여러 개의 쓰레드를 할당할 수 있게 해서 효율성을 올려준다.
- 이는 파티션 수 수정 없이 수직 확장을 가능케 한다. 단, 여러 가지 어려움이 있지만 특히나 offset 관리가 복잡하다는 단점이 있다.
sequenceDiagram
participant Kafka
participant PC as Parallel Consumer
participant W1 as Worker 1
participant W2 as Worker 2
participant W3 as Worker 3
Kafka->>PC: Offset 100: Message A1
Kafka->>PC: Offset 101: Message B1
Kafka->>PC: Offset 102: Message C1
Kafka->>PC: Offset 103: Message A2
Kafka->>PC: Offset 104: Message B2
PC->>W1: A1 (offset 100)
PC->>W2: B1 (offset 101)
PC->>W3: C1 (offset 102)
PC->>W1: A2 (offset 103) - queued
PC->>W2: B2 (offset 104) - queued
Note over W2: B1 완료 (offset 101)
Note over W3: C1 완료 (offset 102)
Note over W1: A1 아직 처리 중...
Note over PC: 🚨 문제: offset 102까지 완료됐지만<br/>offset 100이 미완료<br/>어디까지 커밋할까?
Note over PC: 해결: offset 100 완료 대기<br/>또는 복잡한 추적 로직 필요
W1-->>PC: A1 완료 (offset 100)
PC->>Kafka: ✅ 안전하게 offset 104까지 커밋
- In-flight 상태, Out-of-order 현상에서 각각의 상태값을 추적하고 이전의 상태 값부터 연속하여 완료되어야만 offset을 commit 하도록 구현되어 있다.
- 이러한 메커니즘으로 구현되어 있어 메시지 유실 없이 offset을 commit 할 수 있다.
이러한 선택은 다소 도전적일 수 있지만 Kafka의 동작을 더 잘 이해할 수 있는 계기가 되었다.