EDA 패턴을 적용해보자.(feat.kafka)
- 개발/동시성 테스트
- 2026. 3. 23. 21:22
이전에도 동시성 테스트를 진행한 적이 있었다.
당신의 동시성 테스트가 원하는 결과가 나오지 않는 이유
TL;DR: 낙관락과 비관락을 고르는 기준에 대해 설명합니다.배경그 전에도 낙관락, 비관락을 해봤기때문에 금방할 줄 알았다.하지만 아니었다. 어디서 부터 문제 였을까?생각하기에는 비관락을 사
b-programmer.tistory.com
그때는 낙관락과 비관락에 대해서만 집중해서 테스트를 진행했던 거 같다.
이번에는 차근차근 진행해 볼 예정이다. 낙관락도 비관락도 무엇인지 정확하게 알았으니 다른 테스트도 해도 크게 문제 될 건 없을 거 같다.
일단 쿠폰을 한 장 준비해 둡니다. 이 쿠폰은 100장 제한이 걸려있으며, 불특정 한 사람들이 이 쿠폰 이용이 가능합니다.
먼저 k6 스크립트를 준비해 주고 테스트를 진행해 봅니다.
락을 걸기 전에
현재 선착순 쿠폰을 구하는 api는 다음과 같습니다.
{
"userId": 1
}
userId를 바꿔가면서 데이터를 넣어야 한다는 뜻입니다. 저는 겹치지 않게 userId를 100개 준비하였습니다.

초기 상태는 쿠폰이 100개가 남아있는 상태입니다. 이제 100명을 대상으로 테스트를 진행해 봅시다.

정상적으로 테스트가 완료가 되었습니다. 하지만 알 수 없는 오류가 발생하여 장상적인 결과가 나오지 않았습니다.
WARN[0000] [issue] failed status=500, body={"success":false,"data":null,"message":"일시적인 오류가 발생했습니다."} source=console

6장뿐만 성공했다고 나오고 나머지는 실패를 하였습니다. 생각보다 성공률이 높지는 않네요.
이제 stock을 확인해 봅시다.

이상합니다. 역시 6장이 성공했으면 94가 되어야 하는 게 아닐까요? 2장은 어디로 간 걸까요?
그 이유는 쿠폰 2장에 대해 데드락이 발생이 하였다고 합니다. 이렇게 남은 수량은 신뢰도를 떨어뜨릴 수밖에 없습니다.
그렇다면 어떻게 해야 100장을 요청하면 100장이 모두 소진되게 할 수 있을까요?
비관 락 사용
초기상태로 되돌린 다음 테스트를 다시 진행하겠습니다. 똑같이 쿠폰아이디 1번으로 진행하였습니다.

이번에는 모든 쿠폰이 발급이 되었다는 것을 확인할 수 있었습니다.
DB락에는 2가지 방법이 존재합니다.
낙관락과 비관락입니다. 저는 비관락을 선택하여 사용했습니다. 그 이유는 모든 데이터를 전부 성공을 시켜야 하기 때문입니다.
그렇다는 뜻은, 하나의 작업이 실행 중이라면, 나머지 작업들은 실행을 시키면 안 되기 때문입니다. 만약, 낙관락을 여기에서 사용한다면,
하나만 성공하고 나머지는 실패하는 전략이기 때문에 여기에는 비관락을 선택을 하겠습니다.

쿠폰이 100장을 100명에게 성공한 것을 확인하였습니다.
비관락은 제대로 걸린 거 같습니다. 이제 몇 가지 테스트를 더 진행해보려고 합니다.
추가 테스트
그렇다면 다음과 같은 상황에서도 똑같은 결과를 리턴할 수 있을까요?
1. 쿠폰이 100장이 아닌 10000장인 상황
2. 선착순 쿠폰 10장을 5000명이 발급을 시도하는 상황
바로 진행해 봅시다.
쿠폰이 100장이 아닌 10000장인 상황
- 목적: 락 걸린 상태에서 처리량/지연(p95/p99) 확인
- 기대: 성공률 높음, 대신 동시성 높이면 지연 증가 가능
이 상황은 성능 테스트를 뜻합니다.

이번에는 만장을 준비해 줍니다. 그리고 테스트를 다시 한번 더 돌려봅시다.

예상과 다르게 타임아웃이 발생하였다는 것을 확인할 수 있었습니다. 초반 테스트처럼 쿠폰 100장이 일부만 성공했던 그림과는 전혀 다른 그림이었습니다.

만장모두 종료되었다는 것을 알 수 있습니다.
그렇다면, 결과는 어떨까요?

확인해 보니 http_req_duration은 다음과 같다고 합니다.
- avg: 6.91초
- p95: 15.49초
- p99: null
- max: 16.29초
이 결과의 의미
이번 테스트에서는 총 10,000건의 쿠폰 발급 요청 중 7,629건이 성공하고 2,371건이 실패했습니다.
그럼에도 불구하고 초과 발급은 발생하지 않았고, 초기 재고 10,000장에서 성공 건수 7,629건만큼만 차감되어 최종 재고가 2,371장으로 정확히 일치했습니다.
즉, 성공 건수와 재고 감소량이 정확히 일치했고 oversold가 발생하지 않았으며 409(중복 발급), 400(잘못된 요청/발급 불가) 도 없었습니다. 이 점을 보면 쿠폰 발급 로직 자체는 동시성 상황에서도 재고 정합성을 안정적으로 유지했다고 볼 수 있습니다.
반면, 실패한 2,371건은 모두 failureOther로 집계되었습니다.
이는 비즈니스 규칙 위반이 아니라, 500 응답, 타임아웃, 커넥션 문제, 서버 처리 한계 초과 같은 시스템 과부하성 실패일 가능성이 높다는 뜻입니다.
따라서 이 결과는 다음을 의미합니다.
- 쿠폰 발급 로직은 동시성 상황에서도 재고 정합성을 잘 지킨다.
- 하지만 10,000 VU 수준의 순간 스파이크 부하는 현재 시스템이 안정적으로 감당하지 못한다.
- 결과적으로 일부 요청은 성공했지만, 상당수는 애플리케이션/인프라 병목으로 인해 실패했다.
한 줄 결론
정합성은 통과했지만, 10,000 VU 스파이크 상황에서 성능과 안정성은 실패했습니다.
성능과 안전성을 올리면서 어떻게 해결할 수 있을까요?
선착순 쿠폰 10장을 5000명이 발급을 시도하는 상황
- 목적: 정합성(초과 발급 0) 검증
- 기대: 성공은 정확히 10건 근처, 나머지는 소진/중복 실패
- 핵심 체크: issuedQuantity=10, 발급 row도 10, 초과발급 없음
이 상황은 정합성/경합 테스트를 뜻합니다.

10장이 만들어졌습니다.
예상외의 결과가 나왔습니다. 의외로 나쁘지 않게 나와 당황스러운대요.

다만 조금 이상한 부분이 존재합니다. 바로 failOther부분인데요.
이 부분은 409 말고 다른 실패라서, 약간의 시스템성 실패(500/timeout 등)가 섞여 있을 가능성이 있습니다.
즉 failureOther가 잡혔다는 건, 비즈니스적으로 의도된 탈락(재고 소진/중복 등)이 아니라 정상적이지 않은 추가 실패가 섞였다는 뜻으로 보는 게 맞습니다.
한 줄 결론
이 결과가 의미하는 것은 다음과 같습니다.
- 쿠폰 발급 로직은 극심한 경쟁 상황에서도 초과 발급 없이 정확히 동작했다.
- 대부분의 실패는 의도된 경쟁 탈락(409)으로 처리되었다.
- 하지만 일부 요청은 시스템 과부하성 실패(failureOther)로 떨어져 완전한 안정성 확보에는 미치지 못했다.
이 두 가지 테스트를 통해 확인할 수 있었던 고무적인 점은, 쿠폰 재고가 음수로 내려가는 초과 발급 현상이 발생하지 않았다는 것입니다. 이는 동시성 제어가 의도대로 동작했다는 의미이며, 비관적 락 적용이 정합성 보장에 유효했을 가능성을 보여줍니다. 반면, 초과 발급은 방지했지만 여전히 일부 요청에서 실패가 발생했고 응답 시간도 크게 증가한 것으로 보아, 정합성 문제는 해결되었더라도 성능 및 안정성 이슈는 여전히 남아있다고 판단할 수 있습니다.
어떻게 하면 해결할 수 있을까요?
추가 테스트 해결 방안
쿠폰이 100장이 아닌 10000장인 상황
초기 구현에서는 쿠폰 재고를 DB에서 직접 관리했습니다. 발급 요청이 들어오면 트랜잭션 안에서 재고를 확인하고 차감하는 구조였기 때문에, 정합성 측면에서는 비교적 안전했습니다. 실제로 초과 발급도 발생하지 않았습니다. 하지만 대량 트래픽 환경에서는 다른 문제가 드러났습니다. 특정 쿠폰에 요청이 집중되자 동일한 재고 데이터에 대한 동시 접근이 늘어났고, 결국 DB 락 경합이 병목으로 이어졌습니다.
즉, 정합성은 확보했지만 성능 비용이 커지는 구조였던 것입니다.
발급 재고 처리를 DB 락 중심에서 Redis 중심으로 옮기기
가장 먼저, 현재 DB 락 중심의 재고 처리 방식을 Redis 중심으로 변경해보려고 합니다.
왜 Redis가 더 적합하다고 판단했는가
현재 시나리오의 핵심 문제는 정합성이 아니라 처리량과 응답 성능이었습니다.
실제로 현재 구조는 비관락을 통해 초과 발급을 방지하고 있었고, 재고 정합성도 정상적으로 유지되고 있었습니다.
즉, 재고가 틀어지는 문제는 이미 어느 정도 해결된 상태였습니다.
하지만 문제는 그다음이었습니다. 쿠폰 발급 요청이 하나의 쿠폰에 집중되자, 모든 요청이 동일한 coupon row에 접근하게 되었고, 비관락 기반 구조에서는 이 요청들이 사실상 순차적으로 처리될 수밖에 없었습니다. 그 결과 DB는 재고 저장소인 동시에 동시성 제어 지점이 되었고, 결국 락 경합이 성능 병목으로 이어졌습니다.
이 지점에서 Redis가 더 적합하다고 판단했습니다.
Redis는 메모리 기반 저장소이기 때문에 DB보다 훨씬 빠르게 접근할 수 있고, DECR 같은 원자적 연산을 통해 재고 차감도 매우 가볍게 처리할 수 있습니다. 무엇보다 중요한 점은, DB처럼 특정 row에 대한 락 대기를 길게 유발하지 않으면서도 동시성 제어를 수행할 수 있다는 점입니다. 즉, 현재 문제는 "정합성을 어떻게 맞출 것인가"가 아니라 "정합성은 유지한 채, 더 많은 요청을 더 빠르게 처리할 수 있는가"였고, 그 관점에서 Redis는 DB보다 훨씬 적합한 선택지라고 판단했습니다.

즉, 지금 구조는 DB가 재고 관리와 동시성 제어를 모두 담당하는 형태입니다.
이 방식은 초과 발급을 막는 데는 효과적이었지만, 요청이 많이 몰리는 상황에서는 특정 쿠폰 row에 대한 락 경합이 발생하면서 성능 병목으로 이어질 수 있습니다.

그래서 이 부분을 Redis 중심 구조로 변경할 예정입니다.
앞으로는 DB에서 직접 락을 잡아 재고를 차감하는 대신, Redis에서 재고를 원자적으로 감소시키고, DB는 최종 이력 저장 역할에 더 집중하도록 개선하려고 합니다.
이제부터 DB에서 재고를 차감하는 것이 아닌 Redis에서 재고를 차감하는 방식으로 변경하였습니다.

이제 실행해 봅시다. 똑같은 상황에서 테스트를 진행하겠습니다.

테스트를 해보니 정합성은 지켜졌다는 것을 알 수 있었었습니다. 7961 + 2039 = 10000
이전 결과를 가져와서 비교해 봅시다.
k6 스크립트 문제가 있었네요. finalStock이 몇 개인지 알려주지는 않네요.
하지만 이상하게도 생각보다 효과적이지 않았습니다. 이전 결과를 불러와서 비교해 봅시다.

생각보다 성능이 올라가지 않았다는 것이 발견되었습니다. 이유가 무엇일까요?
나아진 거라고는 duration이 조금 더 빨라졌네요.
한 번에 10,000 연결로 때리지 말고, 10,000명 시뮬레이션과 동시 연결 수를 분리해야 합니다.
그러면 VUS 수를 점진적으로 올리면서 어느 지점에서 connection refused이 발생하는지 확인해 보겠습니다.
(200 → 500 → 1000 → 2000)
이것을 토대로 그래프를 그려보겠습니다.

예상과는 다른 결과가 나와 당황스럽네요. 이러면 DB 쪽도 확인을 해봐야 하는 생각이 문득 드는데요.
사실 잘 모르겠습니다. 성능이 얼마나 올라갈지는 모르겠네요. ㅜㅜ

DB-only는 해당 구간에서 측정된 p95 수치가 더 낮았지만, 동시에 실패율이 매우 높아 성능 우위로 해석하기 어렵습니다. 높은 실패율로 인해 요청이 조기 종료되면서 응답시간 지표가 낮게 관측되었을 가능성이 있습니다.
하지만 이거는 제가 원하는 결과가 아닙니다. 어떻게 하면 조금 더 실패율을 줄일 수 있을까요?
EDA에도 패턴들이 이렇게나 많이 존재 하다니..
EDA(Event Driven Architecture)가 MQ나 이벤트 리스너를 통해 동작한다는 것은 알고 있었습니다. 하지만 "어느 상황에서 사용해야 하는가?"에 대해서는 명확하게 정리되어 있지 않았습니다. 단순히 도메
b-programmer.tistory.com
전에 EDA패턴에 대해 학습을 한 적이 있습니다. 물론 패턴에 대해 자세하게 학습은 안 했지만 말이죠.ㅎㅎ
아무튼, 저희가 생각해야 하는 부분은 10000UV를 버틸 수 있게 만들 수 있을까입니다.
여기서 저희가 생각할 수 있는 부분은 두 가지입니다. 처리량과 정합성을 지켜야 합니다.
처리량을 지켜야 하는 이유는 몇 명이 동시에 메시지(API) 발송을 할지 모르기 때문이고
정합성을 지켜야 하는 이유는 쿠폰을 신청을 했다면, 반드시 받아야 하기 때문입니다.
(kafka를 사용하지 않고 스프링 이벤트로 진행해 보겠습니다. kafka는 추후에 연결시켜 보고 비교해 보겠습니다.)
처리량을 먼저 해결해 봅시다.
처리량 문제를 해결하기 위해서는 작업을 어떻게 분산하고, 어떻게 동시에 처리할 것인지가 중요합니다.
이를 위한 대표적인 패턴으로 Competing Consumer Pattern과 Asynchronous Task Execution Pattern이 있습니다.
Competing Consumer Pattern은 여러 Consumer가 하나의 작업 흐름을 나누어 병렬로 처리함으로써 처리량을 높이는 방식입니다.
반면 Asynchronous Task Execution Pattern은 작업을 비동기로 위임하여, 요청 처리와 실제 작업 수행을 분리하는 방식입니다.
이 두 가지 중 하나를 선택해서 사용하는 것이 아닌 둘 다 사용해도 무방합니다.
위 실험결과에 따르면 200 UVS의 동시사용자에서는 예외가 발생하지 않았음을 확인하였습니다.
그렇다면 메시지를 수신하는 Consumer를 50개 정도 만들면 될 겁니다. 하지만 Consumer의 개수를 늘린다고 해서 '메시지 처리 성능'이 좋아질 거 같지는 않습니다.
만약, 200에서 500 아니 1000까지 증가시킬 수 있다면 병렬적으로 사용되는 Consumer의 개수는 현저히 줄게 될 것입니다.
500은 20개의 Consumer가 1000은 10개의 Conumser를 사용할 수 있게 됩니다.
즉, 우리가 가장 먼저 해야 할 일은 메시지를 얼마나 많이 보낼 수 있는지 알아야 합니다.
메시지를 많이 보내면 보낼수록 사용해야 할 Consumer의 개수는 현저히 줄 거라고 여겨집니다.
Asynchronous Task Execution Pattern
위와 같은 이유로 최대 전달할 수 있는 메시지를 올리는 방법을 먼저 선택하겠습니다.

1) 스프링 이벤트로 보내기
이벤트는 두 가지 속성이 존재합니다. producer와 consumer가 존재합니다.
publiser를 통해 이벤트를 발송하고, consumer를 통해 이벤트를 수신하는 역할을 합니다.
자세한 내용은 아래 블로그 글을 참고해 주시기 바랍니다.
어째서 메시지 브로커가 왜 필요한가?
카프카나 레빗 MQ같은 MQ시스템들을 학습하다보면 브로커라는 개념이 등장합니다. 인터넷에 브로커를 검색해보니 판매자와 구매자 사이에서 정보를 교환하고 중개하여 수수료를 받는 개인 또는
b-programmer.tistory.com
스프링 이벤트에서는 producer를 publisher로 consumer는 listener로 표기합니다. 이렇게 표기하는 이유는 Pub/Sub 개념을 따르기 때문이라고 합니다.
먼저 publisher를 준비해 둡니다.

스프링 이벤트는 이벤트 객체 타입(dto)에 따라 어떤 listener로 실행이 될지 결정됩니다.

현재 비동기 이벤트로 확인하려는 목적이기 때문에 @Async를 붙여줍니다.
하지만 @Async를 붙인다고 해서 비동기로 동작하는 것은 아닙니다.

@EnableAsync로 비동기를 활성화시켜야 비동기를 사용할 수 있습니다.
간단하게 500UV에서 실패율을 확인했기 때문에 요걸로 돌려봅시다.

크읍.. 비동기로 돌렸음에도 실패율이 존재하는군요..ㅜㅜ
하지만 처리 속도가 월등히 높아졌다는 것을 알 수가 있었습니다.
그렇다면 200 UVS -> 10000 UVS까지 결과는 어떨까요?

redis만 사용하는 방식과 비동기 이벤트 방식을 비교해 보았습니다.
생각보다 꽤 흥미로운 결과가 나왔습니다.
특히 1,000 VUS에서 2,000 VUS로 증가하는 구간에서는 오히려 실패율이 상승하는 모습을 확인할 수 있었습니다.
또한 10,000 VUS 구간에서는 비동기 이벤트 방식의 실패율이 동기 방식보다 더 높게 나타났습니다.
그렇다면 스프링 이벤트를 kafka로 변경하면 어떻게 될까요?
2) kafka로 보내기
kafka를 선택한 이유: Kafka를 선택한 이유는 단순히 요청을 비동기로 분리하기 위해서가 아닙니다.
Kafka는 메시지를 로그 형태로 저장하고, 이를 소비자에게 제공하는 구조를 가지고 있습니다.
이러한 특성 덕분에 장애가 발생하더라도 메시지를 다시 읽어 재처리하거나 재시도할 수 있습니다.
결국 Kafka를 도입한 이유는 비동기 처리 자체보다도,
메시지의 유실 가능성을 줄이고 안정적으로 다시 처리할 수 있는 구조를 만들기 위해서입니다.
자세한 내용은 아래 블로그 글을 참고해 주시기 바랍니다.
카프카를 왜 사용해야 할까?
카프카를 계속 공부해 왔지만, 솔직히 말하면 "왜 이걸 써야 하는지" 스스로 완전히 납득했다고 말하기는 어렵습니다.문서와 강의에서는 늘 대규모 트래픽, 이벤트 스트리밍, 비동기 아키텍처
b-programmer.tistory.com
이 특성은 추후 다시 다뤄질 예정입니다.
kafaka 또한 publisher와 listener가 필요합니다. kafka에서는 producer와 consumer라고 합니다.
producer가 메시지를 전달하고, consumer가 메시지를 수신하게 됩니다.
스프링 이벤트와 다른 차이점(mq에서)은 바로 broker가 존재한다는 점입니다. 즉, producer가 consumer로 바로 메시지를 전달하지 않고 broker를 통해, 메시지를 발송하고 수신하게 됩니다. 이것을 간단하게 그림을 그려보면 이렇게 그릴 수 있습니다.

broker를 사용하는 가장 큰 이점은 메시지를 발송하는 쪽과 메시지를 처리하는 쪽이 분리된다는 점입니다.
Producer는 메시지를 보내는 역할만 담당하고, Consumer는 메시지를 받아 처리하는 역할을 담당합니다.
이처럼 메시지 발행과 처리 과정이 분리되면 시스템 간 결합도가 낮아지고, 처리 구조를 유연하게 확장할 수 있습니다.
해당 내용은 다음 글에서 더 자세하게 설명하겠습니다. 자세한 내용은 다음 글을 참고해 주세요.
어째서 메시지 브로커가 왜 필요한가?
카프카나 레빗 MQ같은 MQ시스템들을 학습하다보면 브로커라는 개념이 등장합니다. 인터넷에 브로커를 검색해보니 판매자와 구매자 사이에서 정보를 교환하고 중개하여 수수료를 받는 개인 또는
b-programmer.tistory.com
broker는 직접적으로 구현하지는 않습니다. 즉, producer와 consumer만 구현하면 됩니다.
producer를 만들어줍니다.

기존 스프링 이벤트가 이벤트 DTO로 통신을 했다면, kafka는 토픽으로 통신을 하게 됩니다. 즉, coupon-issue-requests라는 토픽으로 메시지를 전송하게 됩니다.
producer를 만들었으니 이것을 수신하는 consumer도 만들어 봅시다.

이 코드는 producer로 발행된 토픽을 받아오는 역할을 합니다. 이렇게 되면 카프카를 통해 다음과 같이 전달이 됩니다.

가장 기본적인 형태로 진행하였습니다.
이번에도 역시 500UV에서 한번 돌려보겠습니다.

스프링이벤트보다 실패율이나 속도가 많이 증가가 된 걸로 확인이 됩니다. 물론, 이거 하나로 판단하기에는 어려움이 있다고 여겨집니다.
그렇다면 200UV부터 10000UV까지 돌린 결과는 어떨까요?

생각보다 성능이 월등하게 좋아졌다고 말하기는 애매한 결과입니다. 전체적으로 보면 kafka 방식의 실패율이 더 낮은 경향을 보이긴 했지만, 5000 VUS에서는 오히려 kafka 방식의 실패율이 더 높게 나타났습니다.
이러한 현상이 발생한 이유는 kafka 이전 단계에서 오류가 발생했을 가능성이 높기 때문입니다.
즉, 요청이 kafka까지 전달되기 전에 이미 실패했을 수 있습니다.
예를 들면 다음과 같은 경우입니다.
- HTTP 요청 처리 과정에서 발생한 예외
- 애플리케이션 내부 처리 오류
- 로컬 네트워크 지연이나 연결 문제
- 서버의 일시적인 리소스 부족
이러한 오류들은 kafka로 메시지가 전송되기 이전 단계에서 발생하기 때문에, kafka 자체의 처리 방식과는 직접적인 관련이 없을 수 있습니다. 따라서 일부 구간에서 kafka 방식의 실패율이 더 높게 나타난 것은 kafka의 문제라기보다는 요청이 kafka에 도달하기 이전 단계에서 발생한 오류일 가능성이 높다고 볼 수 있습니다. 이 말을 다시 말하면, 환경에 따라 결과는 다르게 나올 수 있다는 뜻이 됩니다.
이것을 kafka를 사용해서 해결할 수는 없습니다. 대신 이러한 실패에 대해서는, 실패 내역을 남기고 이를 바탕으로 재발행하거나 재처리하는 방식으로 실패율을 낮출 수 있습니다. 이러한 문제를 해결하기 위한 대표적인 방법 중 하나가 바로 Outbox Pattern입니다.
이 방식은 추후 다뤄보겠습니다.
Competing Consumer Pattern
다시 말하지만, 이 패턴을 사용한다고 해서 실패율을 0으로 만들 수 있는 것은 아닙니다.
다만 여러 Consumer가 동시에 메시지를 처리할 수 있기 때문에 전체 처리 속도와 처리량을 향상하는 효과는 기대할 수 있습니다.
그럼에도 불구하고 Outbox Pattern보다 먼저 다루는 이유는, 이 패턴이 Asynchronous Task Execution Pattern과 마찬가지로 처리량(throughput)을 개선하기 위한 접근 방식에 속하기 때문입니다.
즉, Competing Consumer Pattern은 메시지의 신뢰성이나 재처리를 해결하기 위한 패턴이라기보다는,
여러 Consumer가 병렬로 작업을 처리하여 시스템의 처리량을 높이기 위한 패턴이라고 볼 수 있습니다.
병렬로 작업하기 위해서는 어떻게 해야 할까요?
이것을 이해하기 위해서는 kafka-key를 이해를 해야 합니다.
기존에는 couponId로 키를 나눴습니다.

이 말을 다시 말하면, 키는 최대한 겹치지 않게 만드는 것이 굉장히 중요하다고 생각합니다. 그래야 많은 파티션을 사용할 수 있기 때문이죠.
그렇다면, 현재 방식에서 최대한 키를 겹치지 않게 하는 방법은 userId 또는 requestId입니다.
그렇게 되면 다음과 같이 그릴 수 있습니다.

이제 파티션 0번은 101번만 파티션 1번은 102번만, 파티션 2는 103번만 작업할 수 있게 되었습니다. 이렇게 되면 병렬적으로 작업할 수 있어 이전 테스트 asychronous를 할 때보다 작업효율이 높아질 거라고 예상합니다.
그럼에도 불구하고, 속도가 빠르냐는 다른 문제라고 여겨집니다. 왜냐하면 작업 효율이 좋다고 해서 무턱대고 속도가 빠르다고 말하는 건 결론부터 말하는 꼴이기 때문에 추후 테스트를 통해 설명드려보겠습니다.

producer를 couponId에서 userId로 변경하였습니다. producer 쪽은 많은 파티션을 사용하기 위해 key만 변경하였습니다.
그렇다면, consumer는 코드가 어떻게 변경되었을까요?

consumer 쪽을 확인해 보니 용어가 두 가지가 등장하였습니다. groupId와 concurrency입니다.
이들은 무엇이며 concurrency에 적힌 숫자는 의미가 무엇일까요?
groupId
groupId는 현 프로젝트에서는 엄청나게 의미가 없을 수도 있습니다. 왜냐하면 컨슈머 여러 개를 하나의 팀으로 만드는 건데
현 프로젝트에서는 group이라고 해봤자 요거 하나뿐이기 때문이죠.
하지만, 앱의 개수가 많아지거나 프로듀서가 많아진다면 얘기는 달라질 겁니다.

concurrency
concurrency는 동시에 병렬로 동작할 수 있는 consumer의 개수를 나눠서 처리를 할 수 있습니다.
그렇다면, concurrency는 많으면 많을수록 좋을까요? 만약, 입구의 크기가 최대 500 UVS라면 10번을 진행하게 되면 5000이고 20번을 하게 되면 10000번이 훌쩍 넘어가게 됩니다.
만약, concurrency를 무턱 되고 올리면 다음과 같은 상황이 발생하게 됩니다.
1. 파티션 개수를 적게 생성하게 된다면, concurrency를 높게 선정하는 것이 의미가 없어집니다. 즉, 파티션이 3이고 concurrency이 10개라면, 동시에 작업하는 consumer는 총 3개입니다.
2. 만약, 파티션 개수는 상관없다고 한다고 해도 concurrency를 높게 설정하는 것은 다음과 같은 문제를 초래합니다.
동시에 많은 consumer가 동작한다는 것은 스레드를 많이 사용한다는 뜻이니, 콘텍스트 스위칭이 많이 발생하게 됩니다.
즉, 콘텍스트 스위칭 비용이 증가된다는 뜻이 되는 거죠.
이 밖에도 DB 커넥션 경쟁, Redis / 외부 API 부하 증가, 락 경합 증가, 중복 처리 / race condition 가능성 증가, 로그/모니터링 복잡도 증가 등등.. 그러니까 처리량보다 충돌이 먼저 발생이 될 수 있다는 뜻이 됩니다. 아이러니하게도 성능이 떨어지는 꼴이 됩니다.

즉, concurrency는 시스템이 버틸 수 있는 최대 개수까지 적당히 높이는 것이 핵심이라고 여겨집니다.
또, 현재 테스트하고 있는 환경이 문제가 로컬이라는 점입니다. 결국, 로컬환경에서의 concurrency의 최대 개수까지 설정하여도 운영 환경에서 도는 또 틀어질 수 있다는 뜻입니다.
그럼에도 불구하고, 로컬에서 얼마나 버틸 수 있는지 왜 concurrency를 높게 설정하면 안 되는지 테스트를 진행해 보겠습니다. 이렇게 만들면 competing Consumer patten의 형태로 만들기는 하였습니다. 하지만 여기서 끝난 것은 아닙니다.
다음과 같은 작업이 동반이 되어야 비로소 패턴이 완성이 되었다고 할 수 있죠.
1. 중복 처리와 멱등성 처리
같은 요청이 들어와도 같은 유저에게는 쿠폰이 2번 이상 발급이 되지 않게 해야 합니다.
현재는 다음과 같은 코드가 존재합니다.

코드상으로도 동일 인물이 같은 쿠폰을 받지 못하도록 처리가 되어 있습니다.
하지만 이것만으로는 부족합니다. DB에서 최종적으로 중복 저장을 막아야 합니다.

왜냐하면, 동시에 두 요청이 들어와서 race condition이 발생한다면, 최종적으로 DB가 막아 줄 수 있기 때문입니다.
하지만 지금 방식은 최종 결과의 종복방지지 이벤트 자체의 멱등성과는 거리가 있어 보입니다.
그렇다면, 어떻게 처리할 수 있을까요?
현재 이벤트에는 couponId, userId, requesedAt뿐만 존재합니다. 여기에는 이벤트의 고유한 값이 존재하지 않습니다.
그렇다는 이야기는 이벤트에 대한 고유한 값을 적용시키지 못한다는 뜻이 됩니다.
그렇다면 어떻게 해야 할까요? eventId라는 고유한 값을 추가한 뒤, 이미 처리가 되어있다면, 무시를 하고
처음 본 이벤트 하면 proccessed_event 같은 저장소에 기록하면 됩니다.
이렇게 되면 동일한 이벤트를 두 번 이상 작업되지 않게 방지할 수 있습니다.
producer 쪽에서 보낸 메시지를 수신한 뒤, consumer 쪽에서 수신을 하게 됩니다.

이전과 다른 점은 eventId를 저장하고 있고 있습니다.
coponId와 userId가 코드 상에서 멱등성을 측정한 것처럼 역시

애플리케이션에서 동일한 이벤트가 다시 발행되더라도, 재고가 중복 차감되거나 쿠폰이 중복 발급되지 않도록 하기 위해서입니다. 하지만 이상한 점이 있습니다. 어째서 예외를 던지지 않고 return을 하게 되는 걸까요? 그 이유는 첫째, 이벤트의 중복 발행을 예외 상황으로 보지 않기 위함입니다. 둘째, 이미 처리된 이벤트에 대해 불필요한 재처리 없이 그대로 종료하기 위함입니다.
처리 도중 다른 스레드가 먼저 성공하는 경우도 고려해야 합니다.
앞선 조회는 이미 처리 완료된 이벤트를 빠르게 걸러내기 위한 사전 확인입니다.
다만 동시 요청 상황에서는 둘 다 조회 시점에는 미처리로 판단할 수 있으므로, 저장 충돌이 발생한 뒤 다시 조회해 선행 처리 결과를 확인합니다.

동시에 요청이 들어온다면, 한쪽이 먼저 성공했다면, 다른 쪽은 DB unique 제약에 걸려 실패하게 됩니다.

참고로 쿠폰 재고를 다시 증가시키는 이유는, 이미 Redis에서 재고를 감소시킨 이후 예외가 발생했기 때문에 차감한 재고를 복구하기 위해서입니다.
이 파트에 대한 내용은 추후 outbox를 설명할 때, 다시 등장할 예정입니다.
2. 동시성 안전성
요청이 몰려서 재고보다 많이 발급되지 않게 해야 합니다.
다행스럽게도 동시성 안전성은 적용되어 있었습니다.
첫째, 재고 수량을 초과해서 쿠폰이 발급되지 않도록 방어하는 로직이 존재합니다.

이로써, 여러 요청이 동시에 들어와도 재고가 0보다 작아지면 다시 복구하고 실패처리가 가능합니다.
둘째, 같은 유저가 같은 쿠폰을 동시에 여러 번 발급받으려고 해도 최종적으로 하나만 저장이 됩니다.
즉, 동시 요청이 들어와도 동일 유저의 중복 방지 발급은 방어할 수 있습니다.

이 정도만 해도 어느 정도, 동시성은 보장이 됩니다. 조금 더 정확하게 동시성을 적용하려면 다음과 같은 것들을 적용할 수 있습니다.
현재는 Redis 재고 차감과 DB 저장이 하나의 원자적 작업이 아닙니다.
현재는 중복을 확인하고 Redis 재고 감소가 발생하며, 그 후에 DB에 저장이 됩니다.
하지만 이 과정이 하나의 원자적 연산으로 묶여 있지는 않습니다. 즉, 처리 중간에 실패가 나면 잠시 어긋날 가능성이 존재합니다.

Redis 재고 차감과 DB 저장은 하나의 원자적 연산이 아닙니다. 현재는 Redis에서 재고를 먼저 감소시킨 뒤 DB에 발급 쿠폰을 저장합니다. 이때 DB 저장이 실패하면, 감소했던 Redis 재고를 다시 증가시켜 보정합니다. 즉, 정상적인 예외 흐름에서는 보상 로직이 존재하지만, Redis 차감 직후 프로세스 장애 같은 상황까지 완전히 원자적으로 보장하는 구조는 아닙니다.
그렇다면, 원자적으로 동작을 시키려면 어떻게 수정할 수 있을까요?
일단 우리가 생각해야 되는 것이 DB를 기준으로 정합성이 발생을 시켜야 된다는 점입니다. 현재는 redis를 기준으로 정합성이 발생하고 있습니다. 이를 다시 말하면 보조 수단으로 사용을 해야 하는 redis가 메인으로 사용이 되었다고 할 수 있습니다.

다시 동시성을 완벽하게 통제하기 위해 DB로 바뀐 것을 확인할 수 있습니다.
그러면 redis는 어떻게 바뀌었을까요?
바로 DB의 트랜잭션이 종료된 이후에 반영이 됩니다. 바로 다음과 같은 코드로 반영이 됩니다.

코드가 두 가지로 나뉘는 이유는, 트랜잭션 동기화가 활성화된 경우와 활성화되지 않은 경우를 구분하기 위해서입니다. 동기화가 활성화되어 있으면 트랜잭션이 정상 커밋된 후 Redis 캐시를 삭제하고, 활성화되어 있지 않으면 Redis 캐시를 즉시 삭제합니다.
이전 코드에서는 redis 차감이 발생하였지만 이제는 차감이 아니라 제거하는 방식을 선택하였습니다.
이렇게 만든 이유는 redis를 재고의 기준 저장소로 쓰지 않고, 조회 캐시로만 사용하기로 했기 때문입니다.
그러면 이런 의문이 들 수 있습니다. redis가 이제 의미가 없는 걸까? 현재 구조에서는 redis는 보조 수단으로 넘어갔기 때문에 조회 최적화를 위한 의미로 가진다고 볼 수 있습니다.
물론 조회 시에는 다음과 같은 코드가 반영되어 있습니다.

조회 시 redis에 데이터가 존재하지 않는다면, DB를 데이터가 존재한다면 redis를 가져오는 코드입니다.
여기까지 진행했다면, 의문이 드는 것이 있습니다. 실제로 동시성 제어는 괜찮을까?
500UV만 돌려보겠습니다. 200~10000 UVS는 추후 테스트 예정입니다.

걱정했던 것과 달리, 이번 테스트에서는 실패가 발생하지 않았습니다. 물론 1회 실행 결과이기 때문에 오차 가능성은 있지만, 응답 속도도 전반적으로 준수한 수준으로 보입니다. 이를 통해 재고 차감 과정의 동시성 제어가 현재 기준에서는 비교적 안정적으로 동작하고 있다고 볼 수 있습니다.
위에서 consumer을 3으로 지정했습니다. consumer:3은 기존 Asynchronous Task Execution Pattern과 비교를 해보겠습니다.

500 VUS 구간에서 소폭의 실패율 증가가 관찰되었습니다. 다만 이는 RDB의 구조적 한계라기보다는, 일시적인 EOF/connection issue 또는 애플리케이션 인스턴스의 순간적인 흔들림에 기인했을 가능성이 큽니다. 그럼에도 전체적으로는 고부하 구간에서 실패율이 크게 감소해, 안정성 측면에서는 오히려 개선된 것으로 볼 수 있습니다.
이제부터는 동시에 얼마나 많은 consumer를 활용할 수 있는지 확인해보려 합니다. 이를 위해 concurrency 값을 점진적으로 증가시키면서, 실제 처리량이 얼마나 늘어나는지와 어느 지점부터 한계가 나타나는지를 함께 살펴볼 예정입니다.
concurrency를 3번부터 10번까지 증가시키면서 테스트를 진행해 보았습니다.


이제 Competing Consumer Pattern까지 적용시켜 봤습니다. 동시성을 지키기 위해서는 자원이 가진 ID들 뿐만 아니라 이벤트까지 중복을 체크를 해야 했습니다. 또한, 애플리케이션에서 중복을 체크를 하지 못하는 것을 방지하고자 DB단에서 키 관리를 하여 최종적으로 체크하여 중복을 최대한 줄였습니다.
데이터 일관성을 지키기 위해 레디스 차감 방식이 아닌, RDB에서 차감하는 방식으로 변경하였으며, 비관락까지 재 추가하였습니다. 이는 레디스로 옮기는 과정에서 누락이 있었던 걸로 파악이 됩니다.
이제 처리량은 마무리를 지었습니다. 제 목표는 10000 UVS까지 안전하고 빠르게 전달하는 것이 목표입니다. 고로 concurrency에서 가장 빠른 성능을 보여준 concurrency=9를 중심으로 진행될 예정이며, 만약, 속도가 너무 느리다면,
5도 함께 진행해 볼 예정입니다.
Outbox 패턴 적용
처리량 개선을 위한 패턴 적용이 마무리되었으니, 이제는 정합성과 안정성을 높이는 단계로 넘어가 보겠습니다. 단순히 요청을 빠르게 처리하는 것만으로는 충분하지 않습니다. 중요한 것은 실제로 누락 없이 완료되는 요청의 수를 늘리는 것입니다. 이러한 문제를 해결하기 위한 대표적인 방법이 Outbox 패턴입니다. 이번에는 Outbox 패턴을 적용하여, 데이터 저장과 메시지 발행 사이의 불일치 가능성을 줄이고 최종 완료 건수를 높여보겠습니다.
Outbox 패턴이란?
Outbox 패턴은 비즈니스 데이터 변경과 이벤트 발행을 분리하되, 둘 사이의 정합성을 보장하기 위한 패턴입니다.
지금까지는 consumer 측에서 중복 처리와 동시성 제어를 통해 안정성을 확보했지만, producer 측에서는 DB 변경과 Kafka 발행이 하나의 원자적 흐름으로 묶여 있지 않아 이벤트 유실 가능성이 있습니다. 이를 해결하기 위해 비즈니스 데이터와 함께 outbox 이벤트를 같은 트랜잭션으로 저장하고, 이후 별도 relay/publisher가 이를 Kafka로 발행하는 방식이 outbox 패턴입니다.
일단 간단하게

outbox에 이벤트를 저장하고 바로 프로듀서를 통해 이벤트를 넘겨주도록 하였습니다.
하지만 결과는...

흐음... kafka로 통신으로 9937개가 성공하였습니다. 여가까지는 뭐 그럴 수 있다고 생각이 듭니다. 왜냐하면 저에게는 이제 outbox가 존재하기 때문이죠. 하지만..

9937개가 저장이 되었습니다. concurrency도 증가시켜 봤고, VUS를 증가시켜 봤지만 결과는 똑같았습니다.
사실 정합성이라는 것은 단순히 모든 요청이 성공해야 한다는 의미는 아닙니다. 성공한 요청 수와 실패한 요청 수를 합친 결과가 전체 입력값과 일치하고, 그 결과가 시스템의 기대 동작과 맞아떨어진다면 정합성이 유지된다고 볼 수 있습니다. 그런 관점에서 보면, 지금까지는 정합성이 크게 깨졌다고 보기는 어렵습니다.
그렇다면 outbox 패턴은 굳이 적용할 필요가 없는 것일까요?
그렇지는 않습니다. 현재는 정합성이 잘 맞아 보일 수 있지만, 그렇다고 해서 장애 상황에 대비한 안전장치가 불필요하다는 뜻은 아닙니다.
outbox 패턴은 평상시에 큰 차이를 드러내기보다는, 장애가 발생하더라도 이벤트 유실 없이 데이터와 메시지의 정합성을 최대한 유지하기 위한 장치에 가깝습니다.
이러한 관점에서 보면, outbox 패턴의 가치를 단순히 결과 수치의 차이로만 증명하기는 어려울 수 있습니다. 오히려 정합성을 위한 안전장치를 추가했음에도 시스템의 성능이 크게 저하되지 않았음을 보여주는 것 역시 충분히 의미 있는 접근입니다.
즉, 안정성을 높이면서도 처리 성능을 일정 수준 이상 유지할 수 있음을 입증하는 것입니다.
물론 시간이 충분하다면 kafka에 적재된 메시지는 결국 모두 소비될 가능성이 높습니다. 그러나 실제 운영 환경에서는 consumer 애플리케이션이 중단되거나, 메시지 처리 도중 장애가 발생하거나, 동일한 메시지가 중복 전달되는 상황도 고려해야 합니다. 이런 이유로 단순히 메시지를 발행하는 것만으로는 정합성을 충분히 보장하기 어렵습니다.
따라서 정합성을 더 높이기 위해서는 producer 측의 outbox 패턴뿐 아니라, consumer 측에서도 멱등성 보장, 재처리 전략, 실패 메시지 관리와 같은 추가적인 설계가 함께 이루어져야 합니다.
결국 outbox 패턴이 없더라도 항상 정합성이 깨지는 것은 아닙니다. 다만 outbox 패턴은 데이터 저장과 이벤트 발행 사이에서 발생할 수 있는 불일치를 줄이고, 장애 상황에서도 정합성을 보다 명확하고 안전하게 보장하기 위한 방법이라고 볼 수 있습니다.
transactional outbox
우리는 outbox 패턴을 이미 적용하고 있었습니다. 현재 구조는 transactional outbox 패턴을 기반으로, 비즈니스 데이터와 이벤트를 동일 트랜잭션으로 저장한 뒤, 커밋 이후 동일 요청 흐름에서 즉시 메시지를 발행하는 Inline 방식으로 구현되어 있습니다.

하지만 현재는 메시지를 다시 발행해 주는 요소는 없습니다. 단순히 outbox와 발행을 하나의 트랜잭션 내에서 할 뿐입니다. 여기서 조금 더 정확히 말하면, 트랜잭션이 완료된 이후에 메시지를 발행해야 한다는 요구에 초점을 둔다면, afterCommit 방식도 하나의 선택지로 고려할 수 있습니다. 이제 저희가 고려해야 할 것은, 어떻게 하면 이러한 안전장치를 더 확실하게 만들 수 있을까 하는 점입니다.
재시도 발행 메커니즘
현재 방식은 이벤트를 outbox에 저장만 할 뿐, 그 값을 다시 발행하는 구조는 아닙니다. 따라서 consumer 측에 문제가 발생하더라도, outbox 데이터를 기반으로 복구를 진행할 수 없습니다. 결국 정합성을 더 확실하게 보장하려면, outbox에 저장된 이벤트를 다시 발행하는 메커니즘이 필요합니다. 이러한 방식으로는 재시도 발행 방식과 CDC 방식을 고려할 수 있습니다.
여기에서는 우선 재시도 발행 방식을 중심으로 살펴보겠습니다.

1초에 한번씩 이벤트를 재 발행을 하고 있습니다. 일정량(BATCH_SIZE)을 정해서 FAIL 혹은 PANDING상태인 이벤트들만 가져옵니다. 그리고 재 발행을 시도하고 있습니다.
일단 테스트를 돌려봅시다.
성능 최적화 대상이 아닌 안전장치에 가깝긴하지만요. 성능 테스트는 의미는 상대적으로 크지 않다고 여겨집니다.

지금까지 DB에 데이터가 완전히 쌓인뒤에 테스트를 보내는 방식을 선택하였지만 이번에는 부하테스트는 DB의 데이터 여부와 상관없이 이벤트를 보내게 하였습니다. 그렇다는 것은 어느 정도 시간이 지났을 때 결과를 봤을 때 값이 일치하는지 여부를 확인해 봐야 하니다.

총개수는 9737개로 위에서 발행된 이벤트의 개수와 일치한다는 것을 알 수 있습니다. 이로서 정합성은 맞추었습니다.
여기에는 치명적인 문제가 있습니다.
1. 1초마다 DB를 조회합니다.
이는 불필요한 polling을 유발하고, DB 부하를 증가시켜 서비스 성능 저하의 원인이 될 수 있습니다.
2. 처리 방식이 batch polling 중심입니다.
현재 구조에서는 outbox relay가 정해진 배치 단위로 이벤트를 조회·발행하므로,
상황에 따라 뒤에 있는 이벤트가 즉시 발행되지 못하고 다음 처리 주기까지 대기하게 됩니다.

3. 이 구조만 놓고 보면 굳이 Kafka일 필요가 없습니다.
현재 방식은 kafka의 고유한 특성을 적극적으로 활용한다기보다, DB polling 기반의 비동기 전달 구조에 kafka를 연결한 형태에 가깝습니다.
따라서 동일한 방식은 다른 MQ에서도 구현 가능하며, 이 점에서 "kafka를 kafka 답게 사용하고 있다"라고 보기는 어렵습니다.
이쯤에서 테스트를 진행해봅시다.


kafka가 꺼졌다가 켜졌음에도 정합성은 지켜졌다는 것을 알 수 있습니다.
그렇다면 kafka답게 outbox 패턴을 사용하는 방법이 무엇일까요?
가능하면 즉시 Kafka 발행하도록 합니다.
여기서 핵심은 publish가 되었을 때, 성공하였다면, outbox를 SENT로 실패하였다면, FAILED으로... 설정이 되어야 합니다.

현재는 트랜잭션 전파 방식을 사용해 별도의 트랜잭션을 생성하도록 구현했습니다. 다만 이 부분은 Spring Event 기반으로 분리해도 충분히 가능하다고 생각합니다. 그리고 해당 로직은 비동기로 처리해야 의미가 있는데, @Async가 적용되지 않으면 결국 동기적으로 실행됩니다. 이 경우 쿠폰 생성 이후 발행까지의 지연 시간이 예상보다 훨씬 커질 수 있습니다.
이렇게 되면, 평소 이벤트 흐름은 kafka가 담당하고 outbox는 실패 시 복구용으로 남겨 둘 수 있습니다.
outbox poller는 느린 safety net으로만 사용
이제 outbox 복구 reply는 1s가 아닌 10s 이상으로 해도 괜찮습니다.

이렇게까지 작성하면, 특정 MQ서비스에 구에 받지 않고 outbox패턴을 적용할 수 있습니다.
하지만 저희는 kafka를 사용중에 있습니다. 그렇다면, 전통적인 outbox패턴 방식보다는 조금 더 kafka스럽게 작성하려면 어떤 방식으로 구현하는 것이 적절할까요?
재시도를 조금더 kafka 답게
지금까지 재시도를 위해 outbox라는 안전장치를 두었습니다. 하지만 그렇다고 해서 이 구조가 kafka를 충분히 잘 활용하고 있다고 보기는 어렵습니다. 그렇다면 어떻게 해야 조금 더 kafka스럽게 만들 수 있을까요?
우선 가장 기본적인 부분부터 살펴보기 위해, producer 옵션부터 조정해보겠습니다.
producer 설정을 수정한다는 것은, 이벤트 발행 방식을 kafka의 특성에 더 맞게 다듬는다는 의미입니다.

acsks=0:
- 가장 빠르게 전송할 수 있는 방식이지만, 유실 위험이 가장 큽니다.
- 지연 시간은 줄일 수 있지만, 메시지가 broker에 실제로 저장되었는지는 보장하지 않습니다.
acsks=1:
- 리더 broker가 메시지를 받으면 성공으로 판단합니다.
- follower replica에 반영될 때까지는 기다리지 않습니다.
- 따라서 리더까지 전달되었다는 보장은 있지만, 리더 장애가 발생하면 유실 가능성이 있습니다.
asks=all(-1):
- 리더와 ISR(in-sync replicas)에 기록되어야 성공으로 판단합니다.
- 가장 안전한 방식이지만, 상대적으로 지연 시간이 길어질 수 있습니다.
저는 신뢰성이 중요하다고 생각하여 asks=all을 선택하였습니다.
enable.idempotence=true
- producer 재시도로 같은 레코드가 중복 저장되는 것을 막습니다.
retries=3
- 전송 실패시 producer가 최대 3번 다시 시도 합니다.
retry.backoff.ms=1000
- 재시도 사이에 1초 기다립니다.
이제는 consumer 측도 함께 살펴볼 필요가 있습니다.
producer뿐 아니라 consumer까지 보완한다면, 수신 측 역시 kafka의 특성에 더 맞는 방향으로 구성할 수 있을 것 같습니다.
consumer에는 무엇을 넣을 수 있을까요?
consumer는 producer와 달리 이벤트를 보내는 역할이 아니라 받아서 처리하는 역할을 담당합니다. 따라서 이벤트를 어떤 방식으로 수신하고 처리할지에 대한 흐름을 이 부분에서 설계할 수 있습니다.
다음과 같은 것들을 추가할 수 있습니다.
즉,consumer 측에서는 처리 실패에 대비한 retry/backoff, 최종 실패 메시지를 위한 DLT, 그리고 중복 소비를 방지하기 위한 idempotent consumer 전략을 함께 설계할 수 있습니다.

@RetryableTopic: 실패한 메시지를 별도의 retry topic으로 보내고 시간이 지난 뒤 다시 소비하게 만드는 방식입니다.
DLT: 원본 메시지 처리 실패 -> retry 여러 번(default:3번) -> 실패 시 -> 더 이상 메인 흐름에서 처리하지 않고 DLT(Dead Letter Topic)으로 보냄
BackOff: 메시지 처리에 실패했다고 해서 바로 즉시 다시 처리하지 않고 조금 기다렸다가 재시도하게 만드는 방식입니다.
지수 백오프(Exponential backoff): delay 값으로 시작하고, 재시도할 때마다 대기 시간이 multiplier만큼 증가하는 방식입니다.
고정 백오프(Fixed backoff): 재시도할 때마다 항상 동일한 delay 간격으로 대기하는 방식입니다.
DLT는 어떻게 처리가 되어지는가?

최종적으로 더 이상 재시도를 수행하지 않는 경우에는, 이렇게 fallback 코드를 작성할 수 있습니다.
이렇게까지 구성하면 어느 정도는 kafka답게 사용하고 있다고 말할 수 있는 수준에는 도달했습니다. 하지만 아직 완전히 kafka-native한 구조라고 보기는 어렵습니다.
마무리
약 2주간 쿠폰 발급 서비스를 개발하면서 많은 것을 학습을 했습니다. 그렇다고 해서 이게 native-kafka라고 말하기는 어렵다고 생각이 듭니다. 왜냐하면 운영쪽으로 많은 고민이 있어야 하지만 현재로써는 반영이 되지 않았습니다. 예를 들면, Consumer retry/DLT를 실제 운영 흐름, DLT 메시지 운영 체계등을 말할 수 있습니다. 게다가 CDC기반 outbox도 구현하지는 않았습니다. CDC기반으로 하게 되면 더 이상 풀링 방식으로 주입을 시켜줄 필요가 없습니다. 현재로써는 이 정도만 해도 큰 문제가 없다고 생각하였습니다. outbox가 없었을때도 정합성은 0%였기 때문입니다. 그래서 outbox때는 부하테스트한 내용이 없던거구요. 그렇다고 해서 여기서 마무리를 짓는다는 것은 아닙니다. 계속 언급했던 kafka답게를 실천하기 위해 앞으로도 꾸준히 개선해나갈 예정입니다.
'개발 > 동시성 테스트' 카테고리의 다른 글
| 분산락은 도대체 무엇일까? (0) | 2026.02.16 |
|---|---|
| 당신의 동시성 테스트가 원하는 결과가 나오지 않는 이유 (7) | 2025.08.07 |