내부 이벤트의 한계
지난주에는 이벤트를 통해 결합도를 줄이는 구조를 만들었다. 주문은 주문만 알도록 하고, 결제나 쿠폰 사용 같은 후속 작업은 이벤트 핸들러가 이어받도록 분리했다.
하지만 애플리케이션 내부 이벤트만으로는 해결할 수 없는 문제들이 남아있었다.
- 신뢰성 부족: 예외가 나면 단순히 로그만 남고 이벤트 자체가 유실될 수 있음
- 확장성 제약: 하나의 애플리케이션 안에서만 소비할 수 있어 별도의 서비스가 이벤트를 받을 수 없음
결국 서비스 경계를 넘어 전달할 수 있는 이벤트 파이프라인이 필요했다.
외부 이벤트 브로커가 만족해야 하는 요구사항을 정리해 보면 다음과 같았다.
- At-Least-Once 전달 보장
- 순서 보장
- 재시도와 DLQ
- 소비자 그룹 확장성
이 모든 요구사항을 충족해주는 도구가 바로 Kafka였고, 그래서 이번 주는 Kafka 기반 이벤트 파이프라인을 설계해보기로 했다.
Producer 설계
시스템 구조 설계
[ commerce-api(Producer) ] → [ Kafka Topics ] → [ Consumer App ]
├─ Event Log 기록
├─ Cache 무효화
└─ 상품 Metrics 집계
Producer와 Consumer를 같은 앱 안에 둘 수도 있었지만 주체를 명확히 하고 장애 격리 측면에서도 분리하는 것이 낫다고 생각해 별도의 앱으로 분리하게 되었다.
- Producer는 이벤트가 발생했다는 사실만 외부로 발행
- Consumer는 그걸 이어받아 후속 처리만 전담
Producer 위치
처음에는 Publisher 로직 안에서 이벤트를 ApplicationEventPublisher와 KafkaTemplate 두 갈래로 동시에 발행하는 방법을 생각했다.
하지만 하나의 퍼블리셔 안에서 두 번 퍼블리싱하는 게 과연 괜찮을까? 만약 하나는 성공하고 다른 하나는 실패한다면?
내부 이벤트는 진행되는데 외부 이벤트는 사라져 버리거나, 반대로 외부 이벤트만 남고 내부 처리는 실패하는 어정쩡한 상태가 만들어질 수 있을 것 같았다.
그래서 카프카 발행은 애초에 내부 이벤트와 분리된 외부 시스템 호출로 보는 게 맞다고 판단했다. 카프카는 REST API처럼 외부 리소스에 요청을 던지는 것에 가깝다고 생각해 트랜잭션을 아예 분리해 handler 로직 안에서 발행하는 방식이 더 자연스럽다고 생각했다.
public class LikeEventHandler {
private final ProductService productService;
private final KafkaTemplate<Object, Object> kafkaTemplate;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLikeAdded(LikeAddEvent event) {
productService.increaseLike(event.getProductId());
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publishLikeAddEvent(LikeAddEvent event) {
KafkaMessage<LikeAddEvent> message = KafkaMessage.of(event, "LIKE_ADD");
kafkaTemplate.send("catalog-events", event.getProductId(), message);
}
}
Topic 분리 전략
토픽을 어디까지 잘게 나눌지도 고민이 많았다. 지나치게 세분화하면 관리 비용이 커질 것이고, 너무 뭉쳐두면 컨슈머 내에서 별도의 후속 처리가 필요해 복잡도가 올라갈 것 같았다.
결국 도메인 단위로 묶되 이벤트 타입을 같이 전달해 컨슈머 내에서 구분하는 방식을 사용해 보기로 했다. catalog-events 토픽으로 재고, 좋아요, 상품 수정 이벤트 메시지를 보내고, 메시지에 이벤트 타입 필드를 두어 구분하는 식이다.
이렇게 하면 토픽 개수는 적당히 단순하게 유지하면서 컨슈머가 필요한 이벤트만 골라 처리할 수 있을 거라 생각했다.
아래 표는 각 이벤트가 어떤 토픽을 거쳐 어떤 컨슈머에서 처리되는지 정리한 것이다.
| 발행 이벤트 (Producer) | 발행 토픽 | Consumer |
| OrderCreatedEvent | order-events | EventLogConsumer |
| PaymentSuccessEvent, PaymentFailEvent,PaymentCallbackFailEvent, PaymentRequestSuccessEvent | payment-events | EventLogConsumer |
| PointHistoryEvent | user-events | EventLogConsumer |
| CouponUseEvent | user-events | EventLogConsumer |
| ProductViewEvent | product-events | EventLogConsumer, CatalogEventConsumer |
| LikeAddEvent, LikeDeleteEvent | catalog-events | EventLogConsumer, CatalogEventConsumer |
| StockIncreaseEvent, StockDecreaseEvent | catalog-events | EventLogConsumer, CatalogEventConsumer |
| BrandModifyEvent | cache-events | EventLogConsumer, CacheEventConsumer |
Consumer 설계
컨슈머 앱에서 담당해야 할 일은 크게 세 가지였다.
- 감사 로그
- 모든 이벤트를 event_log 테이블에 저장
- 상품 데이터 집계
- 좋아요수, 판매량, 조회수 지표를 product_metrics 테이블에 저장
- 캐시 무효화
- 캐시된 데이터에 변경 이벤트가 발생하면 Redis 캐시 제거
멱등성 보장
카프카는 기본적으로 At-Least-Once 전달을 보장한다. 같은 이벤트가 여러 번 소비될 수 있기 때문에 중복 처리 방지가 필요했다.
이를 위해 컨슈머 그룹별로 처리 여부를 기록하는 event_handled 테이블을 뒀다.
event_id, consumer_group_id 기준으로 저장해서 같은 이벤트가 다시 와도 최종적으로는 한 번만 반영되도록 했다.
처음엔 event_log 테이블과 합쳐도 되지 않을까 싶었는데 성격이 다르다고 생각해 분리하게 되었다.
- event_log: 원본 이벤트 보관용
- event_handled: 컨슈머 그룹별 처리 여부 체크용
두 역할이 다르다 보니 테이블도 분리하는 게 맞다고 봤다.
그리고 분리한 김에 Redis로 구현하는 게 나을지도 고민했다.
어차피 처리 여부 데이터는 오래 쌓아둘 필요가 없는 로그성이기 때문에 만료 정책이 있는 레디스가 더 적합하지 않을까 했고 성능상 조회 속도도 더 빠를 것 같았기 때문이다.
하지만 TTL 관리나 장애 대응 같은 운영 복잡도가 따라오기 때문에 일단 DB 레벨에서 구현했다.
추후 트래픽이 커질 가능성을 고려해 보는 단계에서 전환을 생각해 볼 수 있을 것 같다.
Ack 전략 분리
모든 이벤트를 동일한 수준으로 정합성을 지킬 필요가 없다고 생각해 전략을 분리하게 되었다.
정합성이 중요한 이벤트와 상대적으로 느슨하게 처리해도 되는 이벤트를 나눠 전략을 다르게 가져갔다.
- EventLogConsumer
- 이벤트 로그는 모든 이벤트가 반드시 기록되어야함
- DB에 정상 반영된 경우에만 ack.acknowledge()
- 중복은 event_handled 테이블로 검증
public void consume(List<ConsumerRecord<String, KafkaMessage<?>>> records, Acknowledgment ack) throws JsonProcessingException {
for(ConsumerRecord<String, KafkaMessage<?>> record : records) {
KafkaMessage<?> message = record.value();
if (!eventHandledService.markHandled(message.eventId(), groupId)) {
continue;
}
- CatalogEventConsumer / CacheEventConsumer
- 좋아요 수나 캐시 무효화는 유저가 인지하기 어려운 영역임
- 약간의 중복이나 유실은 TTL, 재집계 로직으로 보정 가능함
- 빠른 ack 처리로 성능 우선시
이벤트별로 정합성과 성능의 트레이드오프를 다르게 가져가면서 전체 시스템의 효율을 높이는 방법을 고민해 보았다.
실패 시 전략
마지막으로 고민했던 건 실패 이벤트 처리였다.
현재는 DefaultErrorHandler + DeadLetterPublishingRecoverer 조합으로 3회 재시도 후에도 실패하면 .DLT 토픽에 메시지를 넣도록 했다.
하지만 아직 DLQ 메시지를 어떻게 운영할진 좀 더 고민해봐야 할 지점인 것 같다.
마무리
이번 주에는 카프카 기반 이벤트 파이프라인을 구현하면서 책임을 분리하고 서비스 경계를 넘어 이벤트를 전달하는 프로세스를 경험해 볼 수 있었다.
하지만 여전히 풀리지 않은 과제도 남아 있다.
- DLQ 운영 전략
- 원본 토픽별로 분리(catalog-events.DLT) vs 통합(dlq-events)
- 실패 메시지를 다시 재처리 vs 단순 모니터링 용도
- Batch vs Single Listener
- 현재는 모든 컨슈머를 batch 모드로 작성했지만, EventLogConsumer처럼 정합성이 중요한 로직은 single listener가 더 적절할까 하는 생각도 있다.
아무래도 이런 선택은 실제 운영 맥락에 따라 달라질 수 있어서 더 많은 레퍼런스를 찾아보고 정리한 뒤에야 확실한 결정을 내릴 수 있을 것 같다.
사실 이번 주에 카프카를 붙일 때는 다들 카프카 카프카 하던데 나도..! 라는 기대 반, 걱정 반으로 시작했다.
막상 부딪혀 보니 단순히 카프카 썼다로 끝나는 게 아니라 운영 관점에서 고민할 지점이 정말 많다는 걸 알게 됐다.
매번 설계 때마다 느끼는 건 모든 걸 다 가져가려 하기보다 무엇을 보장하고 무엇을 포기할지 명확히 하는 게 제일 중요하다는 것이다. 두 마리 토끼를 동시에 잡을 수는 없으니까 어떤 건 확실히 책임지고 어떤 건 과감히 놓아주는 선택이 필요할 때가 항상 오는 것 같다.
내가 내린 결정들이 정답이 아니더라도 이제는 점점 감이 아니라 나름의 근거를 바탕으로 선택하고 있다는 점에서 조금은 성장했을까 하는 생각도 든다.
앞으로도 이런 고민과 선택들이 쌓이다 보면 언젠가는 나만의 운영 전략이 만들어지지 않을까!
'Study > Architecture' 카테고리의 다른 글
| Spring Batch로 랭킹 집계 확장하기 (0) | 2025.09.19 |
|---|---|
| 실시간 상품 랭킹? Redis ZSET만 쓰면 끝인 줄 알았는데요 (0) | 2025.09.12 |
| 주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까? (2) | 2025.08.29 |
| 장애 대응 시스템 구축하기 (0) | 2025.08.22 |
| 캐시 구조 개선 (0) | 2025.08.22 |