본문 바로가기

Programming/Technical Writing

kafka를 통한 이벤트 기반 아키텍처

TL;DR
"이벤트로 분리할 것인가 말 것인가"라는 판단이 설계의 핵심이다. 기술 도입보다, 어디에 경계를 그을지를 결정하는 것이 더 중요하다.

 

 

 

커머스 프로젝트에서 주문을 처리하는 서비스 코드가 점점 비대해지고 있었다. 주문을 생성하고 결제를 처리하는 핵심 로직 사이에 알림 발송, 유저 행동 로깅, 좋아요 집계 같은 부가 로직들이 동기적으로 끼어들어 있었다. 알림 서버가 느려지면 주문 응답도 느려지고, 로깅 DB에 문제가 생기면 좋아요가 실패하는 상황이 발생할 수 있었다.

이번 과제에서는 이 문제를 세 단계로 풀었다. 먼저 Spring ApplicationEvent로 프로세스 내에서 주요 로직과 부가 로직의 경계를 나누고, Kafka로 프로세스 간 이벤트 파이프라인을 구축한 뒤, 그 파이프라인 위에서 선착순 쿠폰 발급이라는 실전 시나리오를 구현했다. 이 글에서는 각 단계에서 마주한 설계 선택지들과, 왜 그 결정을 내렸는지를 정리한다.

 


1. 무엇을 이벤트로 분리할 것인가 — 판단 기준 세우기

문제: 모든 로직이 하나의 트랜잭션에 묶여 있다

주문 서비스의 코드를 보면, 하나의 @Transactional 메서드 안에서 주문 생성 → 결제 처리 → 알림 발송 → 유저 행동 로깅 → 판매량 집계가 순차적으로 실행되고 있었다. 이 구조의 문제는 명확하다. 알림 발송이 실패하면 주문 전체가 롤백된다. 로깅 DB에 장애가 나면 결제까지 취소된다. 부가 로직의 장애가 핵심 비즈니스 로직을 오염시키는 구조다.

판단 기준: "이게 실패해도 주문은 성공해야 하는가?"

이벤트 분리를 고민할 때, 세 가지 기준이 후보에 올랐다.

첫 번째는 "모든 부가 로직을 일괄적으로 이벤트로 분리"하는 방식이다. 일관성은 있지만, 정말 동기적으로 처리해야 하는 것까지 분리할 위험이 있다. 두 번째는 "유저 응답 시간에 기여하지 않는 것만 분리"하는 방식이다. 합리적이지만, 응답 시간에는 기여하지 않아도 트랜잭션 성공에는 필수적인 로직을 놓칠 수 있다.

최종적으로 채택한 기준은 실패 허용도다. "이 로직이 실패해도 주요 트랜잭션은 성공해야 하는가?"라는 질문에 "예"라고 답할 수 있으면 이벤트로 분리한다. 이 기준으로 분류하면 결과가 깔끔하게 나온다.

이벤트로 분리한 것들은 좋아요 집계(like_count 갱신), 주문 완료 알림, 유저 행동 로깅이다. 이것들은 실패해도 주문이나 좋아요 자체는 성공해야 한다. 반면 결제 처리는 분리하지 않았다. 결제가 실패하면 주문도 실패해야 하기 때문이다.

이 판단을 적용하면 한 가지를 수용해야 한다. 좋아요를 눌렀는데 like_count가 바로 올라가지 않는 순간이 생긴다. 이것이 eventual consistency의 실제 비용이다. "좋아요는 성공했지만 집계는 아직 반영 안 됨" 상태를 일시적으로 허용하는 것이다.

 


2. 이벤트 클래스 설계 — Entity를 담을 것인가, 값을 담을 것인가

이벤트 클래스를 설계할 때 가장 먼저 부딪히는 질문은 "이벤트에 뭘 담을 것인가"이다.

가장 편한 방법은 OrderCompletedEvent(Order order)처럼 Entity 객체를 통째로 넣는 것이다. 코드는 간결하다. 하지만 이 방식은 두 가지 문제가 있다. 첫째, Entity가 변경될 때마다 이벤트의 계약도 함께 변경된다. 필드가 추가되거나 연관관계가 바뀌면 리스너 쪽도 영향을 받는다. 둘째, AFTER_COMMIT 시점에서 리스너가 실행될 때 Entity의 지연 로딩(Lazy Loading)이 동작하지 않을 수 있다. 트랜잭션이 이미 끝났기 때문이다.

반대쪽 극단은 DomainEvent(String type, Map<String, Object> payload) 같은 범용 구조다. 유연하지만 타입 안전성이 없다. payload.get("userId")를 호출할 때 키 오타가 나면 런타임에서야 발견된다.

채택한 방식은 필요한 필드만 원시 타입으로 추출하는 것이다.

 
 
public record OrderCompletedEvent(
    Long orderId,
    Long userId,
    Long productId,
    int quantity,
    long totalAmount,
    LocalDateTime occurredAt
) {}

 

record를 쓰면 불변(immutable) 객체가 되고, 리스너가 @Async로 다른 스레드에서 실행될 때도 안전하다. 리스너가 추가 DB 조회 없이 처리할 수 있을 만큼의 정보를 담되, Entity의 전체 스냅샷을 가져가지는 않는다. 원칙을 한 문장으로 정리하면 이렇다: 이벤트는 "무슨 일이 일어났는지"를 전달하는 것이지, "현재 상태가 어떤지"를 전달하는 것이 아니다.

 

 


3. @TransactionalEventListener의 phase — 같은 이벤트, 다른 타이밍

Spring의 @TransactionalEventListener에는 BEFORE_COMMIT과 AFTER_COMMIT이 있다. 이 둘의 차이는 단순히 실행 시점의 차이가 아니라, 트랜잭션 참여 여부의 차이다.

BEFORE_COMMIT 리스너는 아직 트랜잭션이 열려 있으므로, 리스너 안에서 DB에 쓰면 비즈니스 로직과 같은 트랜잭션에 포함된다. 리스너에서 예외가 발생하면 비즈니스 트랜잭션 전체가 롤백된다.

AFTER_COMMIT 리스너는 트랜잭션이 이미 커밋된 후에 실행된다. 따라서 리스너에서 DB에 쓰려면 새로운 트랜잭션(REQUIRES_NEW)이 필요하다. 리스너에서 예외가 발생해도 이미 커밋된 비즈니스 트랜잭션에는 영향이 없다.

이 구분에 따라 리스너의 phase를 다르게 설정했다.

좋아요 집계(like_count 갱신), 주문 완료 알림, 유저 행동 로깅은 모두 AFTER_COMMIT으로 설정했다. 이것들은 주요 트랜잭션이 확정된 후에 실행되어야 하고, 실패해도 주요 트랜잭션에 영향을 주면 안 되기 때문이다. 반면 Outbox INSERT(Step 2에서 추가)는 BEFORE_COMMIT으로 설정했다. Outbox 기록이 비즈니스 데이터와 같은 트랜잭션에 묶여야 At Least Once 보장이 성립하기 때문이다.

여기서 하나 주의할 점이 있다. AFTER_COMMIT 시점에는 기존 트랜잭션 컨텍스트가 이미 끝난 상태다. 이 상태에서 @Transactional 없이 DB에 쓰면 트랜잭션 없이 실행되거나, Spring이 자동으로 새 트랜잭션을 열지 않는다. 그래서 좋아요 집계 리스너에는 @Transactional(propagation = REQUIRES_NEW)를 명시적으로 붙여야 한다.

 
 
@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleLikeCreated(LikeCreatedEvent event) {
    productRepository.incrementLikeCount(event.productId());
}
```

이 구조의 트레이드오프는 명확하다. 집계가 실패해도 좋아요는 살아남지만, DB 커넥션을 하나 더 사용한다. 그리고 좋아요와 `like_count` 사이에 일시적 불일치가 생길 수 있다. 이것이 eventual consistency를 선택한 대가다.

---

## 4. Transactional Outbox Pattern — DB 쓰기와 Kafka 발행의 원자성 문제

### 문제: 두 시스템에 동시에 쓸 수 없다

Step 1에서 `ApplicationEvent`로 프로세스 내 경계를 나눴다면, Step 2에서는 프로세스 간 경계를 넘어야 한다. `commerce-api`에서 발생한 이벤트를 `commerce-collector`라는 별도 서비스가 받아서 처리하는 구조다. 중간에 Kafka가 들어간다.

여기서 근본적인 문제가 생긴다. 주문을 DB에 저장하고, 동시에 Kafka에 이벤트를 발행해야 하는데, 이 둘은 하나의 트랜잭션으로 묶을 수 없다. DB는 커밋됐는데 Kafka 발행이 실패하면? 또는 Kafka에는 발행됐는데 DB가 롤백되면? 데이터 불일치가 생긴다.

### 해결: Kafka에 직접 보내지 말고, DB에 같이 쓴다

Transactional Outbox 패턴의 핵심 아이디어는 단순하다. **Kafka에 직접 보내지 말고, 비즈니스 데이터와 같은 DB 트랜잭션 안에서 `outbox_event` 테이블에 INSERT한다.** 그러면 비즈니스 데이터와 이벤트 기록이 원자적으로 저장된다. 별도의 발행기(Publisher)가 이 테이블을 읽어서 Kafka로 보내는 건 그 다음 문제다.
```
비즈니스 트랜잭션:
  1. orders INSERT
  2. outbox_event INSERT  ← 같은 트랜잭션
  3. COMMIT

별도 프로세스:
  4. outbox_event에서 published=false 조회
  5. Kafka 발행
  6. published=true 업데이트

 

이 "별도 프로세스"를 어떻게 구현할지에 두 가지 선택지가 있었다.

Polling Publisher는 @Scheduled로 주기적으로 DB를 조회하는 방식이다. 구현이 단순하고 별도 인프라가 필요 없다. 단점은 polling 주기만큼 발행 지연이 생기고, 주기적 SELECT가 DB에 부하를 준다는 것이다.

**CDC(Change Data Capture)**는 MySQL의 binlog를 Debezium 같은 도구로 캡처해서 자동 발행하는 방식이다. 거의 실시간이고 DB에 추가 SELECT 부하가 없지만, Debezium + Kafka Connect라는 별도 인프라를 운영해야 한다.

이번 과제에서는 Polling Publisher를 채택했다. 1초 간격으로 published=false인 row를 50개씩 조회해서 Kafka로 발행한다. 최대 1초의 지연이 생기지만, 집계나 로깅 같은 부가 로직에서 1초 지연은 충분히 수용 가능하다.

 

Outbox 기록의 타이밍이 중요하다

Outbox INSERT를 어느 시점에 할 것인가도 결정이 필요했다. AFTER_COMMIT 리스너에서 하면 비즈니스 트랜잭션이 이미 끝난 상태이므로 별도 트랜잭션이 된다. 이러면 비즈니스 데이터는 저장됐는데 Outbox INSERT가 실패하는 경우가 생길 수 있고, At Least Once 보장이 깨진다.

그래서 BEFORE_COMMIT 리스너에서 Outbox에 INSERT하도록 했다. 이러면 비즈니스 INSERT와 Outbox INSERT가 같은 트랜잭션에 묶이므로, 하나가 실패하면 둘 다 롤백된다.

트레이드오프는 있다. Outbox INSERT가 실패하면 비즈니스 트랜잭션 전체가 롤백된다. 즉, outbox 테이블에 문제가 생기면 주문이 실패할 수 있다. 하지만 "이벤트 발행 보장 없이 주문만 성공하는 것"보다는 "둘 다 실패하고 재시도하는 것"이 데이터 일관성 측면에서 더 안전하다고 판단했다.

 

 


5. Consumer의 멱등 처리 — 같은 메시지를 두 번 받으면?

문제: At Least Once는 중복을 허용한다

Producer 쪽에서 acks=all + idempotence=true + Outbox 패턴으로 "최소 한 번은 반드시 발행"을 보장했다. 하지만 "최소 한 번"이라는 건 "두 번 이상 올 수 있다"는 뜻이기도 하다.

중복이 발생하는 시나리오는 여러 가지다. Outbox Publisher가 Kafka 발행은 성공했지만 published=true 업데이트에 실패하면, 다음 주기에 같은 메시지를 다시 보낸다. Consumer 쪽에서도 메시지를 처리했지만 offset 커밋 전에 리밸런싱이 발생하면, 새로 할당된 Consumer가 같은 메시지를 다시 받는다.

해결: event_handled 테이블로 멱등 체크

Consumer가 메시지를 받으면 가장 먼저 event_handled 테이블에서 eventId로 조회한다. 이미 존재하면 처리를 건너뛰고 ack만 한다.

 
 
@KafkaListener(topics = {"catalog-events", "order-events"}, groupId = "collector-group")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
    EventPayload payload = objectMapper.readValue(record.value(), EventPayload.class);
    
    if (eventHandledRepository.existsById(payload.eventId())) {
        ack.acknowledge();
        return;
    }
    
    metricsService.updateMetrics(payload);
    eventHandledRepository.save(new EventHandled(payload.eventId(), LocalDateTime.now()));
    ack.acknowledge();
}
```

멱등 체크 방식으로 세 가지를 검토했다. Redis SET으로 빠르게 체크하는 방식은 속도는 좋지만 별도 인프라가 필요하고, Redis 장애 시 멱등 보장이 깨진다. DB Upsert(`INSERT ... ON DUPLICATE KEY UPDATE`)만으로 결과적 멱등을 보장하는 방식은 간단하지만, "이미 처리된 이벤트인지"를 명시적으로 알 수 없다.

채택한 방식은 **DB 기반 `event_handled` 테이블**이다. 멱등 체크와 비즈니스 처리를 같은 DB 트랜잭션으로 묶을 수 있어서 원자성이 보장되고, 별도 인프라 없이 DB만으로 구현할 수 있다. 어떤 이벤트가 처리됐는지 DB에서 직접 조회할 수 있어 운영 시 디버깅도 쉽다.

### event_handled와 이벤트 로그를 왜 분리하는가

과제에서 직접 고민하라고 한 포인트인데, 결론은 **목적과 생명주기가 다르기 때문**이다.

`event_handled`는 멱등 체크 전용 테이블이다. PK 하나로 존재 여부만 확인하면 되고, 일정 기간(예: 7일) 후에는 삭제해도 된다. 7일이 지난 이벤트가 다시 올 가능성은 사실상 없기 때문이다.

이벤트 로그 테이블은 감사(audit)와 디버깅 목적이다. payload 전체, 처리 결과, 에러 메시지 등 훨씬 많은 컬럼을 가지고, 90일 이상 장기 보관한다. 장애 시 이벤트를 리플레이하려면 이 테이블이 필요하다.

이 둘을 한 테이블에 합치면, 멱등 체크 쿼리가 불필요하게 무거운 테이블을 조회하게 되고, 보관 정책도 충돌한다. 멱등 체크용 데이터를 7일만 유지하고 싶은데 로그는 90일 보관해야 하면, 같은 테이블에서 둘 다 만족시키기 어렵다.

---

## 6. 선착순 쿠폰 발급 — 동시성 제어의 실전

### 구조: API는 요청만 받고, Consumer가 처리한다

선착순 100장 한정 쿠폰 발급에 1만 명이 동시에 요청하는 시나리오다. 동기적으로 처리하면 API 서버에 엄청난 부하가 걸리고, DB에 동시성 충돌이 쏟아진다.

Kafka를 활용한 구조는 이렇다. API는 발급 요청을 `coupon-issue-requests` 토픽에 발행하고 즉시 `PENDING` 응답을 돌려준다. Consumer가 순차적으로 메시지를 꺼내서 수량 확인 → 중복 체크 → 발급 처리를 수행한다. 유저는 별도 API(`GET /issue-result/{requestId}`)로 결과를 polling한다.
```
[유저] → POST /coupons/{couponId}/issue
  → commerce-api: coupon_issue_request INSERT (status=PENDING)
  → Outbox → Kafka coupon-issue-requests 토픽
  → 즉시 응답: { requestId, status: "PENDING" }

[Consumer] → 메시지 수신
  → 수량 확인 (issued_count < total_quantity?)
  → 중복 확인 (issued_coupon에 이미 있는지?)
  → 발급 처리 + status=ISSUED 갱신
  → ack

[유저] → GET /coupons/issue-result/{requestId}
  → { requestId, status: "ISSUED" }

6. 동시성 제어: 왜 이중으로 잡는가

동시성 제어 방식으로 네 가지를 검토했다.

DB 비관적 락(SELECT ... FOR UPDATE)은 명시적이고 확실하다. Redis INCR은 원자적 카운터로 빠르지만 Redis 의존이 추가된다. Kafka 단일 파티션 + 단일 Consumer는 순차 처리로 동시성 문제 자체를 회피하지만 처리량에 한계가 있다. DB 낙관적 락은 충돌 시 재시도 로직이 필요하다.

 

최종 결정은 Kafka 단일 파티션(concurrency=1) + SELECT ... FOR UPDATE의 이중 조합이다.

파티션 키를 couponId로 설정했으므로, 같은 쿠폰에 대한 요청은 같은 파티션에 들어간다. concurrency=1이면 한 스레드가 순차적으로 처리하므로 이론적으로는 race condition이 발생하지 않는다. 그런데 왜 SELECT ... FOR UPDATE까지 거는가?

Consumer Group 리밸런싱 때문이다. Consumer가 죽었다 살아나거나, 새 Consumer가 추가되면 파티션 재할당이 일어난다. 이 과정에서 아주 짧은 시간 동안 두 Consumer가 같은 파티션을 처리할 수 있다. 가능성은 낮지만, 쿠폰 발급 초과는 비즈니스적으로 치명적이므로 DB 레벨에서도 방어한다.

 
 
 
@Transactional
public void issueCoupon(Long couponId, Long userId, String requestId) {
    Coupon coupon = couponRepository.findByIdForUpdate(couponId);
    
    if (coupon.getIssuedCount() >= coupon.getTotalQuantity()) {
        updateRequestStatus(requestId, "FAILED", "수량 소진");
        return;
    }
    
    if (issuedCouponRepository.existsByCouponIdAndUserId(couponId, userId)) {
        updateRequestStatus(requestId, "FAILED", "중복 발급");
        return;
    }
    
    coupon.incrementIssuedCount();
    issuedCouponRepository.save(new IssuedCoupon(couponId, userId));
    updateRequestStatus(requestId, "ISSUED", null);
}

 

중복 발급 방지: 로직 체크 + DB 제약의 이중 방어

중복 발급 방지도 같은 이중 방어 원칙을 적용했다. Consumer 로직에서 issued_coupon 테이블을 SELECT해서 이미 발급됐는지 확인하고, 테이블에도 UNIQUE KEY (coupon_id, user_id) 제약을 건다.

로직 체크만으로도 대부분의 중복은 막을 수 있지만, 리밸런싱 같은 극단적 상황에서 SELECT 시점에는 없었는데 INSERT 시점에 이미 다른 처리가 끝난 경우가 있을 수 있다. DB Unique Key가 이 마지막 방어선 역할을 한다.

파티션 키를 couponId로 잡은 이유

파티션 키 후보로 couponId, userId, Round Robin 세 가지를 검토했다.

userId를 키로 하면 같은 유저의 중복 요청을 파티션 내에서 자연스럽게 걸러낼 수 있다. 하지만 서로 다른 유저의 같은 쿠폰 요청이 여러 파티션에 분산되므로, issued_count 수량 제한을 위한 동시성 제어가 복잡해진다.

couponId를 키로 하면 같은 쿠폰에 대한 요청이 한 파티션에 몰린다. 단일 Consumer가 순차 처리하면 issued_count 갱신에 race condition이 발생하지 않는다. 단점은 인기 쿠폰의 요청이 한 파티션에 집중되는 핫 파티션 문제인데, 선착순 쿠폰은 수량이 제한적이라 처리 총량 자체가 크지 않으므로 수용 가능하다고 판단했다.

비동기 결과 전달: 왜 Polling인가

발급 결과를 유저에게 전달하는 방식으로 Polling, SSE, WebSocket, Webhook을 검토했다.

SSE나 WebSocket은 실시간성은 좋지만 서버가 커넥션을 유지해야 하고, 1만 명이 동시에 대기하면 커넥션 관리 비용이 크다. Polling을 선택한 이유는 구현이 가장 단순하기 때문이다. REST API 하나만 추가하면 되고, 서버 측 상태 관리가 불필요하다. 쿠폰 발급 결과를 밀리초 단위로 즉시 알아야 하는 것은 아니므로, 1초 간격의 polling이면 UX 관점에서 충분하다.

발급 실패 처리: 비즈니스 실패는 ack한다

실패 처리에서 중요한 구분은 비즈니스 실패와 인프라 실패의 차이다. "수량 소진"이나 "중복 발급"은 재시도해도 결과가 같은 비즈니스 실패다. 이런 경우 coupon_issue_request.status를 FAILED로 갱신하고 failure_reason을 기록한 뒤 ack 처리한다. 실패 메시지가 Consumer를 블로킹하지 않아서 후속 요청 처리가 지연되지 않는다.

반면 DB 커넥션 타임아웃 같은 인프라 실패는 재시도하면 복구될 수 있다. 이런 경우까지 FAILED로 처리하면 복구 기회를 잃는다. 이상적으로는 비즈니스 실패와 인프라 실패를 구분해서, 인프라 실패만 재시도하거나 DLQ로 보내는 구조가 필요하다. 이번 과제에서는 기본 구현에 집중하고, DLQ 구성은 Nice-to-Have로 남겨두었다.

 


7. 전체를 관통하는 설계 원칙

동기 → ApplicationEvent → Kafka, 점진적 확장

모든 이벤트를 처음부터 Kafka로 보낸 게 아니다. 먼저 ApplicationEvent로 프로세스 내에서 주요/부가 로직의 경계를 나누고, 그 중 시스템 간 전파가 필요한 것만 Kafka로 확장했다.

이 점진적 접근의 장점은 세 가지다. 첫째, 관심사가 명확히 분리된다. "이 이벤트가 프로세스 내에서만 필요한가, 외부 시스템도 알아야 하는가?" 둘째, Kafka 장애 시에도 프로세스 내 부가 로직(알림 등)은 정상 동작한다. 셋째, 처음에 ApplicationEvent로 시작하고, 필요할 때 Kafka로 확장할 수 있어 점진적 전환이 가능하다.

단점도 있다. 같은 OrderCompletedEvent에 대해 AFTER_COMMIT 리스너(알림 발송)와 BEFORE_COMMIT 리스너(Outbox INSERT)가 공존하므로, 이벤트 하나가 발행된 후 어떤 일이 일어나는지 추적하려면 여러 리스너를 확인해야 한다.

이중 방어 원칙

이 과제 전반에서 반복되는 패턴이 있다. Producer의 idempotence=true + Consumer의 event_handled 멱등 체크. Kafka 단일 파티션 순차 처리 + DB SELECT ... FOR UPDATE. Consumer 로직 체크 + DB Unique Key.

한 레이어에서만 방어하면 그 레이어가 뚫렸을 때 데이터가 바로 오염된다. 두 레이어에서 방어하면 한쪽이 뚫려도 다른 쪽이 잡아준다. 비용은 쿼리 수가 늘어나고 코드가 복잡해지는 것이지만, 쿠폰 초과 발급이나 집계 오류 같은 비즈니스 리스크를 생각하면 충분히 합리적인 트레이드오프다.

 

 


이 과제를 통해 가장 크게 배운 것은 "이벤트로 분리할 것인가 말 것인가"라는 판단 자체가 설계의 핵심이라는 점이다. 기술을 도입하는 것보다, 어디에 경계를 그을지를 결정하는 것이 더 어렵고 더 중요하다.

 

또 하나는, 분산 시스템에서 "정확히 한 번 처리(Exactly Once)"는 사실상 불가능하고, 현실적인 목표는 "최소 한 번 발행(At Least Once) + 소비 쪽 멱등 처리"라는 것이다. Producer는 Outbox 패턴으로 최소 한 번 발행을 보장하고, Consumer는 event_handled로 중복을 걸러낸다. 이 조합이 실무에서 가장 현실적인 Exactly Once Semantics의 구현이다.

 

마지막으로, 모든 결정에는 트레이드오프가 있다는 것을 다시 체감했다. eventual consistency를 선택하면 일시적 불일치를 수용해야 하고, Polling을 선택하면 지연을 수용해야 하고, 이중 방어를 선택하면 복잡도를 수용해야 한다. 중요한 건 어떤 비용을 지불할지를 의식적으로 선택하는 것이다.