Skip to content

Conversation

@minor7295
Copy link
Collaborator

@minor7295 minor7295 commented Dec 18, 2025

๐Ÿ“Œ Summary

API ์„œ๋น„์Šค(commerce-api)์™€ ๋…๋ฆฝ๋œ ์ง‘๊ณ„ ์„œ๋น„์Šค(commerce-streamer)๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ๋‘ ์„œ๋น„์Šค ๊ฐ„ ๋А์Šจํ•œ ์—ฐ๊ฒฐ์„ Kafka ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ํŒŒ์ดํ”„๋ผ์ธ์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

์ฃผ์š” ๊ตฌํ˜„ ๋‚ด์šฉ:

  • Producer (commerce-api): Transactional Outbox Pattern์„ ํ†ตํ•œ ์ด๋ฒคํŠธ ๋ฐœํ–‰ (๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜๊ณผ ๋™์ผ ํŠธ๋žœ์žญ์…˜์—์„œ Outbox ์ €์žฅ, ๋ณ„๋„ ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ Kafka๋กœ ๋ฐœํ–‰)
  • Consumer (commerce-streamer): ์ด๋ฒคํŠธ ์ˆ˜์ทจ ๋ฐ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„ (์ข‹์•„์š” ์ˆ˜, ํŒ๋งค๋Ÿ‰, ์กฐํšŒ ์ˆ˜)
  • ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ: event_handled ํ…Œ์ด๋ธ”(UUID ๊ธฐ๋ฐ˜ eventId)๊ณผ version ํ•„๋“œ(aggregateId๋ณ„ ์ˆœ์ฐจ์  ๋ฒ„์ „)๋ฅผ ํ†ตํ•œ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€
  • ์ˆœ์„œ ๋ณด์žฅ: ํŒŒํ‹ฐ์…˜ ํ‚ค ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ์ˆœ์„œ ๋ณด์žฅ ๋ฐ offset.reset: latest ์„ค์ •์œผ๋กœ ๋ถˆํ•„์š”ํ•œ ๊ณผ๊ฑฐ ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ ๋ฐฉ์ง€

๊ตฌํ˜„๋œ ์ด๋ฒคํŠธ:

  • like-events: LikeAdded, LikeRemoved โ†’ ์ข‹์•„์š” ์ˆ˜ ์ง‘๊ณ„
  • order-events: OrderCreated โ†’ ํŒ๋งค๋Ÿ‰ ์ง‘๊ณ„
  • product-events: ProductViewed โ†’ ์กฐํšŒ ์ˆ˜ ์ง‘๊ณ„

๐Ÿ’ฌ Review Points

1. Transactional Outbox Pattern ๊ตฌํ˜„ ๋ฐฉ์‹์˜ ์ ์ ˆ์„ฑ

๋ฐฐ๊ฒฝ ๋ฐ ๋ฌธ์ œ ์ƒํ™ฉ:
์™ธ๋ถ€ ์‹œ์Šคํ…œ(Kafka)๊ณผ์˜ ํ†ต์‹ ์ด ํ•„์š”ํ•œ ์ƒํ™ฉ์—์„œ, ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜๊ณผ Kafka ๋ฐœํ–‰์„ ๋™์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฌถ์„ ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฒคํŠธ ์œ ์‹ค ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์ฃผ๋ฌธ ์ƒ์„ฑ ํŠธ๋žœ์žญ์…˜์ด ์„ฑ๊ณตํ–ˆ์ง€๋งŒ Kafka ๋ฐœํ–‰์ด ์‹คํŒจํ•˜๋ฉด, ์ง‘๊ณ„ ์„œ๋น„์Šค๋Š” ํ•ด๋‹น ์ฃผ๋ฌธ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์ง€ ๋ชปํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋ฐ˜๋Œ€๋กœ Kafka ๋ฐœํ–‰์€ ์„ฑ๊ณตํ–ˆ์ง€๋งŒ ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜์ด ๋กค๋ฐฑ๋˜๋ฉด, ์‹ค์ œ๋กœ๋Š” ์ฃผ๋ฌธ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜๋Š”๋ฐ ์ง‘๊ณ„ ์„œ๋น„์Šค๋Š” ์ฃผ๋ฌธ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›๊ฒŒ ๋˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:
์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด Transactional Outbox Pattern์„ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜๊ณผ ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜์—์„œ OutboxEvent๋ฅผ DB์— ๋จผ์ € ์ €์žฅํ•˜๊ณ , ๋ณ„๋„ ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ PENDING ์ƒํƒœ์˜ ์ด๋ฒคํŠธ๋ฅผ ์ฝ์–ด Kafka๋กœ ๋ฐœํ–‰ํ•˜๋Š” ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜์ด ์„ฑ๊ณตํ•˜๋ฉด Outbox์— ์ด๋ฒคํŠธ๊ฐ€ ์ €์žฅ๋˜๊ณ , ํŠธ๋žœ์žญ์…˜์ด ๋กค๋ฐฑ๋˜๋ฉด Outbox ์ €์žฅ๋„ ํ•จ๊ป˜ ๋กค๋ฐฑ๋˜์–ด ์ผ๊ด€์„ฑ์ด ๋ณด์žฅ๋ฉ๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์„ธ๋ถ€์‚ฌํ•ญ:

  1. ApplicationEvent โ†’ OutboxEvent ๋ณ€ํ™˜: OutboxBridgeEventListener๊ฐ€ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)๋กœ ์„ค์ •๋˜์–ด, ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜์ด ์ปค๋ฐ‹๋œ ํ›„์—๋งŒ Outbox์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋„๋ฉ”์ธ ๋กœ์ง์ด ์‹คํŒจํ•˜์—ฌ ํŠธ๋žœ์žญ์…˜์ด ๋กค๋ฐฑ๋˜๋ฉด Outbox ์ €์žฅ๋„ ๋กค๋ฐฑ๋˜์–ด ๋ถˆํ•„์š”ํ•œ ์ด๋ฒคํŠธ๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

  2. ์Šค์ผ€์ค„๋Ÿฌ ๊ธฐ๋ฐ˜ ๋ฐœํ–‰: OutboxEventPublisher๊ฐ€ 1์ดˆ๋งˆ๋‹ค ์‹คํ–‰๋˜์–ด PENDING ์ƒํƒœ์˜ ์ด๋ฒคํŠธ๋ฅผ ์ตœ๋Œ€ 100๊ฐœ์”ฉ ์ฝ์–ด Kafka๋กœ ๋ฐœํ–‰ํ•ฉ๋‹ˆ๋‹ค. ๋ฐœํ–‰ ์„ฑ๊ณต ์‹œ PUBLISHED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•˜๊ณ , ์‹คํŒจ ์‹œ FAILED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ๋‹ค์Œ ์Šค์ผ€์ค„์—์„œ ์žฌ์‹œ๋„ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

// OutboxBridgeEventListener.java - ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ํ›„์—๋งŒ Outbox์— ์ €์žฅ
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLikeAdded(LikeEvent.LikeAdded event) {
    outboxEventService.saveEvent(/* ... */);
}

// OutboxEventPublisher.java - 1์ดˆ๋งˆ๋‹ค PENDING ์ด๋ฒคํŠธ๋ฅผ ์ฝ์–ด Kafka๋กœ ๋ฐœํ–‰
@Scheduled(fixedDelay = 1000)
public void publishPendingEvents() {
    List<OutboxEvent> pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE);
    for (OutboxEvent event : pendingEvents) {
        publishEvent(event);
        event.markAsPublished();  // PUBLISHED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ
    }
}

๊ณ ๋ฏผํ•œ ์ :

  • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)๋ฅผ ์‚ฌ์šฉํ•œ ์ด์œ ๋Š”, ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ปค๋ฐ‹๋œ ํ›„์—๋งŒ Outbox์— ์ €์žฅํ•˜์—ฌ ์ผ๊ด€์„ฑ์„ ๋ณด์žฅํ•˜๊ธฐ ์œ„ํ•จ์ž…๋‹ˆ๋‹ค. ๋งŒ์•ฝ AFTER_COMMIT์ด ์•„๋‹Œ ๋‹ค๋ฅธ ์‹œ์ ์— ์ €์žฅํ•˜๋ฉด, ๋„๋ฉ”์ธ ๋กœ์ง์ด ์‹คํŒจํ•˜์—ฌ ๋กค๋ฐฑ๋˜์—ˆ๋Š”๋ฐ๋„ Outbox์— ์ด๋ฒคํŠธ๊ฐ€ ์ €์žฅ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ AFTER_COMMIT์€ ํŠธ๋žœ์žญ์…˜์ด ์™„์ „ํžˆ ์ปค๋ฐ‹๋œ ํ›„์— ์‹คํ–‰๋˜๋ฏ€๋กœ, Outbox ์ €์žฅ ์‹คํŒจ ์‹œ ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜์„ ๋กค๋ฐฑํ•  ์ˆ˜ ์—†๋‹ค๋Š” ๋‹จ์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์— ๋Œ€ํ•œ ๊ฒ€ํ† ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

  • ์Šค์ผ€์ค„๋Ÿฌ ์ฃผ๊ธฐ(1์ดˆ)์™€ ๋ฐฐ์น˜ ํฌ๊ธฐ(100)๋Š” ํ˜„์žฌ ํŠธ๋ž˜ํ”ฝ์„ ๊ณ ๋ คํ•˜์—ฌ ์„ค์ •ํ–ˆ์ง€๋งŒ, ์‹ค์ œ ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์ด๋ฒคํŠธ ๋ฐœ์ƒ ๋นˆ๋„์™€ Kafka ์ฒ˜๋ฆฌ ์†๋„๋ฅผ ๊ณ ๋ คํ•˜์—ฌ ์กฐ์ •์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋„ˆ๋ฌด ์งง์€ ์ฃผ๊ธฐ(์˜ˆ: 100ms)๋Š” DB ๋ถ€ํ•˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚ฌ ์ˆ˜ ์žˆ๊ณ , ๋„ˆ๋ฌด ๊ธด ์ฃผ๊ธฐ(์˜ˆ: 10์ดˆ)๋Š” ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์ง€์—ฐ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  • ๊ฐœ๋ณ„ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ ์‹œ FAILED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•˜๊ณ  ๊ณ„์† ์ง„ํ–‰ํ•˜๋„๋ก ํ–ˆ๋Š”๋ฐ, ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์ผ๋ถ€ ์ด๋ฒคํŠธ๋งŒ ์‹คํŒจํ•ด๋„ ๋‹ค์Œ ์Šค์ผ€์ค„์—์„œ ์žฌ์‹œ๋„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ FAILED ์ƒํƒœ์˜ ์ด๋ฒคํŠธ๋ฅผ ๋ณ„๋„๋กœ ๋ชจ๋‹ˆํ„ฐ๋งํ•˜๊ฑฐ๋‚˜, ์žฌ์‹œ๋„ ํšŸ์ˆ˜ ์ œํ•œ์„ ๋‘๋Š” ๋“ฑ์˜ ์ถ”๊ฐ€ ๋กœ์ง์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


2. ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ ์ „๋žต: event_handled ํ…Œ์ด๋ธ”๊ณผ version ํ•„๋“œ์˜ ์กฐํ•ฉ

๋ฐฐ๊ฒฝ ๋ฐ ๋ฌธ์ œ ์ƒํ™ฉ:
Kafka๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ At Least Once ๋ณด์žฅ์„ ์ œ๊ณตํ•˜๋ฏ€๋กœ, ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋‚˜ Consumer ์žฌ์‹œ์ž‘ ๋“ฑ์˜ ์ƒํ™ฉ์—์„œ ๋™์ผํ•œ ๋ฉ”์‹œ์ง€๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ์ „๋‹ฌ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ Producer ์ธก์—์„œ๋„ acks=all, enable.idempotence=true ์„ค์ •์œผ๋กœ At Least Once๋ฅผ ๋ณด์žฅํ•˜๋ฏ€๋กœ, ๋™์ผํ•œ ์ด๋ฒคํŠธ๊ฐ€ ์ค‘๋ณต ๋ฐœํ–‰๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ค‘๋ณต ๋ฉ”์‹œ์ง€๋ฅผ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌํ•˜๋ฉด ์ข‹์•„์š” ์ˆ˜๋‚˜ ํŒ๋งค๋Ÿ‰์ด ์ค‘๋ณต ์ง‘๊ณ„๋˜์–ด ์ž˜๋ชป๋œ ๋ฉ”ํŠธ๋ฆญ์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:
์ค‘๋ณต ์ฒ˜๋ฆฌ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ๋‘ ๊ฐ€์ง€ ์ „๋žต์„ ์กฐํ•ฉํ–ˆ์Šต๋‹ˆ๋‹ค:

  1. event_handled ํ…Œ์ด๋ธ”: ๋™์ผ eventId์˜ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€ (๋™์ผ ์ด๋ฒคํŠธ์˜ ์™„์ „ ์ค‘๋ณต ๋ฐฉ์ง€)
  2. version ํ•„๋“œ: ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๊ฐ€ ์ตœ์‹  ์ƒํƒœ๋ฅผ ๋ฎ์–ด์“ฐ๋Š” ๊ฒƒ ๋ฐฉ์ง€ (์ˆœ์„œ๊ฐ€ ๋’ค๋ฐ”๋€ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐฉ์ง€)

๊ตฌํ˜„ ์„ธ๋ถ€์‚ฌํ•ญ:

1) event_handled ํ…Œ์ด๋ธ”์„ ํ†ตํ•œ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€:

  • ๊ฐ ์ด๋ฒคํŠธ์— UUID ๊ธฐ๋ฐ˜์˜ ๊ณ ์œ ํ•œ eventId๋ฅผ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค.
  • Consumer์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์ „์— event_handled ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹น eventId๊ฐ€ ์ด๋ฏธ ์ฒ˜๋ฆฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ๊ฒฝ์šฐ ์Šคํ‚ตํ•˜๊ณ , ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ์—๋งŒ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์‹คํ–‰ํ•œ ํ›„ event_handled ํ…Œ์ด๋ธ”์— ๊ธฐ๋ก์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • event_handled ํ…Œ์ด๋ธ”์˜ event_id ์ปฌ๋Ÿผ์— UNIQUE ์ œ์•ฝ์กฐ๊ฑด์„ ์„ค์ •ํ•˜์—ฌ, ๋™์‹œ์„ฑ ์ƒํ™ฉ์—์„œ๋„ ์ค‘๋ณต ์ฒ˜๋ฆฌ๋ฅผ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค. ๋งŒ์•ฝ ๋‘ ๊ฐœ์˜ Consumer ์ธ์Šคํ„ด์Šค๊ฐ€ ๋™์‹œ์— ๊ฐ™์€ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋ ค๊ณ  ํ•˜๋ฉด, ํ•˜๋‚˜๋Š” ์„ฑ๊ณตํ•˜๊ณ  ๋‹ค๋ฅธ ํ•˜๋‚˜๋Š” UNIQUE ์ œ์•ฝ์กฐ๊ฑด ์œ„๋ฐ˜ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์—ฌ ์ค‘๋ณต ์ฒ˜๋ฆฌ๊ฐ€ ๋ฐฉ์ง€๋ฉ๋‹ˆ๋‹ค.

2) version ํ•„๋“œ๋ฅผ ํ†ตํ•œ ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐฉ์ง€:

  • OutboxEvent์— aggregateId๋ณ„๋กœ ์ˆœ์ฐจ์ ์œผ๋กœ ์ฆ๊ฐ€ํ•˜๋Š” version ํ•„๋“œ๋ฅผ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, productId=1์— ๋Œ€ํ•œ ์ฒซ ๋ฒˆ์งธ ์ด๋ฒคํŠธ๋Š” version=1, ๋‘ ๋ฒˆ์งธ ์ด๋ฒคํŠธ๋Š” version=2๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.
  • ์ด version์€ Kafka ๋ฉ”์‹œ์ง€ ํ—ค๋”์— ํฌํ•จ๋˜์–ด Consumer๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค.
  • Consumer์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•  ๋•Œ, ProductMetrics์˜ ํ˜„์žฌ version๊ณผ ์ด๋ฒคํŠธ์˜ version์„ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฒคํŠธ์˜ version์ด ๋ฉ”ํŠธ๋ฆญ์˜ version๋ณด๋‹ค ํฌ๋ฉด ์—…๋ฐ์ดํŠธํ•˜๊ณ , ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ์Šคํ‚ตํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋„คํŠธ์›Œํฌ ์ง€์—ฐ์ด๋‚˜ ํŒŒํ‹ฐ์…˜ ์ˆœ์„œ ๋ฌธ์ œ๋กœ ์ธํ•ด ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๊ฐ€ ๋‚˜์ค‘์— ๋„์ฐฉํ•˜๋”๋ผ๋„, ์ด๋ฏธ ๋” ์ตœ์‹  ๋ฒ„์ „์˜ ๋ฉ”ํŠธ๋ฆญ์ด ์กด์žฌํ•˜๋ฉด ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๋Š” ๋ฌด์‹œ๋ฉ๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

// OutboxEventService.java - eventId(UUID)์™€ version(aggregateId๋ณ„ ์ˆœ์ฐจ ์ฆ๊ฐ€) ๋ถ€์—ฌ
public void saveEvent(...) {
    String eventId = UUID.randomUUID().toString();
    Long nextVersion = outboxEventRepository.findLatestVersionByAggregateId(...) + 1L;
    // OutboxEvent์— eventId์™€ version ์ €์žฅ
}

// OutboxEventPublisher.java - Kafka ํ—ค๋”์— eventId์™€ version ํฌํ•จ
private void publishEvent(OutboxEvent event) {
    messageBuilder
        .setHeader("eventId", event.getEventId())
        .setHeader("version", event.getVersion());
}

// ProductMetricsConsumer.java - ๋ฉฑ๋“ฑ์„ฑ ์ฒดํฌ ๋ฐ ๋ฒ„์ „ ๋น„๊ต
public void consumeLikeEvents(...) {
    String eventId = extractEventId(record);
    if (eventHandledService.isAlreadyHandled(eventId)) continue;  // ์ค‘๋ณต ์ฒดํฌ
    
    Long eventVersion = extractVersion(record);
    productMetricsService.incrementLikeCount(productId, eventVersion);  // version ๋น„๊ต ํฌํ•จ
    eventHandledService.markAsHandled(eventId, ...);
}

// ProductMetricsService.java - version ๋น„๊ต๋กœ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜
public void incrementLikeCount(Long productId, Long eventVersion) {
    if (!metrics.shouldUpdate(eventVersion)) return;  // ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ ์Šคํ‚ต
    metrics.incrementLikeCount();
}

๊ณ ๋ฏผํ•œ ์ :

  • event_handled ํ…Œ์ด๋ธ”์˜ UNIQUE ์ œ์•ฝ์กฐ๊ฑด์œผ๋กœ ๋™์‹œ์„ฑ ์ƒํ™ฉ์—์„œ๋„ ์ค‘๋ณต ์ฒ˜๋ฆฌ๋ฅผ ๋ฐฉ์ง€ํ–ˆ์ง€๋งŒ, ์ด ํ…Œ์ด๋ธ”์ด ๊ณ„์† ์ฆ๊ฐ€ํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด์„œ ์ด ํ…Œ์ด๋ธ”์˜ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฌดํ•œ์ • ์ฆ๊ฐ€ํ•˜๊ฒŒ ๋˜๋Š”๋ฐ, ์ด๋Š” ์Šคํ† ๋ฆฌ์ง€ ๋น„์šฉ๊ณผ ์กฐํšŒ ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. TTL(Time To Live)์„ ์„ค์ •ํ•˜์—ฌ ์ผ์ • ๊ธฐ๊ฐ„์ด ์ง€๋‚œ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ž๋™์œผ๋กœ ์‚ญ์ œํ•˜๊ฑฐ๋‚˜, ์•„์นด์ด๋น™ ์ „๋žต์„ ์ˆ˜๋ฆฝํ•˜์—ฌ ์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ„๋„ ํ…Œ์ด๋ธ”๋กœ ์ด๋™์‹œํ‚ค๋Š” ๋“ฑ์˜ ๋ฐฉ์•ˆ์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  • version ํ•„๋“œ๋Š” aggregateId๋ณ„๋กœ ์ž๋™ ์ฆ๊ฐ€ํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, ์ด ๋ฐฉ์‹์˜ ์žฅ์ ์€ ๊ฐ„๋‹จํ•˜๊ณ  ์ˆœ์ฐจ์ ์ธ ๋ฒ„์ „ ๊ด€๋ฆฌ๊ฐ€ ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ updatedAt ๊ธฐ๋ฐ˜ ๋ฐฉ์‹๊ณผ ๋น„๊ตํ–ˆ์„ ๋•Œ, updatedAt์€ ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜์ด๋ฏ€๋กœ ๋„คํŠธ์›Œํฌ ์ง€์—ฐ์ด๋‚˜ ์‹œ์Šคํ…œ ์‹œ๊ฐ„ ๋ถˆ์ผ์น˜ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด version์€ ์ˆœ์ฐจ์ ์œผ๋กœ ์ฆ๊ฐ€ํ•˜๋ฏ€๋กœ ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, aggregateId๋ณ„๋กœ ๋ณ„๋„์˜ ๋ฒ„์ „์„ ๊ด€๋ฆฌํ•ด์•ผ ํ•˜๋ฏ€๋กœ ๋ณต์žก๋„๊ฐ€ ์ฆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ฐฉ์‹์ด ์ ์ ˆํ•œ์ง€, ๋˜๋Š” ๋‹ค๋ฅธ ๋ฐฉ์‹(์˜ˆ: updatedAt ๊ธฐ๋ฐ˜, ๋˜๋Š” ์ด๋ฒคํŠธ ๋ฐœ์ƒ ์‹œ์ ์˜ ํƒ€์ž„์Šคํƒฌํ”„ ๊ธฐ๋ฐ˜)์ด ๋” ๋‚˜์€์ง€ ๊ฒ€ํ† ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

  • event_handled ํ…Œ์ด๋ธ”๊ณผ version ํ•„๋“œ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ค‘๋ณต์ผ ์ˆ˜ ์žˆ๋‹ค๋Š” ์˜๋ฌธ๋„ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋‘ ๊ฐ€์ง€๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ๋ชฉ์ ์„ ๊ฐ€์ง€๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. event_handled๋Š” ๋™์ผํ•œ ์ด๋ฒคํŠธ์˜ ์™„์ „ ์ค‘๋ณต์„ ๋ฐฉ์ง€ํ•˜๊ณ , version์€ ์ˆœ์„œ๊ฐ€ ๋’ค๋ฐ”๋€ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋„คํŠธ์›Œํฌ ๋ฌธ์ œ๋กœ ์ธํ•ด version=3 ์ด๋ฒคํŠธ๊ฐ€ ๋จผ์ € ๋„์ฐฉํ•˜๊ณ  version=2 ์ด๋ฒคํŠธ๊ฐ€ ๋‚˜์ค‘์— ๋„์ฐฉํ•˜๋Š” ๊ฒฝ์šฐ, event_handled๋กœ๋Š” ์ค‘๋ณต์„ ๊ฐ์ง€ํ•  ์ˆ˜ ์—†์ง€๋งŒ version์œผ๋กœ๋Š” ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ์ž„์„ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


3. ํŒŒํ‹ฐ์…˜ ํ‚ค ๊ธฐ๋ฐ˜ ์ˆœ์„œ ๋ณด์žฅ๊ณผ offset.reset: latest ์„ค์ •์˜ ์กฐํ•ฉ

๋ฐฐ๊ฒฝ ๋ฐ ๋ฌธ์ œ ์ƒํ™ฉ:
Kafka๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ํŒŒํ‹ฐ์…˜ ๋‚ด์—์„œ๋งŒ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜๊ณ , ์„œ๋กœ ๋‹ค๋ฅธ ํŒŒํ‹ฐ์…˜ ๊ฐ„์˜ ์ˆœ์„œ๋Š” ๋ณด์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ Consumer๊ฐ€ ์žฌ์‹œ์ž‘๋˜๊ฑฐ๋‚˜ ์ƒˆ๋กœ์šด Consumer Group์ด ์‹œ์ž‘๋  ๋•Œ, ๊ณผ๊ฑฐ์˜ ๋ชจ๋“  ๋ฉ”์‹œ์ง€๋ฅผ ๋‹ค์‹œ ์ฒ˜๋ฆฌํ•˜๊ฒŒ ๋˜๋ฉด ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์˜ค๋ž˜๋œ ๋ฉ”์‹œ์ง€๋ฅผ ์ค‘๋ณต ์ฒ˜๋ฆฌํ•˜๊ฑฐ๋‚˜, ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ ์ด์ „ ํ…Œ์ŠคํŠธ์˜ ๋ฉ”์‹œ์ง€๊ฐ€ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์— ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:

1) ํŒŒํ‹ฐ์…˜ ํ‚ค ๊ธฐ๋ฐ˜ ์ˆœ์„œ ๋ณด์žฅ:

  • ๊ฐ™์€ ๋„๋ฉ”์ธ์—์„œ ๋ฐœ์ƒํ•œ ์ด๋ฒคํŠธ๋Š” ๋™์ผํ•œ ํŒŒํ‹ฐ์…˜์—์„œ ์ฒ˜๋ฆฌ๋˜๋„๋ก ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ์„ค์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • like-events์™€ product-events๋Š” productId๋ฅผ ํŒŒํ‹ฐ์…˜ ํ‚ค๋กœ ์‚ฌ์šฉํ•˜์—ฌ, ๊ฐ™์€ ์ƒํ’ˆ์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ํ•ญ์ƒ ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.
  • order-events๋Š” orderId๋ฅผ ํŒŒํ‹ฐ์…˜ ํ‚ค๋กœ ์‚ฌ์šฉํ•˜์—ฌ, ๊ฐ™์€ ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ํ•ญ์ƒ ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.
  • ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๊ฐ™์€ aggregate root์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ์ˆœ์„œ๊ฐ€ ๋ณด์žฅ๋˜์–ด, ์˜ˆ๋ฅผ ๋“ค์–ด LikeAdded โ†’ LikeRemoved ์ˆœ์„œ๋กœ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ Consumer๋„ ๊ฐ™์€ ์ˆœ์„œ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

2) offset.reset: latest ์„ค์ •:

  • Consumer๊ฐ€ ์ƒˆ๋กœ์šด Consumer Group์œผ๋กœ ์‹œ์ž‘ํ•  ๋•Œ(offset์ด ์—†์„ ๋•Œ), ์ตœ์‹  ๋ฉ”์‹œ์ง€๋ถ€ํ„ฐ ์ฝ๊ธฐ ์‹œ์ž‘ํ•˜๋„๋ก offset.reset: latest๋ฅผ ์„ค์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด Consumer๊ฐ€ ์žฌ์‹œ์ž‘๋˜๊ฑฐ๋‚˜ ์ƒˆ๋กœ์šด Consumer Group์ด ์‹œ์ž‘๋  ๋•Œ, ๊ณผ๊ฑฐ์˜ ์˜ค๋ž˜๋œ ๋ฉ”์‹œ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๊ณ  ์ตœ์‹  ๋ฉ”์‹œ์ง€๋ถ€ํ„ฐ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŠนํžˆ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” ์ด์ „ ํ…Œ์ŠคํŠธ์—์„œ ๋ฐœํ–‰ํ•œ ๋ฉ”์‹œ์ง€๊ฐ€ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋„๋ก ํ•˜๋Š” ๋ฐ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ ์˜ต์…˜๊ณผ์˜ ๋น„๊ต:

  • offset.reset: latest ์„ค์ •์€ ์ƒˆ๋กœ์šด Consumer Group์ด ์‹œ์ž‘ํ•  ๋•Œ ์ตœ์‹  ๋ฉ”์‹œ์ง€๋ถ€ํ„ฐ ์ฝ๊ธฐ ์‹œ์ž‘ํ•˜๋ฏ€๋กœ, earliest๋ฅผ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ(์ด์ „ ํ…Œ์ŠคํŠธ์˜ ๋ฉ”์‹œ์ง€๊ฐ€ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์— ์˜ํ–ฅ์„ ์ฃผ์–ด ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ ์‹คํŒจ)๋ฅผ ๋ฐฉ์ง€ํ•˜๊ณ , manual ๋ฐฉ์‹์ฒ˜๋Ÿผ ๋ณต์žกํ•œ offset ๊ด€๋ฆฌ ๋กœ์ง ์—†์ด๋„ ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ๋ฅผ ๋ณด์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ๊ทธ๋Ÿฌ๋‚˜ latest ์„ค์ •์€ ์ƒˆ๋กœ์šด Consumer Group์ด ์‹œ์ž‘ํ•  ๋•Œ๋งŒ ์ ์šฉ๋˜๋ฏ€๋กœ, ๊ฐ™์€ Consumer Group์„ ๊ณ„์† ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ ์ด๋ฏธ ์ปค๋ฐ‹๋œ offset์ด ์žˆ์œผ๋ฉด latest๋Š” ์ ์šฉ๋˜์ง€ ์•Š๊ณ  ๊ธฐ์กด offset๋ถ€ํ„ฐ ๊ณ„์† ์ฝ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
  • ๋”ฐ๋ผ์„œ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ์—์„œ๋Š” KafkaCleanUp.resetAllTestTopics()๋กœ ํ† ํ”ฝ์„ ์‚ญ์ œํ•˜๊ณ  ์žฌ์ƒ์„ฑํ•˜์—ฌ Consumer Group์„ ์ดˆ๊ธฐํ™”ํ•จ์œผ๋กœ์จ, ๋งค ํ…Œ์ŠคํŠธ๋งˆ๋‹ค offset.reset: latest ์„ค์ •์ด ์ ์šฉ๋˜๋„๋ก ํ•˜๊ณ , ์ด์ „ ํ…Œ์ŠคํŠธ์˜ ๋ฉ”์‹œ์ง€๊ฐ€ ์™„์ „ํžˆ ์ œ๊ฑฐ๋˜์–ด ํ…Œ์ŠคํŠธ ๊ฐ„ ๊ฒฉ๋ฆฌ๋ฅผ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์„ธ๋ถ€์‚ฌํ•ญ:

ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •:

// OutboxBridgeEventListener.java
// productId๋ฅผ ํŒŒํ‹ฐ์…˜ ํ‚ค๋กœ ์‚ฌ์šฉ
public void handleLikeAdded(LikeEvent.LikeAdded event) {
    outboxEventService.saveEvent(
        "LikeAdded",
        event.productId().toString(),  // aggregateId
        "Product",
        event,
        "like-events",
        event.productId().toString()  // partitionKey
    );
}

// OutboxEventPublisher.java
// Kafka ๋ฉ”์‹œ์ง€์— ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •
private void publishEvent(OutboxEvent event) {
    Object payload = objectMapper.readValue(event.getPayload(), Object.class);
    
    var messageBuilder = MessageBuilder
        .withPayload(payload)
        .setHeader(KafkaHeaders.KEY, event.getPartitionKey())  // ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •
        .setHeader("eventId", event.getEventId())
        .setHeader("version", event.getVersion());
    
    kafkaTemplate.send(event.getTopic(), message);
}

offset.reset: latest ์„ค์ •:

# modules/kafka/src/main/resources/kafka.yml
spring:
  kafka:
    properties:
      auto.offset.reset: latest  # ์ƒˆ๋กœ์šด Consumer Group ์‹œ์ž‘ ์‹œ ์ตœ์‹  ๋ฉ”์‹œ์ง€๋ถ€ํ„ฐ
    producer:
      properties:
        acks: all                    # ๋ชจ๋“  ๋ฆฌํ”Œ๋ฆฌ์นด์— ์“ฐ๊ธฐ ํ™•์ธ
        enable.idempotence: true     # ์ค‘๋ณต ๋ฐฉ์ง€
    consumer:
      properties:
        enable-auto-commit: false    # ์ˆ˜๋™ ์ปค๋ฐ‹ ์‚ฌ์šฉ
    listener:
      ack-mode: manual               # ์ˆ˜๋™ ์ปค๋ฐ‹ ๋ชจ๋“œ

ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ์˜ ํ† ํ”ฝ ๋ฐ Consumer Group ์ดˆ๊ธฐํ™”:

// KafkaCleanUp.java
// ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ „์— ํ† ํ”ฝ๊ณผ Consumer Group์„ ์ดˆ๊ธฐํ™”ํ•˜์—ฌ offset.reset: latest๊ฐ€ ์ ์šฉ๋˜๋„๋ก ํ•จ
public void resetAllTestTopics() {
    deleteAllTestTopics();
    recreateTestTopics();
}

public void resetAllConsumerGroups() {
    try (AdminClient adminClient = createAdminClient()) {
        Set<String> consumerGroups = adminClient.listConsumerGroups()
            .all()
            .get(5, TimeUnit.SECONDS)
            .stream()
            .map(group -> group.groupId())
            .collect(java.util.stream.Collectors.toSet());
        
        if (!consumerGroups.isEmpty()) {
            DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups(consumerGroups);
            deleteResult.all().get(5, TimeUnit.SECONDS);
        }
    } catch (Exception e) {
        // Consumer Group์ด ์—†๊ฑฐ๋‚˜ ์ด๋ฏธ ์‚ญ์ œ๋œ ๊ฒฝ์šฐ ๋ฌด์‹œ
    }
}

ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ์˜ ๊ฒฉ๋ฆฌ ๋ณด์žฅ:

  • offset.reset: latest๋Š” ์ƒˆ๋กœ์šด Consumer Group์ด ์‹œ์ž‘ํ•  ๋•Œ๋งŒ ์ ์šฉ๋˜๋ฏ€๋กœ, ์ด๋ฏธ offset์ด ์ปค๋ฐ‹๋œ Consumer Group์—์„œ๋Š” ์ ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” ๊ฐ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ „์— KafkaCleanUp.resetAllTestTopics()์™€ resetAllConsumerGroups()๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ํ† ํ”ฝ๊ณผ Consumer Group์„ ์ดˆ๊ธฐํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋งค ํ…Œ์ŠคํŠธ๋งˆ๋‹ค offset.reset: latest ์„ค์ •์ด ์ ์šฉ๋˜์–ด, ์ด์ „ ํ…Œ์ŠคํŠธ์˜ ๋ฉ”์‹œ์ง€๊ฐ€ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ๋˜ํ•œ ํ…Œ์ŠคํŠธ ํ”„๋กœํŒŒ์ผ์—์„œ Consumer Group ID๋ฅผ ๋™์ ์œผ๋กœ ์ƒ์„ฑ(${spring.application.name}-test-${random.uuid})ํ•˜์—ฌ, ๊ฐ ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๋‹ค๋ฅธ Consumer Group์„ ์‚ฌ์šฉํ•˜๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์ด์ „ ํ…Œ์ŠคํŠธ์˜ offset์ด ๋‹ค์Œ ํ…Œ์ŠคํŠธ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

// KafkaCleanUp.java - ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ๋ฅผ ์œ„ํ•œ ์ดˆ๊ธฐํ™”
public void resetAllTestTopics() {
    deleteAllTestTopics();  // ๋ชจ๋“  ๋ฉ”์‹œ์ง€ ์ œ๊ฑฐ
    recreateTestTopics();   // ๊นจ๋—ํ•œ ์ƒํƒœ๋กœ ์žฌ์ƒ์„ฑ
}

// ProductMetricsConsumerIntegrationTest.java
@BeforeEach
void setUp() {
    kafkaCleanUp.resetAllTestTopics();      // ํ† ํ”ฝ ์ดˆ๊ธฐํ™”
    kafkaCleanUp.resetAllConsumerGroups();  // Consumer Group ์ดˆ๊ธฐํ™”
}

๊ณ ๋ฏผํ•œ ์ :

  • offset.reset: latest๋Š” ์ƒˆ๋กœ์šด Consumer Group์ด ์‹œ์ž‘ํ•  ๋•Œ๋งŒ ์ ์šฉ๋˜๋ฏ€๋กœ, ์ด๋ฏธ offset์ด ์ปค๋ฐ‹๋œ Consumer Group์—์„œ๋Š” ์ ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” ๊ฐ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ „์— KafkaCleanUp.resetAllTestTopics()์™€ resetAllConsumerGroups()๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ํ† ํ”ฝ๊ณผ Consumer Group์„ ์ดˆ๊ธฐํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋งค ํ…Œ์ŠคํŠธ๋งˆ๋‹ค offset.reset: latest ์„ค์ •์ด ์ ์šฉ๋˜์–ด, ์ด์ „ ํ…Œ์ŠคํŠธ์˜ ๋ฉ”์‹œ์ง€๊ฐ€ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ด ๋ฐฉ์‹์€ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์‹œ๊ฐ„์„ ์ฆ๊ฐ€์‹œํ‚ฌ ์ˆ˜ ์žˆ๊ณ , ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ ์ „๋žต(์˜ˆ: ๊ฐ ํ…Œ์ŠคํŠธ๋งˆ๋‹ค ๊ณ ์œ ํ•œ Consumer Group ID ์‚ฌ์šฉ, ๋˜๋Š” ํ…Œ์ŠคํŠธ์šฉ ๋ณ„๋„ ํ† ํ”ฝ ์‚ฌ์šฉ)์ด ๋” ๋‚˜์€์ง€ ๊ฒ€ํ† ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

  • ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ productId ๋˜๋Š” orderId๋กœ ์„ค์ •ํ–ˆ๋Š”๋ฐ, ์ด๋กœ ์ธํ•œ ํŒŒํ‹ฐ์…˜ ๋ถˆ๊ท ํ˜• ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ํŠน์ • ์ƒํ’ˆ์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๊ฐ€ ๋งค์šฐ ๋งŽ์œผ๋ฉด ํ•ด๋‹น ์ƒํ’ˆ์˜ ํŒŒํ‹ฐ์…˜์—๋งŒ ๋ฉ”์‹œ์ง€๊ฐ€ ์ง‘์ค‘๋˜์–ด ๋‹ค๋ฅธ ํŒŒํ‹ฐ์…˜์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜๋Š” ๊ฒƒ์ด ๋” ์ค‘์š”ํ•˜๋‹ค๊ณ  ํŒ๋‹จํ–ˆ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ํŒŒํ‹ฐ์…˜ ๋ถˆ๊ท ํ˜•์ด ์‹ฌ๊ฐํ•œ ๋ฌธ์ œ๊ฐ€ ๋œ๋‹ค๋ฉด, ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ํ•ด์‹œ ํ•จ์ˆ˜๋กœ ๋ณ€ํ™˜ํ•˜๊ฑฐ๋‚˜, ๋ณตํ•ฉ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋“ฑ์˜ ๋ฐฉ์•ˆ์„ ๊ณ ๋ คํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  • ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ๋žœ๋ค ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ• ๊นŒ์š”? ์˜ˆ๋ฅผ ๋“ค์–ด, ๊ฐ™์€ ์ƒํ’ˆ์— ๋Œ€ํ•œ LikeAdded์™€ LikeRemoved ์ด๋ฒคํŠธ๊ฐ€ ์„œ๋กœ ๋‹ค๋ฅธ ํŒŒํ‹ฐ์…˜์— ๋ฐœํ–‰๋˜๋ฉด, Consumer๊ฐ€ LikeRemoved๋ฅผ ๋จผ์ € ์ฒ˜๋ฆฌํ•˜๊ณ  LikeAdded๋ฅผ ๋‚˜์ค‘์— ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๊ฒฝ์šฐ ์ข‹์•„์š” ์ˆ˜๊ฐ€ ์Œ์ˆ˜๊ฐ€ ๋˜๊ฑฐ๋‚˜ ์ž˜๋ชป๋œ ๋ฉ”ํŠธ๋ฆญ์ด ์ƒ์„ฑ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ™์€ aggregate root์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ํ•ญ์ƒ ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋˜๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.


๊ตฌํ˜„ ์„ธ๋ถ€์‚ฌํ•ญ

1. ๋‚ด๋ถ€ ์ด๋ฒคํŠธ์™€ ์™ธ๋ถ€ ์ด๋ฒคํŠธ์˜ ๊ตฌ๋ถ„

๋ฐฐ๊ฒฝ:
๊ธฐ์กด์—๋Š” JVM ๋‚ด์—์„œ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅํ•œ ๋А์Šจํ•œ ์—ฐ๊ฒฐ์„ ์œ„ํ•ด Spring Application Event๋ฅผ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œ ์žฌ๊ณ  ์ฐจ๊ฐ์ด๋‚˜ ํฌ์ธํŠธ ์ ๋ฆฝ ๋“ฑ์˜ ๋กœ์ง์€ ๊ฐ™์€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋‚ด์—์„œ ์ฒ˜๋ฆฌ๋˜๋ฏ€๋กœ Application Event๋กœ ์ถฉ๋ถ„ํ–ˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ด๋ฒˆ์—๋Š” ์™ธ๋ถ€ ์‹œ์Šคํ…œ(์ง‘๊ณ„ ์„œ๋น„์Šค)๊ณผ์˜ ํ†ต์‹ ์ด ํ•„์š”ํ•˜๋ฏ€๋กœ, JVM์„ ๋ฒ—์–ด๋‚˜ ๋„คํŠธ์›Œํฌ๋ฅผ ํ†ตํ•ด ๋ฉ”์‹œ์ง€๋ฅผ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋Š” Kafka๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

๋‚ด๋ถ€ ์ด๋ฒคํŠธ (Application Event):

  • ๊ฐ™์€ JVM ๋‚ด์—์„œ ์ฒ˜๋ฆฌ๋˜๋Š” ์ด๋ฒคํŠธ
  • ์˜ˆ: ์ฃผ๋ฌธ ์ƒ์„ฑ โ†’ ์žฌ๊ณ  ์ฐจ๊ฐ, ํฌ์ธํŠธ ์ ๋ฆฝ ๋“ฑ
  • Spring์˜ ApplicationEventPublisher๋ฅผ ํ†ตํ•ด ๋ฐœํ–‰
  • ๋™๊ธฐ ๋˜๋Š” ๋น„๋™๊ธฐ๋กœ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ
  • ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์ฒ˜๋ฆฌ๋˜๋ฏ€๋กœ ์ผ๊ด€์„ฑ ๋ณด์žฅ์ด ์ƒ๋Œ€์ ์œผ๋กœ ์‰ฌ์›€

์™ธ๋ถ€ ์ด๋ฒคํŠธ (Kafka Event):

  • ๋‹ค๋ฅธ ์„œ๋น„์Šค(์ง‘๊ณ„ ์„œ๋น„์Šค)๋กœ ์ „๋‹ฌ๋˜์–ด์•ผ ํ•˜๋Š” ์ด๋ฒคํŠธ
  • ์˜ˆ: ์ฃผ๋ฌธ ์ƒ์„ฑ โ†’ ํŒ๋งค๋Ÿ‰ ์ง‘๊ณ„, ์ข‹์•„์š” ์ถ”๊ฐ€ โ†’ ์ข‹์•„์š” ์ˆ˜ ์ง‘๊ณ„ ๋“ฑ
  • Kafka๋ฅผ ํ†ตํ•ด ๋„คํŠธ์›Œํฌ๋กœ ์ „๋‹ฌ
  • ๋น„๋™๊ธฐ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ, ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋‚˜ ์„œ๋น„์Šค ๋‹ค์šด ๋“ฑ์˜ ์ƒํ™ฉ์„ ๊ณ ๋ คํ•ด์•ผ ํ•จ
  • ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„๋ฅผ ๋„˜์–ด์„œ๋ฏ€๋กœ ์ผ๊ด€์„ฑ ๋ณด์žฅ์ด ๋ณต์žกํ•จ (Outbox ํŒจํ„ด ํ•„์š”)

๊ตฌ์กฐ:

๋„๋ฉ”์ธ ๋กœ์ง (์˜ˆ: ์ฃผ๋ฌธ ์ƒ์„ฑ)
    โ†“
ApplicationEvent ๋ฐœํ–‰ (JVM ๋‚ด๋ถ€)
    โ†“
OutboxBridgeEventListener (ApplicationEvent โ†’ OutboxEvent ๋ณ€ํ™˜)
    - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    - ๋„๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜๊ณผ ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜์—์„œ OutboxEvent ์ €์žฅ
    โ†“
OutboxEvent (DB ์ €์žฅ, PENDING ์ƒํƒœ)
    โ†“
OutboxEventPublisher (์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ PENDING ์ด๋ฒคํŠธ ์ฝ๊ธฐ)
    - @Scheduled(fixedDelay = 1000)
    - Kafka๋กœ ๋ฐœํ–‰ ํ›„ PUBLISHED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ
    โ†“
Kafka Topic (like-events, order-events, product-events)
    โ†“
ProductMetricsConsumer (์ง‘๊ณ„ ์„œ๋น„์Šค, ๋‹ค๋ฅธ JVM)
    - ์ด๋ฒคํŠธ ์ˆ˜์ทจ ๋ฐ ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„
    - event_handled ํ…Œ์ด๋ธ”๋กœ ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ
    - version ํ•„๋“œ๋กœ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜

๊ด€๋ จ ์ฝ”๋“œ:

// ๋‚ด๋ถ€ ์ด๋ฒคํŠธ: ApplicationEvent (JVM ๋‚ด๋ถ€)
applicationEventPublisher.publishEvent(new OrderEvent.OrderCreated(...));

// ์™ธ๋ถ€ ์ด๋ฒคํŠธ: ApplicationEvent โ†’ OutboxEvent โ†’ Kafka
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderEvent.OrderCreated event) {
    outboxEventService.saveEvent(/* OutboxEvent๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ DB ์ €์žฅ */);
}

@Scheduled(fixedDelay = 1000)
public void publishPendingEvents() {
    // PENDING ์ด๋ฒคํŠธ๋ฅผ ์ฝ์–ด Kafka๋กœ ๋ฐœํ–‰
}

2. Producer ์„ค์ •: At Least Once ๋ณด์žฅ ๋ฐ ์ด๋ฒคํŠธ ์ƒ์„ฑ ์‹คํŒจ ์ฒ˜๋ฆฌ

๋ฐฐ๊ฒฝ:
์™ธ๋ถ€ ์‹œ์Šคํ…œ๊ณผ์˜ ํ†ต์‹ ์ด๊ธฐ ๋•Œ๋ฌธ์— DB์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ์ง์ ‘ ์กฐํšŒํ•˜๊ฑฐ๋‚˜, ๊ธฐ์กด์˜ ์„œ๋น„์Šค ๋กœ์ง์„ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒ€์ฆ์ฒ˜๋ฆฌํ•˜๊ธฐ๋Š” ์–ด๋ ค์šด ์ƒํ™ฉ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์ด๋ฒคํŠธ publisher ์ž์ฒด์˜ ์„ค์ •์„ ํ†ตํ•ด ๋ฉ”์‹œ์ง€ ์œ ์‹ค์„ ๋ฐฉ์ง€ํ•˜๊ณ , Consumer ์ธก์—์„œ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ๋ฅผ ํ†ตํ•ด ์ค‘๋ณต์„ ๋ฐฉ์ง€ํ•˜๋Š” ๊ตฌ์กฐ๋กœ ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

์„ค์ • ๋‚ด์šฉ:

  • acks=all: Producer๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐœํ–‰ํ•  ๋•Œ, ๋ชจ๋“  ๋ฆฌํ”Œ๋ฆฌ์นด์— ์“ฐ๊ธฐ๊ฐ€ ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋ฆฌํ”Œ๋ฆฌ์นด ์ค‘ ํ•˜๋‚˜๊ฐ€ ์‹คํŒจํ•˜๋”๋ผ๋„ ๋‹ค๋ฅธ ๋ฆฌํ”Œ๋ฆฌ์นด์— ๋ฉ”์‹œ์ง€๊ฐ€ ์ €์žฅ๋˜์–ด ์œ ์‹ค์„ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • enable.idempotence=true: Producer๊ฐ€ ๋™์ผํ•œ ๋ฉ”์‹œ์ง€๋ฅผ ์—ฌ๋Ÿฌ ๋ฒˆ ๋ฐœํ–‰ํ•˜๋”๋ผ๋„ Kafka ๋ธŒ๋กœ์ปค๊ฐ€ ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜์—ฌ ํ•œ ๋ฒˆ๋งŒ ์ €์žฅํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋กœ ์ธํ•œ ์žฌ์‹œ๋„ ์‹œ ์ค‘๋ณต ๋ฉ”์‹œ์ง€๊ฐ€ ์ €์žฅ๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.
  • max.in.flight.requests.per.connection=5: idempotence=true์ผ ๋•Œ ํ•„์ˆ˜ ์„ค์ •์ž…๋‹ˆ๋‹ค. ๋™์‹œ์— ์ „์†กํ•  ์ˆ˜ ์žˆ๋Š” ๋ฏธํ™•์ธ ์š”์ฒญ์˜ ์ตœ๋Œ€ ๊ฐœ์ˆ˜๋ฅผ ์ œํ•œํ•ฉ๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

# modules/kafka/src/main/resources/kafka.yml
spring:
  kafka:
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      retries: 3
      properties:
        acks: all                    # ๋ชจ๋“  ๋ฆฌํ”Œ๋ฆฌ์นด์— ์“ฐ๊ธฐ ํ™•์ธ (At Least Once ๋ณด์žฅ)
        enable.idempotence: true     # ์ค‘๋ณต ๋ฐฉ์ง€ (At Least Once ๋ณด์žฅ)
        max.in.flight.requests.per.connection: 5  # idempotence=true์ผ ๋•Œ ํ•„์ˆ˜

๊ณ ๋ฏผํ•œ ์ :

  • acks=all์€ ๋ฉ”์‹œ์ง€ ์œ ์‹ค์„ ๋ฐฉ์ง€ํ•˜์ง€๋งŒ, ๋ชจ๋“  ๋ฆฌํ”Œ๋ฆฌ์นด์— ์“ฐ๊ธฐ๊ฐ€ ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•˜๋ฏ€๋กœ ์ง€์—ฐ ์‹œ๊ฐ„์ด ์ฆ๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋ฉ”์‹œ์ง€ ์œ ์‹ค์„ ๋ฐฉ์ง€ํ•˜๋Š” ๊ฒƒ์ด ๋” ์ค‘์š”ํ•˜๋‹ค๊ณ  ํŒ๋‹จํ•˜์—ฌ acks=all์„ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค.
  • enable.idempotence=true๋Š” Producer ์ธก์—์„œ ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜์ง€๋งŒ, ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋‚˜ Consumer ์žฌ์‹œ์ž‘ ๋“ฑ์˜ ์ƒํ™ฉ์—์„œ ๋™์ผํ•œ ๋ฉ”์‹œ์ง€๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ์ „๋‹ฌ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ Consumer ์ธก์—์„œ๋„ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

3. Consumer ์„ค์ •: Manual Ack ์ฒ˜๋ฆฌ

๋ฐฐ๊ฒฝ:
Consumer๊ฐ€ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ณผ์ •์—์„œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ์ž๋™ ์ปค๋ฐ‹์„ ์‚ฌ์šฉํ•˜๋ฉด, ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์ „์— offset์ด ์ปค๋ฐ‹๋˜์–ด ์ด๋ฒคํŠธ๊ฐ€ ์œ ์‹ค๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ˜๋Œ€๋กœ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹คํŒจ ์‹œ์—๋„ offset์ด ์ปค๋ฐ‹๋˜์ง€ ์•Š๋„๋ก ํ•˜์—ฌ ์žฌ์ฒ˜๋ฆฌ๊ฐ€ ๊ฐ€๋Šฅํ•˜๋„๋ก ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์„ค์ • ๋‚ด์šฉ:

  • enable-auto-commit: false: ์ž๋™ ์ปค๋ฐ‹์„ ๋น„ํ™œ์„ฑํ™”ํ•˜์—ฌ ์ˆ˜๋™ ์ปค๋ฐ‹์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
  • ack-mode: manual: ์ˆ˜๋™ ์ปค๋ฐ‹ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ, ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์„ฑ๊ณต ํ›„์—๋งŒ Acknowledgment.acknowledge()๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ offset์„ ์ปค๋ฐ‹ํ•ฉ๋‹ˆ๋‹ค.

์ฒ˜๋ฆฌ ํ๋ฆ„:

  1. Consumer๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ์ˆ˜์‹ ํ•ฉ๋‹ˆ๋‹ค.
  2. ๊ฐ ๋ฉ”์‹œ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค (๋ฉฑ๋“ฑ์„ฑ ์ฒดํฌ, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์‹คํ–‰, event_handled ํ…Œ์ด๋ธ”์— ๊ธฐ๋ก).
  3. ๋ชจ๋“  ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ ์™„๋ฃŒ ํ›„ acknowledgment.acknowledge()๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ offset์„ ์ปค๋ฐ‹ํ•ฉ๋‹ˆ๋‹ค.
  4. ๋งŒ์•ฝ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด acknowledgment.acknowledge()๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์•„ offset์ด ์ปค๋ฐ‹๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ, Consumer๊ฐ€ ์žฌ์‹œ์ž‘๋˜๊ฑฐ๋‚˜ ๋‹ค์Œ poll ์‹œ ๋™์ผํ•œ ๋ฉ”์‹œ์ง€๋ฅผ ๋‹ค์‹œ ๋ฐ›์•„ ์žฌ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

# modules/kafka/src/main/resources/kafka.yml
spring:
  kafka:
    consumer:
      group-id: loopers-default-consumer
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.ByteArrayDeserializer
      properties:
        enable-auto-commit: false    # ์ž๋™ ์ปค๋ฐ‹ ๋น„ํ™œ์„ฑํ™”
    listener:
      ack-mode: manual                # ์ˆ˜๋™ ์ปค๋ฐ‹ ๋ชจ๋“œ
// ProductMetricsConsumer.java - ์ฒ˜๋ฆฌ ์„ฑ๊ณต ํ›„์—๋งŒ ์ˆ˜๋™ ์ปค๋ฐ‹
@KafkaListener(topics = "like-events")
public void consumeLikeEvents(..., Acknowledgment acknowledgment) {
    // ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง
    acknowledgment.acknowledge();  // ์„ฑ๊ณต ์‹œ์—๋งŒ ์ปค๋ฐ‹
}

๊ณ ๋ฏผํ•œ ์ :

  • Manual Ack๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์„ฑ๊ณต ํ›„์—๋งŒ offset์ด ์ปค๋ฐ‹๋˜๋ฏ€๋กœ, ์ฒ˜๋ฆฌ ์‹คํŒจ ์‹œ ์žฌ์ฒ˜๋ฆฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ Consumer๊ฐ€ ์žฌ์‹œ์ž‘๋˜๊ฑฐ๋‚˜ ์žฅ์• ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์ฒ˜๋ฆฌ ์ค‘์ด๋˜ ๋ฉ”์‹œ์ง€๋“ค์ด ๋‹ค์‹œ ์ฒ˜๋ฆฌ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์ˆ˜์ ์ž…๋‹ˆ๋‹ค.
  • ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ์‹œ ๋ชจ๋“  ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ ์™„๋ฃŒ ํ›„ ํ•œ ๋ฒˆ์— ์ปค๋ฐ‹ํ•˜๋Š” ๋ฐฉ์‹๊ณผ, ๊ฐ ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ ํ›„ ๊ฐœ๋ณ„์ ์œผ๋กœ ์ปค๋ฐ‹ํ•˜๋Š” ๋ฐฉ์‹ ์ค‘ ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ๋Š” ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ํ›„ ํ•œ ๋ฒˆ์— ์ปค๋ฐ‹ํ•˜๋Š” ๋ฐฉ์‹์„ ์‚ฌ์šฉํ–ˆ๋Š”๋ฐ, ์ด๋Š” ์„ฑ๋Šฅ์ƒ ์œ ๋ฆฌํ•˜์ง€๋งŒ ์ผ๋ถ€ ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ ์‹คํŒจ ์‹œ ์ „์ฒด ๋ฐฐ์น˜๊ฐ€ ์žฌ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค. ๊ฐœ๋ณ„ ์ปค๋ฐ‹ ๋ฐฉ์‹์€ ์„ฑ๋Šฅ์ด ๋–จ์–ด์ง€์ง€๋งŒ ์‹คํŒจํ•œ ๋ฉ”์‹œ์ง€๋งŒ ์žฌ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

4. ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ: event_handled ํ…Œ์ด๋ธ”

๋ฐฐ๊ฒฝ:
Kafka์˜ At Least Once ๋ณด์žฅ๊ณผ Manual Ack ์ฒ˜๋ฆฌ๋กœ ์ธํ•ด, ๋™์ผํ•œ ์ด๋ฒคํŠธ๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ์ „๋‹ฌ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ Consumer๊ฐ€ ์—ฌ๋Ÿฌ ์ธ์Šคํ„ด์Šค๋กœ ์‹คํ–‰๋˜๋Š” ๊ฒฝ์šฐ, ๋™์‹œ์— ๊ฐ™์€ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋ ค๊ณ  ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์ƒํ™ฉ์—์„œ ์ค‘๋ณต ์ฒ˜๋ฆฌ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด event_handled ํ…Œ์ด๋ธ”์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ตฌํ˜„ ๋ฐฉ์‹:

  • ๊ฐ ์ด๋ฒคํŠธ์— ๊ณ ์œ ํ•œ UUID ๊ธฐ๋ฐ˜ eventId๋ฅผ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค.
  • Consumer์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์ „์— event_handled ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹น eventId๊ฐ€ ์ด๋ฏธ ์ฒ˜๋ฆฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ๊ฒฝ์šฐ ์Šคํ‚ตํ•˜๊ณ , ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ์—๋งŒ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์‹คํ–‰ํ•œ ํ›„ event_handled ํ…Œ์ด๋ธ”์— ๊ธฐ๋ก์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • event_handled ํ…Œ์ด๋ธ”์˜ event_id ์ปฌ๋Ÿผ์— UNIQUE ์ œ์•ฝ์กฐ๊ฑด์„ ์„ค์ •ํ•˜์—ฌ, ๋™์‹œ์„ฑ ์ƒํ™ฉ์—์„œ๋„ ์ค‘๋ณต ์ฒ˜๋ฆฌ๋ฅผ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

// EventHandled.java - eventId์— UNIQUE ์ œ์•ฝ์กฐ๊ฑด
@Entity
public class EventHandled {
    @Id
    @Column(unique = true)
    private String eventId;  // UNIQUE ์ œ์•ฝ์กฐ๊ฑด์œผ๋กœ ๋™์‹œ์„ฑ ๋ณด์žฅ
}

// ProductMetricsConsumer.java - ์ค‘๋ณต ์ฒดํฌ
if (eventHandledService.isAlreadyHandled(eventId)) continue;
productMetricsService.incrementLikeCount(...);
eventHandledService.markAsHandled(eventId, ...);  // UNIQUE ์ œ์•ฝ์กฐ๊ฑด์œผ๋กœ ์ค‘๋ณต ๋ฐฉ์ง€

๊ณ ๋ฏผํ•œ ์ :

  • event_handled ํ…Œ์ด๋ธ”์„ DB๋กœ ๊ตฌํ˜„ํ–ˆ์ง€๋งŒ, Redis๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ๋„ ๊ณ ๋ คํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Redis๋Š” TTL์„ ์‰ฝ๊ฒŒ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๊ณ  ์กฐํšŒ ์„ฑ๋Šฅ์ด ๋น ๋ฅด์ง€๋งŒ, ์˜์†์„ฑ์ด ๋ณด์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. DB๋Š” ์˜์†์„ฑ์ด ๋ณด์žฅ๋˜์ง€๋งŒ TTL ์„ค์ •์ด ๋ณต์žกํ•˜๊ณ  ์กฐํšŒ ์„ฑ๋Šฅ์ด ์ƒ๋Œ€์ ์œผ๋กœ ๋А๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ๋Š” DB๋ฅผ ์„ ํƒํ–ˆ๋Š”๋ฐ, ์ด๋Š” ์˜์†์„ฑ์ด ์ค‘์š”ํ•˜๊ณ  TTL์€ ๋ณ„๋„ ์•„์นด์ด๋น™ ์ „๋žต์œผ๋กœ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค๊ณ  ํŒ๋‹จํ–ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • event_handled ํ…Œ์ด๋ธ”์ด ๊ณ„์† ์ฆ๊ฐ€ํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด์„œ ์ด ํ…Œ์ด๋ธ”์˜ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฌดํ•œ์ • ์ฆ๊ฐ€ํ•˜๊ฒŒ ๋˜๋Š”๋ฐ, ์ด๋Š” ์Šคํ† ๋ฆฌ์ง€ ๋น„์šฉ๊ณผ ์กฐํšŒ ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. TTL์„ ์„ค์ •ํ•˜์—ฌ ์ผ์ • ๊ธฐ๊ฐ„์ด ์ง€๋‚œ ๋ ˆ์ฝ”๋“œ๋ฅผ ์ž๋™์œผ๋กœ ์‚ญ์ œํ•˜๊ฑฐ๋‚˜, ์•„์นด์ด๋น™ ์ „๋žต์„ ์ˆ˜๋ฆฝํ•˜์—ฌ ์˜ค๋ž˜๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ„๋„ ํ…Œ์ด๋ธ”๋กœ ์ด๋™์‹œํ‚ค๋Š” ๋“ฑ์˜ ๋ฐฉ์•ˆ์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

5. ๋ฒ„์ „ ๊ธฐ๋ฐ˜ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜

๋ฐฐ๊ฒฝ:
๋„คํŠธ์›Œํฌ ์ง€์—ฐ์ด๋‚˜ ํŒŒํ‹ฐ์…˜ ์ˆœ์„œ ๋ฌธ์ œ๋กœ ์ธํ•ด, ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ ์ˆœ์„œ์™€ Consumer๊ฐ€ ๋ฐ›๋Š” ์ˆœ์„œ๊ฐ€ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, version=3 ์ด๋ฒคํŠธ๊ฐ€ ๋จผ์ € ๋„์ฐฉํ•˜๊ณ  version=2 ์ด๋ฒคํŠธ๊ฐ€ ๋‚˜์ค‘์— ๋„์ฐฉํ•˜๋Š” ๊ฒฝ์šฐ, version=2 ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋ฉด ์ด๋ฏธ version=3์œผ๋กœ ์—…๋ฐ์ดํŠธ๋œ ๋ฉ”ํŠธ๋ฆญ์„ ๋ฎ์–ด์“ฐ๊ฒŒ ๋˜์–ด ์ž˜๋ชป๋œ ์ƒํƒœ๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:

  • OutboxEvent์— aggregateId๋ณ„๋กœ ์ˆœ์ฐจ์ ์œผ๋กœ ์ฆ๊ฐ€ํ•˜๋Š” version ํ•„๋“œ๋ฅผ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค.
  • ์ด version์€ Kafka ๋ฉ”์‹œ์ง€ ํ—ค๋”์— ํฌํ•จ๋˜์–ด Consumer๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค.
  • Consumer์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•  ๋•Œ, ProductMetrics์˜ ํ˜„์žฌ version๊ณผ ์ด๋ฒคํŠธ์˜ version์„ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ฒคํŠธ์˜ version์ด ๋ฉ”ํŠธ๋ฆญ์˜ version๋ณด๋‹ค ํฌ๋ฉด ์—…๋ฐ์ดํŠธํ•˜๊ณ , ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ์Šคํ‚ตํ•ฉ๋‹ˆ๋‹ค.

๊ตฌํ˜„ ๋ฐฉ์‹:

  • OutboxEventService.saveEvent()์—์„œ aggregateId๋ณ„ ์ตœ์‹  ๋ฒ„์ „์„ ์กฐํšŒํ•œ ํ›„ +1ํ•˜์—ฌ ์ƒˆ๋กœ์šด ๋ฒ„์ „์„ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค.
  • ProductMetrics์—๋„ version ํ•„๋“œ๋ฅผ ๋‘๊ณ , ์—…๋ฐ์ดํŠธํ•  ๋•Œ๋งˆ๋‹ค ์ฆ๊ฐ€์‹œํ‚ต๋‹ˆ๋‹ค.
  • ProductMetrics.shouldUpdate() ๋ฉ”์„œ๋“œ๋กœ ์ด๋ฒคํŠธ ๋ฒ„์ „๊ณผ ๋ฉ”ํŠธ๋ฆญ ๋ฒ„์ „์„ ๋น„๊ตํ•˜์—ฌ, ์ด๋ฒคํŠธ๊ฐ€ ์ตœ์‹ ์ธ ๊ฒฝ์šฐ์—๋งŒ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค.

์ „์ฒด ํ”Œ๋กœ์šฐ ๋‹ค์ด์–ด๊ทธ๋žจ:

[๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ๋ฐœ์ƒ]
    โ†“
[OutboxBridgeEventListener]
    โ†“
[OutboxEventService.saveEvent()]
    โ”œโ”€ aggregateId๋ณ„ ์ตœ์‹  ๋ฒ„์ „ ์กฐํšŒ (DB)
    โ”œโ”€ ๋ฒ„์ „ +1 ๊ณ„์‚ฐ
    โ””โ”€ OutboxEvent์— version ์ €์žฅ (DB)
    โ†“
[OutboxEventPublisher (์Šค์ผ€์ค„๋Ÿฌ, 1์ดˆ๋งˆ๋‹ค)]
    โ”œโ”€ PENDING ์ด๋ฒคํŠธ ์กฐํšŒ
    โ”œโ”€ Kafka ๋ฉ”์‹œ์ง€ ํ—ค๋”์— version ์ถ”๊ฐ€
    โ””โ”€ Kafka๋กœ ๋ฐœํ–‰
    โ†“
[ProductMetricsConsumer]
    โ”œโ”€ Kafka ํ—ค๋”์—์„œ version ์ถ”์ถœ
    โ”œโ”€ ProductMetricsService์— version ์ „๋‹ฌ
    โ””โ”€ version ๋น„๊ต๋กœ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜

ํ•ต์‹ฌ ์ฝ”๋“œ ์œ„์น˜:

1) ๋ฒ„์ „ ์ƒ์„ฑ: OutboxEventService.saveEvent() (59-60์ค„)

// ์ง‘๊ณ„ ID๋ณ„ ์ตœ์‹  ๋ฒ„์ „ ์กฐํšŒ ํ›„ +1
Long latestVersion = outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType);
Long nextVersion = latestVersion + 1L;

2) Kafka ํ—ค๋”์— ์ถ”๊ฐ€: OutboxEventPublisher.publishEvent() (99-102์ค„)

// version์ด ์žˆ์œผ๋ฉด ํ—ค๋”์— ์ถ”๊ฐ€
if (event.getVersion() != null) {
    messageBuilder.setHeader("version", event.getVersion());
}

3) Consumer์—์„œ ์ถ”์ถœ: ProductMetricsConsumer.extractVersion() (374-387์ค„)

// Kafka ํ—ค๋”์—์„œ version ์ถ”์ถœ
Header header = record.headers().lastHeader(VERSION_HEADER);
return Long.parseLong(new String(header.value(), StandardCharsets.UTF_8));

4) ๋ฒ„์ „ ๋น„๊ต: ProductMetricsService์—์„œ eventVersion๊ณผ metrics.version ๋น„๊ตํ•˜์—ฌ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜

์ด๋ ‡๊ฒŒ aggregateId๋ณ„๋กœ ์ˆœ์ฐจ์ ์ธ ๋ฒ„์ „์ด ์ƒ์„ฑ๋˜์–ด Kafka ํ—ค๋”๋กœ ์ „๋‹ฌ๋˜๊ณ , Consumer์—์„œ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜ํ•˜๋Š” ๋ฐ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

// OutboxEventService.java - aggregateId๋ณ„ ์ˆœ์ฐจ์  version ๋ถ€์—ฌ
Long nextVersion = findLatestVersionByAggregateId(...) + 1L;

// ProductMetrics.java - version ๋น„๊ต๋กœ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜
public boolean shouldUpdate(Long eventVersion) {
    return eventVersion > this.version;  // ์ด๋ฒคํŠธ ๋ฒ„์ „์ด ๋” ํฌ๋ฉด ์—…๋ฐ์ดํŠธ
}

// ProductMetricsService.java
if (!metrics.shouldUpdate(eventVersion)) return;  // ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ ์Šคํ‚ต
metrics.incrementLikeCount();

๊ณ ๋ฏผํ•œ ์ :

  • version ํ•„๋“œ๋Š” aggregateId๋ณ„๋กœ ์ž๋™ ์ฆ๊ฐ€ํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ๋Š”๋ฐ, ์ด ๋ฐฉ์‹์˜ ์žฅ์ ์€ ๊ฐ„๋‹จํ•˜๊ณ  ์ˆœ์ฐจ์ ์ธ ๋ฒ„์ „ ๊ด€๋ฆฌ๊ฐ€ ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ updatedAt ๊ธฐ๋ฐ˜ ๋ฐฉ์‹๊ณผ ๋น„๊ตํ–ˆ์„ ๋•Œ, updatedAt์€ ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜์ด๋ฏ€๋กœ ๋„คํŠธ์›Œํฌ ์ง€์—ฐ์ด๋‚˜ ์‹œ์Šคํ…œ ์‹œ๊ฐ„ ๋ถˆ์ผ์น˜ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด version์€ ์ˆœ์ฐจ์ ์œผ๋กœ ์ฆ๊ฐ€ํ•˜๋ฏ€๋กœ ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ, aggregateId๋ณ„๋กœ ๋ณ„๋„์˜ ๋ฒ„์ „์„ ๊ด€๋ฆฌํ•ด์•ผ ํ•˜๋ฏ€๋กœ ๋ณต์žก๋„๊ฐ€ ์ฆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
  • version ํ•„๋“œ๊ฐ€ aggregateId๋ณ„๋กœ ๊ด€๋ฆฌ๋˜๋ฏ€๋กœ, ๊ฐ™์€ aggregateId์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ์ˆœ์ฐจ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ aggregateId๋กœ ์„ค์ •ํ–ˆ์œผ๋ฏ€๋กœ, ๊ฐ™์€ aggregateId์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋˜๋ฏ€๋กœ ์ด ๋ฌธ์ œ๋Š” ํ•ด๊ฒฐ๋ฉ๋‹ˆ๋‹ค.

6. ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •

๋ฐฐ๊ฒฝ:
Kafka๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ํŒŒํ‹ฐ์…˜ ๋‚ด์—์„œ๋งŒ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜๊ณ , ์„œ๋กœ ๋‹ค๋ฅธ ํŒŒํ‹ฐ์…˜ ๊ฐ„์˜ ์ˆœ์„œ๋Š” ๋ณด์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๊ฐ™์€ aggregate root์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ฒ˜๋ฆฌ๋˜์–ด์•ผ ์ˆœ์„œ๊ฐ€ ๋ณด์žฅ๋ฉ๋‹ˆ๋‹ค.

7. ํผ๋ธ”๋ฆฌ์…”์™€ ์ปจ์Šˆ๋จธ๊ฐ€ ๋™์ผ ์ด๋ฒคํŠธ๋ฅผ ํŒ๋‹จํ•˜๋Š” ๊ธฐ์ค€ ์„ค์ •

๋ฐฐ๊ฒฝ ๋ฐ ๋ฌธ์ œ ์ƒํ™ฉ:
aggregate root id(์˜ˆ: productId, orderId)๋งŒ์œผ๋กœ๋Š” ํ•ด๋‹น event๊ฐ€ ๋™์ผํ•œ ํ•ญ๋ชฉ์„ ์ง€์ •ํ•˜๋Š”์ง€ ๋ณด์žฅํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๊ฐ™์€ productId์— ๋Œ€ํ•ด ์—ฌ๋Ÿฌ ๋ฒˆ ์ข‹์•„์š”๊ฐ€ ์ถ”๊ฐ€๋˜๋ฉด, ๊ฐ๊ฐ์€ ์„œ๋กœ ๋‹ค๋ฅธ ์ด๋ฒคํŠธ์ด์ง€๋งŒ productId๋งŒ์œผ๋กœ๋Š” ๊ตฌ๋ถ„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋‚˜ Consumer ์žฌ์‹œ์ž‘์œผ๋กœ ์ธํ•ด ๋™์ผํ•œ ์ด๋ฒคํŠธ๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ์ „๋‹ฌ๋  ์ˆ˜ ์žˆ๋Š”๋ฐ, productId๋งŒ์œผ๋กœ๋Š” ์ด๊ฒƒ์ด ์ค‘๋ณต์ธ์ง€ ์ƒˆ๋กœ์šด ์ด๋ฒคํŠธ์ธ์ง€ ํŒ๋‹จํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ์•ˆ:
์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋‘ ๊ฐ€์ง€ ์‹๋ณ„์ž๋ฅผ ์กฐํ•ฉํ•˜์—ฌ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:

  1. eventId (UUID): ๊ฐ ์ด๋ฒคํŠธ์— ๊ณ ์œ ํ•œ UUID๋ฅผ ๋ถ€์—ฌํ•˜์—ฌ, ๋™์ผํ•œ ์ด๋ฒคํŠธ๋Š” ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ๋  ์ˆ˜ ์žˆ๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.
  2. version: ๊ฐ™์€ aggregate root์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ์˜ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜๊ณ , ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๊ฐ€ ์ตœ์‹  ์ƒํƒœ๋ฅผ ๋ฎ์–ด์“ฐ๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.

๊ตฌํ˜„ ์„ธ๋ถ€์‚ฌํ•ญ:

1) eventId๋ฅผ ํ†ตํ•œ ๋™์ผ ์ด๋ฒคํŠธ ํŒ๋‹จ:

  • OutboxEventService.saveEvent()์—์„œ ๊ฐ ์ด๋ฒคํŠธ์— UUID ๊ธฐ๋ฐ˜์˜ ๊ณ ์œ ํ•œ eventId๋ฅผ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค.
  • ์ด eventId๋Š” Kafka ๋ฉ”์‹œ์ง€ ํ—ค๋”์— ํฌํ•จ๋˜์–ด Consumer๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค.
  • Consumer์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์ „์— event_handled ํ…Œ์ด๋ธ”์—์„œ ํ•ด๋‹น eventId๊ฐ€ ์ด๋ฏธ ์ฒ˜๋ฆฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ๊ฒฝ์šฐ ์Šคํ‚ตํ•˜๊ณ , ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ์—๋งŒ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์‹คํ–‰ํ•œ ํ›„ event_handled ํ…Œ์ด๋ธ”์— ๊ธฐ๋ก์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋™์ผํ•œ ์ด๋ฒคํŠธ๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ์ „๋‹ฌ๋˜๋”๋ผ๋„ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

2) version์„ ํ†ตํ•œ ๋ถˆํ•„์š”ํ•œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐฉ์ง€:

  • OutboxEventService.saveEvent()์—์„œ aggregateId๋ณ„๋กœ ์ˆœ์ฐจ์ ์œผ๋กœ ์ฆ๊ฐ€ํ•˜๋Š” version์„ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค.
  • ์ด version์€ Kafka ๋ฉ”์‹œ์ง€ ํ—ค๋”์— ํฌํ•จ๋˜์–ด Consumer๋กœ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค.
  • Consumer์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•  ๋•Œ, ProductMetrics์˜ ํ˜„์žฌ version๊ณผ ์ด๋ฒคํŠธ์˜ version์„ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ฒคํŠธ์˜ version์ด ๋ฉ”ํŠธ๋ฆญ์˜ version๋ณด๋‹ค ํฌ๋ฉด ์—…๋ฐ์ดํŠธํ•˜๊ณ , ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ์Šคํ‚ตํ•ฉ๋‹ˆ๋‹ค.
  • ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋„คํŠธ์›Œํฌ ์ง€์—ฐ์ด๋‚˜ ํŒŒํ‹ฐ์…˜ ์ˆœ์„œ ๋ฌธ์ œ๋กœ ์ธํ•ด ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๊ฐ€ ๋‚˜์ค‘์— ๋„์ฐฉํ•˜๋”๋ผ๋„, ์ด๋ฏธ ๋” ์ตœ์‹  ๋ฒ„์ „์˜ ๋ฉ”ํŠธ๋ฆญ์ด ์กด์žฌํ•˜๋ฉด ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๋Š” ๋ฌด์‹œ๋ฉ๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

// OutboxEventService.java - eventId(UUID)์™€ version(aggregateId๋ณ„ ์ˆœ์ฐจ ์ฆ๊ฐ€) ๋ถ€์—ฌ
String eventId = UUID.randomUUID().toString();
Long nextVersion = findLatestVersionByAggregateId(...) + 1L;

// OutboxEventPublisher.java - Kafka ํ—ค๋”์— eventId์™€ version ํฌํ•จ
messageBuilder.setHeader("eventId", event.getEventId())
              .setHeader("version", event.getVersion());

// ProductMetricsConsumer.java - eventId๋กœ ์ค‘๋ณต ์ฒดํฌ, version์œผ๋กœ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜
String eventId = extractEventId(record);
if (eventHandledService.isAlreadyHandled(eventId)) continue;
Long eventVersion = extractVersion(record);
productMetricsService.incrementLikeCount(productId, eventVersion);  // version ๋น„๊ต ํฌํ•จ

๊ณ ๋ฏผํ•œ ์ :

  • eventId์™€ version์„ ๋ชจ๋‘ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ค‘๋ณต์ผ ์ˆ˜ ์žˆ๋‹ค๋Š” ์˜๋ฌธ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋‘ ๊ฐ€์ง€๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ๋ชฉ์ ์„ ๊ฐ€์ง€๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. eventId๋Š” ๋™์ผํ•œ ์ด๋ฒคํŠธ์˜ ์™„์ „ ์ค‘๋ณต์„ ๋ฐฉ์ง€ํ•˜๊ณ , version์€ ์ˆœ์„œ๊ฐ€ ๋’ค๋ฐ”๋€ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋„คํŠธ์›Œํฌ ๋ฌธ์ œ๋กœ ์ธํ•ด version=3 ์ด๋ฒคํŠธ๊ฐ€ ๋จผ์ € ๋„์ฐฉํ•˜๊ณ  version=2 ์ด๋ฒคํŠธ๊ฐ€ ๋‚˜์ค‘์— ๋„์ฐฉํ•˜๋Š” ๊ฒฝ์šฐ, eventId๋กœ๋Š” ์ค‘๋ณต์„ ๊ฐ์ง€ํ•  ์ˆ˜ ์—†์ง€๋งŒ version์œผ๋กœ๋Š” ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ์ž„์„ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ˜๋Œ€๋กœ, ๋™์ผํ•œ ์ด๋ฒคํŠธ๊ฐ€ ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋กœ ์ธํ•ด ์—ฌ๋Ÿฌ ๋ฒˆ ์ „๋‹ฌ๋˜๋Š” ๊ฒฝ์šฐ, version์œผ๋กœ๋Š” ์ค‘๋ณต์„ ๊ฐ์ง€ํ•  ์ˆ˜ ์—†์ง€๋งŒ eventId๋กœ๋Š” ์ค‘๋ณต์„ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  • aggregateId๋งŒ์œผ๋กœ๋Š” ๋™์ผ ์ด๋ฒคํŠธ๋ฅผ ํŒ๋‹จํ•  ์ˆ˜ ์—†๋Š” ์ด์œ ๋Š”, ๊ฐ™์€ aggregateId์— ๋Œ€ํ•ด ์—ฌ๋Ÿฌ ๋ฒˆ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๊ฐ™์€ ์ƒํ’ˆ์— ๋Œ€ํ•ด ์ข‹์•„์š”๊ฐ€ ์—ฌ๋Ÿฌ ๋ฒˆ ์ถ”๊ฐ€๋˜๋ฉด, ๊ฐ๊ฐ์€ ์„œ๋กœ ๋‹ค๋ฅธ ์ด๋ฒคํŠธ์ด์ง€๋งŒ productId๋งŒ์œผ๋กœ๋Š” ๊ตฌ๋ถ„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๊ฐ ์ด๋ฒคํŠธ์— ๊ณ ์œ ํ•œ eventId๋ฅผ ๋ถ€์—ฌํ•˜์—ฌ ๊ตฌ๋ถ„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  • version์€ aggregateId๋ณ„๋กœ ๊ด€๋ฆฌ๋˜๋ฏ€๋กœ, ๊ฐ™์€ aggregateId์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ์ˆœ์ฐจ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ aggregateId๋กœ ์„ค์ •ํ–ˆ์œผ๋ฏ€๋กœ, ๊ฐ™์€ aggregateId์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋˜๋ฏ€๋กœ ์ด ๋ฌธ์ œ๋Š” ํ•ด๊ฒฐ๋ฉ๋‹ˆ๋‹ค.

6. ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •

๋ฐฐ๊ฒฝ:
Kafka๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ํŒŒํ‹ฐ์…˜ ๋‚ด์—์„œ๋งŒ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜๊ณ , ์„œ๋กœ ๋‹ค๋ฅธ ํŒŒํ‹ฐ์…˜ ๊ฐ„์˜ ์ˆœ์„œ๋Š” ๋ณด์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๊ฐ™์€ aggregate root์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ฒ˜๋ฆฌ๋˜์–ด์•ผ ์ˆœ์„œ๊ฐ€ ๋ณด์žฅ๋ฉ๋‹ˆ๋‹ค.

์„ค์ • ๋‚ด์šฉ:

  • like-events, product-events: productId๋ฅผ ํŒŒํ‹ฐ์…˜ ํ‚ค๋กœ ์‚ฌ์šฉํ•˜์—ฌ, ๊ฐ™์€ ์ƒํ’ˆ์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ํ•ญ์ƒ ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.
  • order-events: orderId๋ฅผ ํŒŒํ‹ฐ์…˜ ํ‚ค๋กœ ์‚ฌ์šฉํ•˜์—ฌ, ๊ฐ™์€ ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ํ•ญ์ƒ ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

๊ด€๋ จ ์ฝ”๋“œ:

// OutboxBridgeEventListener.java - ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •
outboxEventService.saveEvent(..., partitionKey: productId.toString());  // like-events, product-events
outboxEventService.saveEvent(..., partitionKey: orderId.toString());     // order-events

// OutboxEventPublisher.java - Kafka ๋ฉ”์‹œ์ง€์— ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •
messageBuilder.setHeader(KafkaHeaders.KEY, event.getPartitionKey());

๊ณ ๋ฏผํ•œ ์ :

  • ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ productId ๋˜๋Š” orderId๋กœ ์„ค์ •ํ–ˆ๋Š”๋ฐ, ์ด๋กœ ์ธํ•œ ํŒŒํ‹ฐ์…˜ ๋ถˆ๊ท ํ˜• ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ํŠน์ • ์ƒํ’ˆ์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๊ฐ€ ๋งค์šฐ ๋งŽ์œผ๋ฉด ํ•ด๋‹น ์ƒํ’ˆ์˜ ํŒŒํ‹ฐ์…˜์—๋งŒ ๋ฉ”์‹œ์ง€๊ฐ€ ์ง‘์ค‘๋˜์–ด ๋‹ค๋ฅธ ํŒŒํ‹ฐ์…˜์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜๋Š” ๊ฒƒ์ด ๋” ์ค‘์š”ํ•˜๋‹ค๊ณ  ํŒ๋‹จํ–ˆ์Šต๋‹ˆ๋‹ค. ๋งŒ์•ฝ ํŒŒํ‹ฐ์…˜ ๋ถˆ๊ท ํ˜•์ด ์‹ฌ๊ฐํ•œ ๋ฌธ์ œ๊ฐ€ ๋œ๋‹ค๋ฉด, ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ํ•ด์‹œ ํ•จ์ˆ˜๋กœ ๋ณ€ํ™˜ํ•˜๊ฑฐ๋‚˜, ๋ณตํ•ฉ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋“ฑ์˜ ๋ฐฉ์•ˆ์„ ๊ณ ๋ คํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ๋žœ๋ค ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ• ๊นŒ์š”? ์˜ˆ๋ฅผ ๋“ค์–ด, ๊ฐ™์€ ์ƒํ’ˆ์— ๋Œ€ํ•œ LikeAdded์™€ LikeRemoved ์ด๋ฒคํŠธ๊ฐ€ ์„œ๋กœ ๋‹ค๋ฅธ ํŒŒํ‹ฐ์…˜์— ๋ฐœํ–‰๋˜๋ฉด, Consumer๊ฐ€ LikeRemoved๋ฅผ ๋จผ์ € ์ฒ˜๋ฆฌํ•˜๊ณ  LikeAdded๋ฅผ ๋‚˜์ค‘์— ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๊ฒฝ์šฐ ์ข‹์•„์š” ์ˆ˜๊ฐ€ ์Œ์ˆ˜๊ฐ€ ๋˜๊ฑฐ๋‚˜ ์ž˜๋ชป๋œ ๋ฉ”ํŠธ๋ฆญ์ด ์ƒ์„ฑ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ํŒŒํ‹ฐ์…˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ™์€ aggregate root์— ๋Œ€ํ•œ ์ด๋ฒคํŠธ๋Š” ํ•ญ์ƒ ๊ฐ™์€ ํŒŒํ‹ฐ์…˜์—์„œ ์ˆœ์„œ๋Œ€๋กœ ์ฒ˜๋ฆฌ๋˜๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.

โœ… Checklist

  • ๋„๋ฉ”์ธ(์• ํ”Œ๋ฆฌ์ผ€์ด์…˜) ์ด๋ฒคํŠธ ์„ค๊ณ„

    • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java
    • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java
    • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java
    • ์ด๋ฒคํŠธ ํƒ€์ž…: LikeAdded, LikeRemoved, OrderCreated, ProductViewed
  • Producer ์•ฑ์—์„œ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ๋ฐœํ–‰

    • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java
    • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
    • ํ† ํ”ฝ: like-events, order-events, product-events
  • PartitionKey ๊ธฐ๋ฐ˜์˜ ์ด๋ฒคํŠธ ์ˆœ์„œ ๋ณด์žฅ

    • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (partitionKey ํ•„๋“œ)
    • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (KafkaHeaders.KEY ์„ค์ •)
    • ํŒŒํ‹ฐ์…˜ ํ‚ค: like-events, product-events โ†’ productId, order-events โ†’ orderId
  • At Least Once ๋ณด์žฅ (acks=all, idempotence=true)

    • modules/kafka/src/main/resources/kafka.yml (19-21์ค„)
    • ์„ค์ •: acks: all, enable.idempotence: true, max.in.flight.requests.per.connection: 5
  • Transactional Outbox Pattern ๊ตฌํ˜„

    • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
    • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
    • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
    • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java
  • ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰ ์‹คํŒจ ์ฒ˜๋ฆฌ

    • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (63-69์ค„)
    • ๊ฐœ๋ณ„ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹คํŒจ ์‹œ FAILED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•˜๊ณ  ๋‹ค์Œ ์Šค์ผ€์ค„์—์„œ ์žฌ์‹œ๋„

โšพ Consumer (7/7)

  • Consumer๊ฐ€ Metrics ์ง‘๊ณ„ ์ฒ˜๋ฆฌ

    • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
    • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
    • ์ง‘๊ณ„ ๋Œ€์ƒ: ์ข‹์•„์š” ์ˆ˜, ํŒ๋งค๋Ÿ‰, ์กฐํšŒ ์ˆ˜
  • Manual Ack ์ฒ˜๋ฆฌ

    • modules/kafka/src/main/resources/kafka.yml (27, 29์ค„)
    • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (141, 217์ค„)
    • ์„ค์ •: enable-auto-commit: false, ack-mode: manual
  • event_handled ํ…Œ์ด๋ธ” ๊ธฐ๋ฐ˜ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ

    • apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java
    • apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java
    • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (91, 275์ค„)
    • UNIQUE ์ œ์•ฝ์กฐ๊ฑด์œผ๋กœ ๋™์‹œ์„ฑ ์ƒํ™ฉ์—์„œ๋„ ์ค‘๋ณต ๋ฐฉ์ง€
  • version ๊ธฐ์ค€ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ๋ฐ˜์˜

    • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (56์ค„, version ํ•„๋“œ)
    • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java (59-60์ค„, ๋ฒ„์ „ ์ƒ์„ฑ)
    • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (99-102์ค„, ํ—ค๋”์— ์ถ”๊ฐ€)
    • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (100, 284์ค„, ํ—ค๋”์—์„œ ์ถ”์ถœ)
    • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java (100, 126์ค„, ๋ฒ„์ „ ๋น„๊ต)
    • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (shouldUpdate ๋ฉ”์„œ๋“œ)
  • product_metrics ํ…Œ์ด๋ธ”์— upsert

    • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
    • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
    • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • ์ค‘๋ณต ๋ฉ”์‹œ์ง€ ์žฌ์ „์†ก ํ…Œ์ŠคํŠธ

    • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java (352์ค„, handlesDuplicateMessagesIdempotently())
    • ๋™์ผํ•œ eventId๋ฅผ ๊ฐ€์ง„ ๋ฉ”์‹œ์ง€๊ฐ€ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ๋˜๋Š”์ง€ ๊ฒ€์ฆ
  • ์žฌ๊ณ  ์†Œ์ง„ ์‹œ ์ƒํ’ˆ ์บ์‹œ ๊ฐฑ์‹ 

    • apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java (147-151์ค„)
    • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java (evictProductDetailCache ๋ฉ”์„œ๋“œ)
    • ์žฌ๊ณ  ์ฐจ๊ฐ ํ›„ stock == 0 ์ฒดํฌํ•˜์—ฌ ์บ์‹œ ๋ฌดํšจํ™”

๐Ÿ“Ž References

Summary by CodeRabbit

๋ฆด๋ฆฌ์Šค ๋…ธํŠธ

  • ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ

    • ์ƒํ’ˆ ์กฐํšŒ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ์ด๋ฒคํŠธ์— ๋Œ€ํ•œ ์‹ค์‹œ๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ์ถ”์  ์‹œ์Šคํ…œ ์ถ”๊ฐ€
    • ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜๋กœ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ์•ˆ์ •์„ฑ ํ–ฅ์ƒ
    • ์ค‘๋ณต ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ๊ตฌํ˜„
  • ํ…Œ์ŠคํŠธ

    • ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐ ๋ฉ”ํŠธ๋ฆญ ์ถ”์  ๊ด€๋ จ ์ข…ํ•ฉ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ ์ถ”๊ฐ€

โœ๏ธ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 18, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

์ด PR์€ transactional outbox ํŒจํ„ด์„ ๊ตฌํ˜„ํ•˜์—ฌ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•œ ํ›„ ๋ณ„๋„ ํ”„๋กœ์„ธ์Šค์—์„œ Kafka๋กœ ๋ฐœํ–‰ํ•˜๋Š” ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๋„์ž…ํ•ฉ๋‹ˆ๋‹ค. commerce-api์—์„œ ProductEvent, LikeEvent, OrderEvent๋ฅผ ๋ฐœํ–‰ํ•˜๊ณ , OutboxEventPublisher๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ pending ์ด๋ฒคํŠธ๋ฅผ Kafka์— ๋ฐฐํฌํ•˜๋ฉฐ, commerce-streamer์˜ ProductMetricsConsumer๊ฐ€ ์ด๋ฒคํŠธ๋ฅผ ์†Œ๋น„ํ•˜์—ฌ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ์„ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค.

Changes

Cohort / File(s) ๋ณ€๊ฒฝ ์š”์•ฝ
Kafka ๋ชจ๋“ˆ ์„ค์ •
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java, modules/kafka/src/main/resources/kafka.yml, modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java, modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java
KafkaConfig์—์„œ ์ œ๋„ค๋ฆญ ํƒ€์ž…์„ Object์—์„œ String, Object๋กœ ๋ณ€๊ฒฝํ•˜๊ณ  6๊ฐœ ํ† ํ”ฝ ๋นˆ ์ถ”๊ฐ€; kafka.yml์— At-Least-Once ๋ฐœํ–‰ ์„ค์ • ๋ฐ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ง€์› ์ถ”๊ฐ€; Kafka ํ…Œ์ŠคํŠธ ์ปจํ…Œ์ด๋„ˆ ์„ค์ • ๋ฐ ํ† ํ”ฝ/์ปจ์Šˆ๋จธ ๊ทธ๋ฃน ๊ด€๋ฆฌ ์œ ํ‹ธ๋ฆฌํ‹ฐ ์ถ”๊ฐ€
Outbox ํŒจํ„ด ๋„๋ฉ”์ธ ๋ฐ ์„œ๋น„์Šค
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java, apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
OutboxEvent JPA ์—”ํ‹ฐํ‹ฐ ๋ฐ OutboxEventRepository ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€; ํŠธ๋žœ์žญ์…”๋„ outbox ํŒจํ„ด์„ ์œ„ํ•œ PENDING/PUBLISHED/FAILED ์ƒํƒœ ๊ด€๋ฆฌ
Outbox ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ณ„์ธต
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java, apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java
OutboxEventService๋กœ ์ด๋ฒคํŠธ ์ง๋ ฌํ™” ๋ฐ ์ €์žฅ; OutboxBridgeEventListener์—์„œ LikeEvent, OrderEvent, ProductEvent๋ฅผ ์ˆ˜์‹ ํ•˜์—ฌ Outbox์— ์ €์žฅ
Outbox ์ธํ”„๋ผ ๋ฐ ๋ฐœํ–‰
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ๋ฐ ๊ตฌํ˜„; OutboxEventPublisher๋Š” 1์ดˆ ์ฃผ๊ธฐ๋กœ pending ์ด๋ฒคํŠธ๋ฅผ ๋ฐฐ์น˜๋กœ ์กฐํšŒํ•˜์—ฌ Kafka์— ๋ฐœํ–‰
Product ์ด๋ฒคํŠธ
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java, apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java, apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
ProductEvent ๋ฐ ProductViewed ๋ ˆ์ฝ”๋“œ ๋„์ž…; ProductEventPublisher ์ธํ„ฐํŽ˜์ด์Šค ๋ฐ ๊ตฌํ˜„; CatalogFacade์—์„œ product ์กฐํšŒ ์‹œ ProductViewed ์ด๋ฒคํŠธ ๋ฐœํ–‰
์˜์กด์„ฑ ๋ฐ ์„ค์ •
apps/commerce-api/build.gradle.kts, apps/commerce-api/src/main/resources/application.yml
Kafka ๋ชจ๋“ˆ ์˜์กด์„ฑ ์ถ”๊ฐ€; kafka.yml ์ž„ํฌํŠธ ์ถ”๊ฐ€
Streamer ๋„๋ฉ”์ธ ๋ฐ ์„œ๋น„์Šค
apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java, apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java, apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java, apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java, apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
์ด๋ฒคํŠธ DTO ๋ ˆ์ฝ”๋“œ ์ •์˜; EventHandled ์—”ํ‹ฐํ‹ฐ ๋ฐ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋กœ ๋ฉฑ๋“ฑ์„ฑ ๊ด€๋ฆฌ; ProductMetrics ์—”ํ‹ฐํ‹ฐ๋กœ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์ถ”์ 
Streamer ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ณ„์ธต
apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java, apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
EventHandledService๋กœ ์ด๋ฒคํŠธ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€; ProductMetricsService๋กœ like/sales/view count ์—…๋ฐ์ดํŠธ ๋ฐ ๋ฒ„์ „ ๊ด€๋ฆฌ
Streamer ์ธํ”„๋ผ ๋ฐ ์ปจ์Šˆ๋จธ
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ๊ตฌํ˜„; ProductMetricsConsumer๋Š” like-events, order-events, product-events ํ† ํ”ฝ์„ ๋ฐฐ์น˜๋กœ ์ˆ˜์‹ ํ•˜์—ฌ ๋ฉ”ํŠธ๋ฆญ ์—…๋ฐ์ดํŠธ ๋ฐ ๋ฉฑ๋“ฑ์„ฑ ํ™•์ธ
ํ…Œ์ŠคํŠธ
apps/commerce-api/src/test/java/com/loopers/application/outbox/*Test.java, apps/commerce-api/src/test/java/com/loopers/domain/outbox/*Test.java, apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/*Test.java, apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/*Test.java, apps/commerce-streamer/src/test/java/com/loopers/application/metrics/*Test.java, apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/*Test.java, apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/*Test.java
OutboxBridgeEventListener, OutboxEventService, OutboxEvent, OutboxEventPublisher, EventHandledService, ProductMetricsService, ProductMetrics, ProductMetricsConsumer์— ๋Œ€ํ•œ ๋‹จ์œ„ ๋ฐ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ

Sequence Diagram

sequenceDiagram
    participant CatalogFacade
    participant ProductEventPublisher
    participant OutboxBridgeEventListener
    participant OutboxEventService
    participant OutboxEventRepository
    participant Database
    participant OutboxEventPublisher_Infra
    participant Kafka
    participant ProductMetricsConsumer
    participant ProductMetricsService

    CatalogFacade->>ProductEventPublisher: publish(ProductViewed)
    ProductEventPublisher->>OutboxBridgeEventListener: publishEvent(ProductEvent)
    OutboxBridgeEventListener->>OutboxEventService: saveEvent(eventType, aggregateId, ...)
    OutboxEventService->>OutboxEventRepository: save(OutboxEvent)
    OutboxEventRepository->>Database: INSERT outbox_event (status=PENDING)
    
    rect rgb(200, 220, 240)
        Note over OutboxEventPublisher_Infra: 1์ดˆ ์ฃผ๊ธฐ ์Šค์ผ€์ค„
        OutboxEventPublisher_Infra->>OutboxEventRepository: findPendingEvents(100)
        OutboxEventRepository->>Database: SELECT * WHERE status=PENDING
        Database-->>OutboxEventPublisher_Infra: List<OutboxEvent>
    end
    
    loop ๊ฐ Pending ์ด๋ฒคํŠธ
        OutboxEventPublisher_Infra->>Kafka: send(topic, key, payload, headers)
        alt ๋ฐœํ–‰ ์„ฑ๊ณต
            OutboxEventPublisher_Infra->>OutboxEventRepository: save(status=PUBLISHED, publishedAt)
        else ๋ฐœํ–‰ ์‹คํŒจ
            OutboxEventPublisher_Infra->>OutboxEventRepository: save(status=FAILED)
        end
    end
    
    ProductMetricsConsumer->>Kafka: poll(like-events, order-events, product-events)
    Kafka-->>ProductMetricsConsumer: ConsumerRecord<String, Object>
    
    loop ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ
        rect rgb(240, 220, 200)
            Note over ProductMetricsConsumer: ๋ฉฑ๋“ฑ์„ฑ ํ™•์ธ
            ProductMetricsConsumer->>ProductMetricsService: eventHandledService.isAlreadyHandled(eventId)
        end
        
        alt ๋ฏธ์ฒ˜๋ฆฌ ์ด๋ฒคํŠธ
            ProductMetricsConsumer->>ProductMetricsService: incrementViewCount(productId, version)
            ProductMetricsService->>Database: SELECT * FOR UPDATE WHERE productId (PESSIMISTIC_WRITE)
            ProductMetricsService->>Database: UPDATE product_metrics SET viewCount++, version++
            ProductMetricsConsumer->>ProductMetricsService: markAsHandled(eventId)
        else ์ค‘๋ณต ์ด๋ฒคํŠธ
            Note over ProductMetricsConsumer: ์Šคํ‚ต (DataIntegrityViolationException)
        end
    end
    
    ProductMetricsConsumer->>Kafka: acknowledge(batch)
Loading

Estimated code review effort

๐ŸŽฏ 4 (Complex) | โฑ๏ธ ~60 minutes

์ถ”๊ฐ€ ์ฃผ์˜๊ฐ€ ํ•„์š”ํ•œ ์˜์—ญ:

  • OutboxEventPublisher: ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ์ค‘ ๊ฐœ๋ณ„ ์ด๋ฒคํŠธ ์‹คํŒจ ์‹œ ๊ฒฉ๋ฆฌ ์ฒ˜๋ฆฌ, Kafka ํ—ค๋” ๊ตฌ์„ฑ ๋ฐ ๋น„๋™๊ธฐ ์ „์†ก ๋กœ์ง์˜ ์ •ํ™•์„ฑ
  • ProductMetricsConsumer: ๋ฐฐ์น˜ ๋ฉ€ํ‹ฐ ํ† ํ”ฝ ์ฒ˜๋ฆฌ, JSON ์—ญ์ง๋ ฌํ™” ํด๋ฐฑ, ๋ฉฑ๋“ฑ์„ฑ์„ ์œ„ํ•œ DataIntegrityViolationException ์ฒ˜๋ฆฌ
  • ProductMetrics ๋™์‹œ์„ฑ: pessimistic write lock์„ ์‚ฌ์šฉํ•œ version ๊ธฐ๋ฐ˜ ์—…๋ฐ์ดํŠธ ๋กœ์ง ๋ฐ shouldUpdate ๊ฒ€์ฆ
  • Outbox ์ƒํƒœ ๊ด€๋ฆฌ: PENDING โ†’ PUBLISHED/FAILED ์ƒํƒœ ์ „์ด ๋ฐ publishedAt ํƒ€์ด๋ฐ
  • ์ด๋ฒคํŠธ ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”: ObjectMapper๋ฅผ ํ†ตํ•œ payload ์ฒ˜๋ฆฌ ๋ฐ ์—๋Ÿฌ ์ผ€์ด์Šค

Possibly related PRs

Suggested labels

enhancement

Poem

๐Ÿฐ ํ† ๋ผ๊ฐ€ ์†Œ์‹์„ ์ „ํ•˜๋„ค,
์šฐํŽธํ•จ์— ๋‹ด์•„ ์•ˆ์ „ํ•˜๊ฒŒ,
์นดํ”„์นด ๋ฌผ์ค„๊ธฐ ๋”ฐ๋ผ ํ˜๋Ÿฌ,
๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘์€ ์šฐ์•„ํ•˜๊ฒŒ.
๋ฉฑ๋“ฑ์„ฑ์˜ ์ถค, ์ผ๊ด€์„ฑ์˜ ๋…ธ๋ž˜~

Pre-merge checks and finishing touches

โŒ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage โš ๏ธ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
โœ… Passed checks (2 passed)
Check name Status Explanation
Description check โœ… Passed PR ์„ค๋ช…์ด ํ…œํ”Œ๋ฆฟ์˜ ๋ชจ๋“  ํ•„์ˆ˜ ์„น์…˜(๐Ÿ“Œ Summary, ๐Ÿ’ฌ Review Points, โœ… Checklist)์„ ํฌํ•จํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ๊ฐ ์„น์…˜์ด ์ƒ์„ธํ•˜๊ฒŒ ์ž‘์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.
Title check โœ… Passed PR ์ œ๋ชฉ์€ ์ฃผ์š” ๋ณ€๊ฒฝ์‚ฌํ•ญ์ธ Kafka ๋„์ž…๊ณผ ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค ๊ฐ„ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ํ†ต์‹  ์•„ํ‚คํ…์ฒ˜๋ฅผ ๋ช…ํ™•ํ•˜๊ฒŒ ์š”์•ฝํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

โค๏ธ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@minor7295
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Dec 18, 2025

โœ… Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

๐Ÿงน Nitpick comments (16)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java (1)

21-26: DTO ๊ฒ€์ฆ ๋กœ์ง ์ถ”๊ฐ€๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”.

commerce-api์˜ ProductEvent.ProductViewed๋Š” ์ƒ์„ฑ์ž์—์„œ productId != null ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•˜์ง€๋งŒ, ์ด DTO์—๋Š” ๊ฒ€์ฆ ๋กœ์ง์ด ์—†์Šต๋‹ˆ๋‹ค. Kafka์—์„œ ์ž˜๋ชป๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์‹ ํ•  ๊ฒฝ์šฐ null ๊ฐ’์ด ์ „ํŒŒ๋˜์–ด ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ๋™์ž‘์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋‹ค์Œ ๊ฒ€์ฆ ๋กœ์ง ์ถ”๊ฐ€๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”:
     public record ProductViewed(
             Long productId,
             Long userId,
             LocalDateTime occurredAt
     ) {
+        public ProductViewed {
+            if (productId == null) {
+                throw new IllegalArgumentException("productId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.");
+            }
+        }
     }

์ฐธ๊ณ : Line 10์˜ ์ฃผ์„์—์„œ ์–ธ๊ธ‰ํ•œ ๋Œ€๋กœ, ํ–ฅํ›„ ๊ณต์œ  ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌํ•˜๋ฉด ์ด๋Ÿฌํ•œ ์ค‘๋ณต๊ณผ ๋ถˆ์ผ์น˜๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java (1)

122-124: ์บ์‹œ ํžˆํŠธ ์‹œ ํŠธ๋žœ์žญ์…˜ ์˜ค๋ฒ„ํ—ค๋“œ๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”.

์บ์‹œ์—์„œ ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์กฐํšŒํ•˜๋Š” ๊ฒฝ์šฐ์—๋„ @Transactional ๋ฉ”์„œ๋“œ ๋‚ด์—์„œ ์‹คํ–‰๋˜๋ฏ€๋กœ DB ์ปค๋„ฅ์…˜์„ ํš๋“ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ์บ์‹œ ํžˆํŠธ์œจ์ด ๋†’์€ ๊ฒฝ์šฐ ๋ถˆํ•„์š”ํ•œ ํŠธ๋žœ์žญ์…˜ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์„ฑ๋Šฅ์ด ์ค‘์š”ํ•œ ๊ฒฝ์šฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ตœ์ ํ™”๋ฅผ ๊ณ ๋ คํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  1. ์ด๋ฒคํŠธ ๋ฐœํ–‰์„ ๋ณ„๋„์˜ ๋น„๋™๊ธฐ ๋ฉ”์„œ๋“œ๋กœ ๋ถ„๋ฆฌ
  2. ์บ์‹œ ํžˆํŠธ ๊ฒฝ๋กœ์—์„œ๋Š” @Transactional(propagation = Propagation.NOT_SUPPORTED) ์‚ฌ์šฉ

๋‹ค๋งŒ, ํ˜„์žฌ ๊ตฌ์กฐ๊ฐ€ outbox ํŒจํ„ด์˜ ์ผ๊ด€์„ฑ์„ ๋ณด์žฅํ•˜๋ฏ€๋กœ, ์‹ค์ œ ์„ฑ๋Šฅ ๋ฌธ์ œ๊ฐ€ ํ™•์ธ๋˜๊ธฐ ์ „๊นŒ์ง€๋Š” ํ˜„์žฌ ๊ตฌํ˜„์„ ์œ ์ง€ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java (1)

37-42: TODO ์ฃผ์„: ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ๊ตฌํ˜„์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ์˜ ๋ผˆ๋Œ€๋Š” ์ž˜ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์ง€๋งŒ, ์‹ค์ œ ๊ฒ€์ฆ ๋กœ์ง์ด ๊ตฌํ˜„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. Outbox ํŒจํ„ด์˜ ํ•ต์‹ฌ ๊ธฐ๋Šฅ์ธ ์ด๋ฒคํŠธ ์ €์žฅ ๋ฐ Kafka ๋ฐœํ–‰์„ ๊ฒ€์ฆํ•˜๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ๊ตฌํ˜„์„ ๋„์™€๋“œ๋ฆด๊นŒ์š”? ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ํฌํ•จํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  1. OutboxEvent ์ €์žฅ ํ›„ PENDING ์ƒํƒœ ํ™•์ธ
  2. OutboxEventPublisher ์‹คํ–‰ ํ›„ Kafka ๋ฉ”์‹œ์ง€ ์ˆ˜์‹  ํ™•์ธ
  3. ๋ฐœํ–‰ ํ›„ PUBLISHED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝ ํ™•์ธ

๋˜๋Š” ์ด ์ž‘์—…์„ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•œ ์ด์Šˆ๋ฅผ ์ƒ์„ฑํ•ด ๋“œ๋ฆด๊นŒ์š”?

apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java (1)

51-75: Thread.sleep ์‚ฌ์šฉ์„ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Line 66์˜ Thread.sleep(1)์€ ํƒ€์ž„์Šคํƒฌํ”„ ์ฐจ์ด๋ฅผ ๋ณด์žฅํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ๋˜์—ˆ์ง€๋งŒ, ํ…Œ์ŠคํŠธ๋ฅผ ๋А๋ฆฌ๊ฒŒ ๋งŒ๋“ค๊ณ  ๋ถˆ์•ˆ์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋‹ค์Œ ๋ฐฉ๋ฒ•๋“ค์„ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”:

๋ฐฉ๋ฒ• 1: ๋‹จ์ˆœํžˆ publishedAt์ด null์ด ์•„๋‹Œ์ง€๋งŒ ํ™•์ธ

-        LocalDateTime beforePublish = outboxEvent.getCreatedAt();
-        Thread.sleep(1); // ์‹œ๊ฐ„ ์ฐจ์ด๋ฅผ ๋ณด์žฅํ•˜๊ธฐ ์œ„ํ•œ ์ž‘์€ ์ง€์—ฐ
-
         // act
         outboxEvent.markAsPublished();
 
         // assert
         assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED);
         assertThat(outboxEvent.getPublishedAt()).isNotNull();
-        assertThat(outboxEvent.getPublishedAt()).isAfter(beforePublish);
+        assertThat(outboxEvent.getPublishedAt()).isAfterOrEqualTo(outboxEvent.getCreatedAt());

๋ฐฉ๋ฒ• 2: Clock์„ ์ฃผ์ž…๋ฐ›๋„๋ก OutboxEvent ๋ฆฌํŒฉํ† ๋ง (๋” ๋ณต์žกํ•˜์ง€๋งŒ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์ข‹์Œ)

apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java (1)

132-147: ํ…Œ์ŠคํŠธ ์ •ํ™•์„ฑ ๊ฐœ์„  ์ œ์•ˆ: ์‹ค์ œ ์˜ˆ์™ธ ํƒ€์ž… ์‚ฌ์šฉ

ObjectMapper.writeValueAsString()์€ JsonProcessingException (checked exception)์„ ๋ฐœ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ์‹ค์ œ ๋™์ž‘์„ ๋” ์ •ํ™•ํžˆ ํ…Œ์ŠคํŠธํ•˜๋ ค๋ฉด RuntimeException ๋Œ€์‹  JsonProcessingException์„ throwํ•˜๋„๋ก ์ˆ˜์ •ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ • ์‚ฌํ•ญ:
 @DisplayName("JSON ์ง๋ ฌํ™” ์‹คํŒจ ์‹œ ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค.")
 @Test
 void throwsException_whenJsonSerializationFails() throws Exception {
     // arrange
     LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now());
     when(objectMapper.writeValueAsString(event))
-        .thenThrow(new RuntimeException("JSON ์ง๋ ฌํ™” ์‹คํŒจ"));
+        .thenThrow(new com.fasterxml.jackson.core.JsonProcessingException("JSON ์ง๋ ฌํ™” ์‹คํŒจ") {});

     // act & assert
     assertThatThrownBy(() -> 
         outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1")
     ).isInstanceOf(RuntimeException.class)
      .hasMessageContaining("Outbox ์ด๋ฒคํŠธ ์ €์žฅ ์‹คํŒจ");

     verify(outboxEventRepository, never()).save(any());
 }
apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java (1)

56-61: ์„ ํƒ์  ๊ฐœ์„ : ์ƒ์„ฑ์ž ํŒŒ๋ผ๋ฏธํ„ฐ null ๊ฒ€์ฆ

ํ•„์ˆ˜ ํ•„๋“œ(eventId, eventType, topic)์— ๋Œ€ํ•œ null ๊ฒ€์ฆ์„ ์ƒ์„ฑ์ž์— ์ถ”๊ฐ€ํ•˜๋ฉด DB ์ œ์•ฝ์กฐ๊ฑด ์œ„๋ฐ˜ ์ „์— ๋น ๋ฅด๊ฒŒ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ ๊ตฌํ˜„๋„ DB ๋ ˆ๋ฒจ์—์„œ ๋ณดํ˜ธ๋˜๋ฏ€๋กœ ์„ ํƒ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ • ์‚ฌํ•ญ:
 public EventHandled(String eventId, String eventType, String topic) {
+    if (eventId == null || eventId.isBlank()) {
+        throw new IllegalArgumentException("eventId๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.");
+    }
+    if (eventType == null || eventType.isBlank()) {
+        throw new IllegalArgumentException("eventType์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.");
+    }
+    if (topic == null || topic.isBlank()) {
+        throw new IllegalArgumentException("topic์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.");
+    }
     this.eventId = eventId;
     this.eventType = eventType;
     this.topic = topic;
     this.handledAt = LocalDateTime.now();
 }
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java (1)

5-14: ๋ฌธ์„œํ™” ๊ฐœ์„  ์ œ์•ˆ: ์ค‘๋ณต ๊ตฌ์กฐ์— ๋Œ€ํ•œ ์ฃผ์˜์‚ฌํ•ญ ์ถ”๊ฐ€

commerce-streamer์˜ ProductEvent์™€ ๋™์ผํ•œ ๊ตฌ์กฐ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. LikeEvent์—์„œ์ฒ˜๋Ÿผ ํ–ฅํ›„ ๊ณต์œ  ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌ๋ฅผ ๊ณ ๋ คํ•œ๋‹ค๋Š” ์ฃผ์„์„ ์ถ”๊ฐ€ํ•˜๋ฉด ์ผ๊ด€์„ฑ์ด ํ–ฅ์ƒ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž JavaDoc ๋ณด์™„ ์˜ˆ์‹œ:
 /**
  * ์ƒํ’ˆ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ.
  * <p>
  * ์ƒํ’ˆ ๋„๋ฉ”์ธ์˜ ์ค‘์š”ํ•œ ์ƒํƒœ ๋ณ€ํ™”๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ์ด๋ฒคํŠธ๋“ค์ž…๋‹ˆ๋‹ค.
  * </p>
+ * <p>
+ * <b>์ฃผ์˜:</b> ์ด ํด๋ž˜์Šค๋Š” commerce-streamer์˜ ProductEvent์™€ ๋™์ผํ•œ ๊ตฌ์กฐ๋ฅผ ๊ฐ€์ง‘๋‹ˆ๋‹ค.
+ * ํ–ฅํ›„ ๊ณต์œ  ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
+ * </p>
  *
  * @author Loopers
  * @version 1.0
  */
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java (1)

76-80: ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ฐœ์„ ์„ ๊ณ ๋ คํ•ด ์ฃผ์„ธ์š”.

Exception์„ ํฌ๊ด„์ ์œผ๋กœ catchํ•˜๋Š” ๋Œ€์‹  JsonProcessingException๊ณผ ๊ฐ™์€ ํŠน์ • ์˜ˆ์™ธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋ฉด ๋””๋ฒ„๊น…์ด ๋” ์šฉ์ดํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ๊ฐœ์„  ์ œ์•ˆ:
-        } catch (Exception e) {
+        } catch (JsonProcessingException e) {
             log.error("Outbox ์ด๋ฒคํŠธ ์ €์žฅ ์‹คํŒจ: eventType={}, aggregateId={}", 
                 eventType, aggregateId, e);
-            throw new RuntimeException("Outbox ์ด๋ฒคํŠธ ์ €์žฅ ์‹คํŒจ", e);
+            throw new RuntimeException("Outbox ์ด๋ฒคํŠธ JSON ์ง๋ ฌํ™” ์‹คํŒจ", e);
         }
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java (1)

21-25: ๋„ค์ดํ‹ฐ๋ธŒ ์ฟผ๋ฆฌ์˜ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ด์‹์„ฑ ๊ณ ๋ ค.

LIMIT ์ ˆ์€ MySQL/PostgreSQL์—์„œ ๋™์ž‘ํ•˜์ง€๋งŒ, Oracle์ด๋‚˜ SQL Server์—์„œ๋Š” ๋‹ค๋ฅธ ๊ตฌ๋ฌธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋‹ค์–‘ํ•œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์ง€์›ํ•ด์•ผ ํ•œ๋‹ค๋ฉด JPQL๊ณผ Pageable์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๋˜ํ•œ, ์—ฌ๋Ÿฌ ์ธ์Šคํ„ด์Šค๊ฐ€ ์Šค์ผ€์ค„๋Ÿฌ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๊ฒฝ์šฐ ์ค‘๋ณต ๋ฐœํ–‰์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ๋น„๊ด€์  ๋ฝ(FOR UPDATE SKIP LOCKED)์„ ๊ณ ๋ คํ•ด ์ฃผ์„ธ์š”.

๐Ÿ”Ž ๊ฐœ์„  ์ œ์•ˆ:
-    @Query(value = "SELECT * FROM outbox_event e " +
-           "WHERE e.status = 'PENDING' " +
-           "ORDER BY e.created_at ASC " +
-           "LIMIT :limit", nativeQuery = true)
-    List<OutboxEvent> findPendingEvents(@Param("limit") int limit);
+    @Query(value = "SELECT * FROM outbox_event e " +
+           "WHERE e.status = 'PENDING' " +
+           "ORDER BY e.created_at ASC " +
+           "LIMIT :limit " +
+           "FOR UPDATE SKIP LOCKED", nativeQuery = true)
+    List<OutboxEvent> findPendingEvents(@Param("limit") int limit);
apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1)

40-41: Thread.sleep ๋Œ€์•ˆ ๊ณ ๋ ค.

Thread.sleep(1)์€ ํƒ€์ž„์Šคํƒฌํ”„ ์ฐจ์ด๋ฅผ ๋ณด์žฅํ•˜๊ธฐ ์œ„ํ•œ ๊ฒƒ์ด์ง€๋งŒ, ํ…Œ์ŠคํŠธ ์•ˆ์ •์„ฑ์„ ์œ„ํ•ด java.time.Clock์„ ์ฃผ์ž…๋ฐ›์•„ ํ…Œ์ŠคํŠธ์—์„œ ์‹œ๊ฐ„์„ ์ œ์–ดํ•˜๋Š” ๋ฐฉ์‹์„ ๊ณ ๋ คํ•ด ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java (1)

37-38: findById ๋ฐ˜ํ™˜ ํƒ€์ž…์— Optional ์‚ฌ์šฉ์„ ๊ณ ๋ คํ•ด ์ฃผ์„ธ์š”.

ID๋กœ ์กฐํšŒ ์‹œ ๊ฒฐ๊ณผ๊ฐ€ ์—†์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ Optional<OutboxEvent>๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฉด ํ˜ธ์ถœ์ž๊ฐ€ null ์ฒดํฌ๋ฅผ ๊ฐ•์ œํ•˜๊ฒŒ ๋˜์–ด NPE๋ฅผ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๊ฐœ์„  ์ œ์•ˆ:
-    OutboxEvent findById(Long id);
+    Optional<OutboxEvent> findById(Long id);

import ์ถ”๊ฐ€:

import java.util.Optional;
apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java (1)

52-52: ๋ฐฐ์น˜ ํฌ๊ธฐ ์ƒ์ˆ˜ ์ถ”์ถœ์„ ๊ณ ๋ คํ•ด ์ฃผ์„ธ์š”.

100์ด ์—ฌ๋Ÿฌ ํ…Œ์ŠคํŠธ์—์„œ ๋ฐ˜๋ณต ์‚ฌ์šฉ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ƒ์ˆ˜๋กœ ์ถ”์ถœํ•˜๋ฉด ์‹ค์ œ Publisher์˜ ๋ฐฐ์น˜ ํฌ๊ธฐ๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ํ…Œ์ŠคํŠธ ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ์‰ฌ์›Œ์ง‘๋‹ˆ๋‹ค.

๐Ÿ”Ž ๊ฐœ์„  ์ œ์•ˆ:
private static final int BATCH_SIZE = 100;

// ์‚ฌ์šฉ ์˜ˆ์‹œ
when(outboxEventRepository.findPendingEvents(BATCH_SIZE)).thenReturn(pendingEvents);
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java (1)

53-140: ํ† ํ”ฝ ์ด๋ฆ„์„ ์ƒ์ˆ˜๋กœ ์ถ”์ถœํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”

"like-events", "order-events", "product-events" ๋“ฑ ํ† ํ”ฝ ์ด๋ฆ„์ด ์—ฌ๋Ÿฌ ๊ณณ์—์„œ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ์˜คํƒ€ ๋ฐฉ์ง€์™€ ์œ ์ง€๋ณด์ˆ˜๋ฅผ ์œ„ํ•ด ์ƒ์ˆ˜๋‚˜ ์„ค์ •์œผ๋กœ ๊ด€๋ฆฌํ•˜๋ฉด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ํ† ํ”ฝ ์ƒ์ˆ˜ ์ถ”์ถœ ์˜ˆ์‹œ:
public final class KafkaTopics {
    public static final String LIKE_EVENTS = "like-events";
    public static final String ORDER_EVENTS = "order-events";
    public static final String PRODUCT_EVENTS = "product-events";
    
    private KafkaTopics() {}
}
modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java (1)

35-51: ํ…Œ์ŠคํŠธ ์œ ํ‹ธ๋ฆฌํ‹ฐ๊ฐ€ ํ”„๋กœ๋•์…˜์—์„œ ์‹ค์ˆ˜๋กœ ํ™œ์„ฑํ™”๋  ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค.

@Component ์–ด๋…ธํ…Œ์ด์…˜์€ ์ปดํฌ๋„ŒํŠธ ์Šค์บ” ์‹œ ์–ด๋–ค ์ปจํ…์ŠคํŠธ์—์„œ๋“  ์ด ๋นˆ์„ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. testFixtures ์†Œ์Šค ์„ธํŠธ๋Š” ์ผ๋ฐ˜์ ์œผ๋กœ ํ”„๋กœ๋•์…˜์— ํฌํ•จ๋˜์ง€ ์•Š์ง€๋งŒ, ๋ช…์‹œ์ ์œผ๋กœ ํ…Œ์ŠคํŠธ ์ „์šฉ์ž„์„ ํ‘œ์‹œํ•˜๋Š” ๊ฒƒ์ด ๋” ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ํ…Œ์ŠคํŠธ ํ”„๋กœํŒŒ์ผ ์ œํ•œ์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”:
+import org.springframework.context.annotation.Profile;
+
+@Profile("test")
 @Component
 public class KafkaCleanUp {

๋˜๋Š” ์ด ํด๋ž˜์Šค๋ฅผ @Configuration ํด๋ž˜์Šค์—์„œ ๋ช…์‹œ์ ์œผ๋กœ @Bean์œผ๋กœ ๋“ฑ๋กํ•˜์—ฌ ์ œ์–ด๋ฅผ ๊ฐ•ํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (1)

232-240: ํŒŒ์‹ฑ ๋ฉ”์„œ๋“œ์˜ ์ผ๊ด€์„ฑ์„ ๊ฐœ์„ ํ•˜์„ธ์š”.

parseLikeEvent๋Š” instanceof ์ฒดํฌ ์—†์ด ๋ฐ”๋กœ JSON ํŒŒ์‹ฑ์„ ์‹œ๋„ํ•˜๋Š” ๋ฐ˜๋ฉด, parseOrderCreatedEvent์™€ parseProductViewedEvent๋Š” instanceof ์ฒดํฌ๋ฅผ ๋จผ์ € ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด parseLikeEvent๋„ ๋™์ผํ•œ ํŒจํ„ด์„ ๋”ฐ๋ฅด๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ผ๊ด€๋œ ํŒŒ์‹ฑ ํŒจํ„ด ์ ์šฉ:
 private LikeEvent.LikeAdded parseLikeEvent(Object value) {
     try {
+        if (value instanceof LikeEvent.LikeAdded) {
+            return (LikeEvent.LikeAdded) value;
+        }
+        
         // JSON ๋ฌธ์ž์—ด์ธ ๊ฒฝ์šฐ ํŒŒ์‹ฑ
         String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value);
         return objectMapper.readValue(json, LikeEvent.LikeAdded.class);
     } catch (Exception e) {
         throw new RuntimeException("LikeEvent ํŒŒ์‹ฑ ์‹คํŒจ", e);
     }
 }
modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java (1)

78-166: ํ† ํ”ฝ ์„ค์ •์ด ๊ฐœ๋ฐœ/ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์— ์ ํ•ฉํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๊ฐ ํ† ํ”ฝ์˜ ํŒŒํ‹ฐ์…˜ ํ‚ค ์ „๋žต์ด ๋ช…ํ™•ํžˆ ๋ฌธ์„œํ™”๋˜์–ด ์žˆ์œผ๋ฉฐ, 3๊ฐœ ํŒŒํ‹ฐ์…˜์€ ์ ์ ˆํ•œ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ replicas=1 ์„ค์ •์€ ๊ฐœ๋ฐœ/ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—๋งŒ ์ ํ•ฉํ•˜๋ฏ€๋กœ, ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋Š” ์ตœ์†Œ 3๊ฐœ ์ด์ƒ์˜ ๋ ˆํ”Œ๋ฆฌ์นด๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ์„ค์ •์„ ์™ธ๋ถ€ํ™”ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์„ ์œ„ํ•œ ์„ค์ • ์™ธ๋ถ€ํ™” ์ œ์•ˆ:

application.yml์— ํ† ํ”ฝ ์„ค์ •์„ ์ถ”๊ฐ€ํ•˜์—ฌ ํ™˜๊ฒฝ๋ณ„๋กœ ๋‹ค๋ฅธ ๋ ˆํ”Œ๋ฆฌ์นด ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

kafka:
  topic:
    partitions: 3
    replicas: 3  # production
    min-insync-replicas: 2

๊ทธ๋ฆฌ๊ณ  @Value ๋˜๋Š” @ConfigurationProperties๋ฅผ ํ†ตํ•ด ์ด ๊ฐ’๋“ค์„ ์ฃผ์ž…๋ฐ›์•„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“œ Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between fb769b2 and 3964c8e.

๐Ÿ“’ Files selected for processing (41)
  • apps/commerce-api/build.gradle.kts (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java (4 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java (1 hunks)
  • apps/commerce-api/src/main/resources/application.yml (1 hunks)
  • apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java (1 hunks)
  • apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java (1 hunks)
  • apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java (1 hunks)
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java (1 hunks)
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (1 hunks)
  • apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java (1 hunks)
  • apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java (1 hunks)
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1 hunks)
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java (1 hunks)
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java (1 hunks)
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java (4 hunks)
  • modules/kafka/src/main/resources/kafka.yml (2 hunks)
  • modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java (1 hunks)
  • modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java (1 hunks)
๐Ÿงฐ Additional context used
๐Ÿง  Learnings (8)
๐Ÿ““ Common learnings
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.
๐Ÿ“š Learning: 2025-12-18T13:24:54.339Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:54.339Z
Learning: In this codebase, Kafka consumers delegate error handling and event tracking to the service layer via EventInboxAspect. Service methods annotated with InboxEvent are intercepted by the aspect, which handles failures by saving failed EventInbox entries and logging errors. This centralized approach avoids duplicating error handling logic across multiple consumers.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java
๐Ÿ“š Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java
๐Ÿ“š Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java
๐Ÿ“š Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product ์—”ํ‹ฐํ‹ฐ (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)๋Š” ์œ ์ฆˆ์ผ€์ด์Šค๋ณ„๋กœ ์˜๋„์ ์œผ๋กœ ๋‹ค๋ฅธ ๋ฝ ์ „๋žต์„ ์‚ฌ์šฉํ•œ๋‹ค: ์ข‹์•„์š” ๊ธฐ๋Šฅ์—๋Š” ๋น„๊ด€์  ๋ฝ(findByIdForUpdate)์„, ์žฌ๊ณ  ์ฐจ๊ฐ์—๋Š” ๋‚™๊ด€์  ๋ฝ(Version + ์žฌ์‹œ๋„)์„ ์‚ฌ์šฉํ•œ๋‹ค.

Applied to files:

  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
๐Ÿ“š Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier์™€ ๊ฐ™์€ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ID์™€ ProductLikeId, OrderItemId ๋“ฑ์˜ ๋„๋ฉ”์ธ ID๋Š” ๊ฒ€์ฆ ํŒจํ„ด์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค. UserIdentifier๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•˜๋Š” ๋กœ๊ทธ์ธ ID๋กœ์„œ ์ •๊ทœ์‹, ๊ธธ์ด ๋“ฑ์˜ ํ˜•์‹ ๊ฒ€์ฆ์ด ํ•„์š”ํ•˜์ง€๋งŒ, ๋„๋ฉ”์ธ ID๋Š” ์‹œ์Šคํ…œ์—์„œ ์ƒ์„ฑํ•˜๋Š” ์‹๋ณ„์ž(UUID, DB ์ƒ์„ฑ ID)์ด๋ฏ€๋กœ ํŒจํ„ด ๊ฒ€์ฆ์ด ๋ถˆํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋„๋ฉ”์ธ ID VO๋Š” ๋‹จ์ˆœํ•œ record์™€ empty() ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ๋งŒ์œผ๋กœ ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.

Applied to files:

  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java
๐Ÿ“š Learning: 2025-12-18T01:01:55.894Z
Learnt from: jikimee64
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 65
File: modules/kafka/src/main/resources/kafka.yml:25-32
Timestamp: 2025-12-18T01:01:55.894Z
Learning: In Spring Boot Kafka configuration YAML, properties inside spring.kafka.producer.properties and spring.kafka.consumer.properties maps must use exact Kafka client property names with dot notation, and must be quoted with bracket notation like "[enable.idempotence]": true and "[enable.auto.commit]": false to prevent YAML from parsing dots as nested keys. Spring Boot's relaxed binding only applies to top-level Spring Kafka properties, not to the properties map.

Applied to files:

  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
  • modules/kafka/src/main/resources/kafka.yml
๐Ÿ“š Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService์—์„œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์‹œ Redis ์บ์‹œ๋ฅผ ์ ์šฉํ–ˆ์œผ๋ฉฐ, ์บ์‹œ ํ‚ค๋Š” brandId, sortType, pageNumber, pageSize์˜ ์กฐํ•ฉ์œผ๋กœ ๊ตฌ์„ฑ๋˜๊ณ  TTL์€ 5๋ถ„์œผ๋กœ ์„ค์ •๋˜์–ด ์žˆ๋‹ค.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java
๐Ÿงฌ Code graph analysis (10)
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java (2)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java (1)
  • LikeEvent (16-37)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java (1)
  • OrderEvent (17-40)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (2)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java (1)
  • LikeEvent (16-37)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java (1)
  • Slf4j (28-154)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (3)
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java (1)
  • Slf4j (39-141)
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java (1)
  • Slf4j (23-82)
apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java (1)
  • Component (13-39)
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java (1)
apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java (1)
  • SpringBootTest (23-43)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java (1)
  • ProductEvent (14-59)
apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java (1)
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java (1)
  • SpringBootTest (35-116)
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java (1)
apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java (1)
  • Slf4j (21-64)
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java (1)
  • ProductEvent (16-27)
apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java (1)
  • ProductEvent (14-59)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java (1)
  • Component (26-36)
apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java (1)
  • ProductEvent (14-59)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java (1)
  • ProductEvent (16-27)
๐Ÿ”‡ Additional comments (58)
apps/commerce-api/src/main/resources/application.yml (1)

23-23: LGTM!

Kafka ์„ค์ • ํŒŒ์ผ์„ configuration import ๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•œ ๊ฒƒ์€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ์ด๋Š” PR์˜ Kafka ํ†ตํ•ฉ ๋ชฉํ‘œ์™€ ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)

40-57: LGTM!

Upsert ์‹œ ๋™์‹œ์„ฑ ์ œ์–ด๋ฅผ ์œ„ํ•œ ๋น„๊ด€์  ๋ฝ ์ „๋žต์ด ์ž˜ ๋ฌธ์„œํ™”๋˜์–ด ์žˆ๊ณ , ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„ ์‹œ Lost Update๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•œ ์ ์ ˆํ•œ ์ ‘๊ทผ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค. Lock ๋ฒ”์œ„๋ฅผ PK ๊ธฐ๋ฐ˜ ์กฐํšŒ๋กœ ์ตœ์†Œํ™”ํ•œ ๊ฒƒ๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

Based on learnings, ์ด ํ”„๋กœ์ ํŠธ๋Š” ์œ ์ฆˆ์ผ€์ด์Šค๋ณ„๋กœ ์˜๋„์ ์œผ๋กœ ๋‹ค๋ฅธ ๋ฝ ์ „๋žต์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„์— ๋น„๊ด€์  ๋ฝ์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ํ•ฉ๋ฆฌ์ ์ธ ์„ ํƒ์ž…๋‹ˆ๋‹ค.

apps/commerce-api/build.gradle.kts (1)

5-5: LGTM!

Kafka ๋ชจ๋“ˆ๊ณผ ํ…Œ์ŠคํŠธ ํ”ฝ์Šค์ฒ˜ ์˜์กด์„ฑ์„ ์ถ”๊ฐ€ํ•˜์—ฌ commerce-api์—์„œ Kafka ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•œ ๊ฒƒ์€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ์ด๋Š” PR์˜ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜ ๋„์ž… ๋ชฉํ‘œ์™€ ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค.

Also applies to: 38-38

apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java (1)

117-125: LGTM!

@Transactional(readOnly = true) ์–ด๋…ธํ…Œ์ด์…˜ ์ถ”๊ฐ€๋Š” outbox ํŒจํ„ด์„ ์œ„ํ•œ ์ ์ ˆํ•œ ์„ ํƒ์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹œ outbox ์—”ํŠธ๋ฆฌ๊ฐ€ ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ ๋‚ด์—์„œ ์ €์žฅ๋˜๋„๋ก ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค. ์บ์‹œ ํžˆํŠธ ์‹œ์—๋„ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœํ–‰ํ•˜์—ฌ ์กฐํšŒ ์ˆ˜ ์ง‘๊ณ„๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java (1)

1-21: LGTM! ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด์˜ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์ธํ„ฐํŽ˜์ด์Šค๊ฐ€ ์ž˜ ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

DIP ์›์น™์„ ์ค€์ˆ˜ํ•˜์—ฌ ๋„๋ฉ”์ธ ๋ ˆ์ด์–ด์—์„œ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์ •์˜ํ•˜๊ณ  ๊ตฌํ˜„์„ ์ธํ”„๋ผ ๋ ˆ์ด์–ด์— ์œ„์ž„ํ•˜๋Š” ๊ตฌ์กฐ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ๋ฌธ์„œํ™”๋„ ๋ช…ํ™•ํ•˜๊ฒŒ ์ž‘์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java (1)

1-40: LGTM! ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๊ธฐ๋ก์„ ์œ„ํ•œ ์ €์žฅ์†Œ ์ธํ„ฐํŽ˜์ด์Šค๊ฐ€ ์ž˜ ์ •์˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ๋ฉ”์„œ๋“œ๋“ค์ด ์ ์ ˆํ•˜๊ฒŒ ์ •์˜๋˜์–ด ์žˆ์œผ๋ฉฐ, DIP ์›์น™์„ ์ค€์ˆ˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฌธ์„œํ™”๋„ ๋ช…ํ™•ํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java (3)

15-49: LGTM! OutboxEvent ์ƒ์„ฑ ๋ฐ ์ดˆ๊ธฐ ์ƒํƒœ ๊ฒ€์ฆ์ด ์ž˜ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

ํ•„์ˆ˜ ํ•„๋“œ์™€ ์ดˆ๊ธฐ ์ƒํƒœ(PENDING)๋ฅผ ํ™•์ธํ•˜๋Š” ํ…Œ์ŠคํŠธ๊ฐ€ ๋ช…ํ™•ํ•˜๊ณ  ํฌ๊ด„์ ์ž…๋‹ˆ๋‹ค.


77-97: LGTM! ์‹คํŒจ ์ƒํƒœ ์ „ํ™˜ ํ…Œ์ŠคํŠธ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ์‹คํŒจ ์ฒ˜๋ฆฌ ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


99-123: LGTM! ์ƒํƒœ ์ „ํ™˜ ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ๊ฐ€ ์ž˜ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๋ฐœํ–‰ ์™„๋ฃŒ ํ›„ ์‹คํŒจ ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•˜๋Š” ์—ฃ์ง€ ์ผ€์ด์Šค๋ฅผ ๋‹ค๋ฃจ๊ณ  ์žˆ์œผ๋ฉฐ, publishedAt์ด ์œ ์ง€๋˜๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java (8)

29-48: LGTM! ํ…Œ์ŠคํŠธ ์„ค์ •์ด ์ž˜ ๊ตฌ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

Mockito๋ฅผ ์‚ฌ์šฉํ•œ ์˜์กด์„ฑ ๋ชจํ‚น์ด ์ ์ ˆํ•˜๊ฒŒ ์„ค์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.


50-108: LGTM! ๊ฐœ๋ณ„ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ํ…Œ์ŠคํŠธ๊ฐ€ ํฌ๊ด„์ ์ž…๋‹ˆ๋‹ค.

LikeAdded์™€ LikeRemoved ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ๋ช…ํ™•ํ•˜๊ฒŒ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, eventId ํ—ค๋”์™€ version ํ—ค๋”๋ฅผ ํฌํ•จํ•œ ์™„์ „ํ•œ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๋‹ค๋ฃจ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


110-149: LGTM! OrderCreated ์ด๋ฒคํŠธ์˜ ๋ณต์žกํ•œ ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ์ž˜ ํ…Œ์ŠคํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ์•„์ดํ…œ์„ ํฌํ•จํ•œ OrderCreated ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ์™€ ๊ฐ ์ƒํ’ˆ๋ณ„ ํŒ๋งค ์ˆ˜๋Ÿ‰ ์ฆ๊ฐ€๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


151-189: LGTM! ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ํ…Œ์ŠคํŠธ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ ์ด๋ฒคํŠธ๋ฅผ ๋ฐฐ์น˜๋กœ ์ฒ˜๋ฆฌํ•˜๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, acknowledgment๊ฐ€ ํ•œ ๋ฒˆ๋งŒ ํ˜ธ์ถœ๋˜๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.


191-263: LGTM! ์˜ค๋ฅ˜ ๋ณต์›๋ ฅ ํ…Œ์ŠคํŠธ๊ฐ€ ํ›Œ๋ฅญํ•ฉ๋‹ˆ๋‹ค.

๊ฐœ๋ณ„ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹คํŒจ ์‹œ์—๋„ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋ฅผ ๊ณ„์†ํ•˜๊ณ  acknowledgment๋ฅผ ์ˆ˜ํ–‰ํ•˜๋Š” ๋กœ์ง์ด ์ž˜ ๊ฒ€์ฆ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” Kafka ์†Œ๋น„์ž์˜ ์ค‘์š”ํ•œ ํŠน์„ฑ์ž…๋‹ˆ๋‹ค.

Based on learnings, ์ด ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ Kafka ์†Œ๋น„์ž๋Š” ์—๋Ÿฌ ์ฒ˜๋ฆฌ์™€ ์ด๋ฒคํŠธ ์ถ”์ ์„ EventInboxAspect๋ฅผ ํ†ตํ•ด ์„œ๋น„์Šค ๋ ˆ์ด์–ด์— ์œ„์ž„ํ•˜๋Š” ๊ฒƒ์œผ๋กœ ์•Œ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ํ…Œ์ŠคํŠธ๋Š” ์†Œ๋น„์ž ๋ ˆ๋ฒจ์˜ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ์ž˜ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


265-292: LGTM! ๋ฉฑ๋“ฑ์„ฑ ํ…Œ์ŠคํŠธ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์ด๋ฒคํŠธ๋ฅผ ๊ฑด๋„ˆ๋›ฐ๋Š” ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์œผ๋ฉฐ, ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€ ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด ์ž˜ ์ž‘๋™ํ•จ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.


294-314: LGTM! eventId ๋ˆ„๋ฝ ์ฒ˜๋ฆฌ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

eventId๊ฐ€ ์—†๋Š” ๋ฉ”์‹œ์ง€๋ฅผ ๊ฑด๋„ˆ๋›ฐ๋Š” ๋ฐฉ์–ด์  ํ”„๋กœ๊ทธ๋ž˜๋ฐ์ด ์ž˜ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.


316-346: LGTM! ๋™์‹œ์„ฑ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๊ฐ€ ํ›Œ๋ฅญํ•ฉ๋‹ˆ๋‹ค.

DataIntegrityViolationException์ด ๋ฐœ์ƒํ•˜๋Š” ๊ฒฝ์Ÿ ์กฐ๊ฑด(race condition) ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๋‹ค๋ฃจ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ์—ฌ๋Ÿฌ ์†Œ๋น„์ž ์ธ์Šคํ„ด์Šค๊ฐ€ ๋™์ผํ•œ ์ด๋ฒคํŠธ๋ฅผ ๊ฑฐ์˜ ๋™์‹œ์— ์ฒ˜๋ฆฌํ•˜๋ ค ํ•  ๋•Œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์ƒํ™ฉ์„ ์ž˜ ๋ฐ˜์˜ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java (6)

19-29: LGTM! ํ…Œ์ŠคํŠธ ์„ค์ •์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

Mockito๋ฅผ ์‚ฌ์šฉํ•œ ์˜์กด์„ฑ ๋ชจํ‚น์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.


31-73: LGTM! Like ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ํ…Œ์ŠคํŠธ๊ฐ€ ํฌ๊ด„์ ์ž…๋‹ˆ๋‹ค.

LikeAdded์™€ LikeRemoved ์ด๋ฒคํŠธ๋ฅผ Outbox์— ์ €์žฅํ•˜๋Š” ๋กœ์ง์ด ๋ช…ํ™•ํ•˜๊ฒŒ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์œผ๋ฉฐ, ์˜ฌ๋ฐ”๋ฅธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ OutboxEventService๊ฐ€ ํ˜ธ์ถœ๋˜๋Š”์ง€ ํ™•์ธํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


75-100: LGTM! OrderCreated ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ๋ฅผ Outbox์— ์ €์žฅํ•˜๋Š” ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


102-124: LGTM! ProductViewed ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ๋ฅผ Outbox์— ์ €์žฅํ•˜๋Š” ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


126-141: LGTM! ์—๋Ÿฌ ๊ฒฉ๋ฆฌ ํ…Œ์ŠคํŠธ๊ฐ€ ํ›Œ๋ฅญํ•ฉ๋‹ˆ๋‹ค.

Outbox ์ €์žฅ ์‹คํŒจ ์‹œ์—๋„ ์˜ˆ์™ธ๋ฅผ ์ „ํŒŒํ•˜์ง€ ์•Š๋Š” ๊ฒƒ์€ transactional outbox ํŒจํ„ด์˜ ์ค‘์š”ํ•œ ํŠน์„ฑ์ž…๋‹ˆ๋‹ค. ์ฃผ ํŠธ๋žœ์žญ์…˜์ด ์„ฑ๊ณตํ•˜๋”๋ผ๋„ outbox ์ €์žฅ ์‹คํŒจ๋กœ ์ธํ•ด ์ „์ฒด ์ž‘์—…์ด ๋กค๋ฐฑ๋˜์ง€ ์•Š๋„๋ก ํ•˜๋Š” ๋ฐฉ์–ด์  ์„ค๊ณ„๊ฐ€ ์ž˜ ๊ฒ€์ฆ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.


143-162: LGTM! ์ˆœ์ฐจ์  ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ํ…Œ์ŠคํŠธ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๋Ÿฌ ์ด๋ฒคํŠธ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ์ž˜ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java (1)

1-31: LGTM! JPA ์ €์žฅ์†Œ ์ธํ„ฐํŽ˜์ด์Šค๊ฐ€ ์ž˜ ์ •์˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

Spring Data JPA์˜ derived query ๋ฉ”์„œ๋“œ๋ฅผ ์ ์ ˆํ•˜๊ฒŒ ํ™œ์šฉํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ์ด๋ฒคํŠธ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ์กฐํšŒ ๋ฉ”์„œ๋“œ๋“ค์ด ๋ช…ํ™•ํ•˜๊ฒŒ ์ •์˜๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java (4)

18-28: LGTM! ํ…Œ์ŠคํŠธ ์„ค์ •์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

Mockito๋ฅผ ์‚ฌ์šฉํ•œ ์˜์กด์„ฑ ๋ชจํ‚น์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.


30-58: LGTM! ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ํ™•์ธ ๋กœ์ง์ด ์ž˜ ํ…Œ์ŠคํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ ์ด๋ฒคํŠธ์™€ ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์ด๋ฒคํŠธ์— ๋Œ€ํ•œ ๋‘ ๊ฐ€์ง€ ๊ฒฝ๋กœ๋ฅผ ๋ชจ๋‘ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ์ €์žฅ์†Œ์™€์˜ ์ƒํ˜ธ์ž‘์šฉ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ํ™•์ธ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


60-76: LGTM! ์„ฑ๊ณต์ ์ธ ์ด๋ฒคํŠธ ์ €์žฅ ํ…Œ์ŠคํŠธ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์ƒˆ๋กœ์šด ์ด๋ฒคํŠธ๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์ €์žฅํ•˜๋Š” ๊ฒฝ๋กœ๊ฐ€ ์ž˜ ๊ฒ€์ฆ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


78-95: LGTM! ์ค‘๋ณต ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์˜ˆ์™ธ ํ…Œ์ŠคํŠธ๊ฐ€ ํ›Œ๋ฅญํ•ฉ๋‹ˆ๋‹ค.

DataIntegrityViolationException์„ ํ†ตํ•ด eventId์˜ UNIQUE ์ œ์•ฝ ์กฐ๊ฑด ์œ„๋ฐ˜์„ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ˆ˜์ค€์—์„œ ๋ฉฑ๋“ฑ์„ฑ์„ ๋ณด์žฅํ•˜๋Š” ์ค‘์š”ํ•œ ๋ฉ”์ปค๋‹ˆ์ฆ˜์ž…๋‹ˆ๋‹ค.

apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java (1)

1-73: LGTM! ํ…Œ์ŠคํŠธ๊ฐ€ ์ž˜ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

Outbox ์ด๋ฒคํŠธ ์ €์žฅ์˜ ํ•ต์‹ฌ ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ์ž˜ ๊ฒ€์ฆ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ArgumentCaptor๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ €์žฅ๋œ ์—”ํ‹ฐํ‹ฐ์˜ ์†์„ฑ๋“ค์„ ์„ธ๋ฐ€ํ•˜๊ฒŒ ๊ฒ€์ฆํ•˜๋Š” ๋ฐฉ์‹์ด ์ข‹์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java (1)

28-61: ์ž˜ ์„ค๊ณ„๋œ ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ ์—”ํ‹ฐํ‹ฐ์ž…๋‹ˆ๋‹ค.

eventId๋ฅผ PK๋กœ ์‚ฌ์šฉํ•˜์—ฌ DB ๋ ˆ๋ฒจ์—์„œ ์ค‘๋ณต ์ฒ˜๋ฆฌ๋ฅผ ๋ฐฉ์ง€ํ•˜๋Š” ์„ค๊ณ„๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. handled_at ์ธ๋ฑ์Šค๋Š” ์ด๋ฒคํŠธ ๋ณด์กด ์ •์ฑ…์ด๋‚˜ ์ •๋ฆฌ ์ž‘์—…์— ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java (1)

27-58: ์ž˜ ๊ตฌํ˜„๋œ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ์ž…๋‹ˆ๋‹ค.

productId์— ๋Œ€ํ•œ null ๊ฒ€์ฆ๊ณผ ๋‘ ๊ฐ€์ง€ ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ์ œ๊ณต์ด ์ข‹์Šต๋‹ˆ๋‹ค. userId๊ฐ€ nullable์ธ ๊ฒƒ๋„ ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž ์ผ€์ด์Šค๋ฅผ ๊ณ ๋ คํ•œ ์ ์ ˆํ•œ ์„ค๊ณ„์ž…๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java (1)

1-37: LGTM! DTO ๊ตฌ์กฐ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

Kafka ์ด๋ฒคํŠธ ํŒŒ์‹ฑ์šฉ DTO๋กœ์„œ ์ ์ ˆํ•œ ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค. JavaDoc์—์„œ commerce-api์˜ LikeEvent์™€์˜ ์ค‘๋ณต ๋ฐ ํ–ฅํ›„ ๊ณต์œ  ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ๊ณ ๋ ค ์‚ฌํ•ญ์„ ๋ช…ํ™•ํžˆ ๋ฌธ์„œํ™”ํ•œ ์ ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)

20-49: LGTM! ํ‘œ์ค€์ ์ธ Repository ๊ตฌํ˜„์ž…๋‹ˆ๋‹ค.

JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์— ์œ„์ž„ํ•˜๋Š” ๊น”๋”ํ•œ ๊ตฌํ˜„์ž…๋‹ˆ๋‹ค. findByProductIdForUpdate ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ๋น„๊ด€์  ๋ฝ์„ ์ง€์›ํ•˜๋Š” ๊ฒƒ์ด ๋ฉ”ํŠธ๋ฆญ ์—…๋ฐ์ดํŠธ์˜ ๋™์‹œ์„ฑ ์ œ์–ด์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java (1)

17-40: LGTM! ๊ตฌ์กฐ๊ฐ€ ๊น”๋”ํ•ฉ๋‹ˆ๋‹ค.

Kafka ์ด๋ฒคํŠธ ์—ญ์ง๋ ฌํ™”๋ฅผ ์œ„ํ•œ record ๊ธฐ๋ฐ˜ DTO ๊ตฌ์กฐ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ์ฃผ์„์—์„œ ์–ธ๊ธ‰๋œ ๋Œ€๋กœ commerce-api์˜ OrderEvent์™€ ์ค‘๋ณต๋˜๋ฏ€๋กœ ํ–ฅํ›„ ๊ณต์œ  ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์ข‹๊ฒ ์Šต๋‹ˆ๋‹ค.

ํ•œ ๊ฐ€์ง€ ํ™•์ธ ์‚ฌํ•ญ: subtotal์€ Integer์ด๊ณ  usedPointAmount๋Š” Long์ธ๋ฐ, ๊ธˆ์•ก ๊ด€๋ จ ํ•„๋“œ์˜ ํƒ€์ž…์ด ์˜๋„์ ์œผ๋กœ ๋‹ค๋ฅธ ๊ฒƒ์ธ์ง€ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.

apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java (1)

26-36: LGTM! DIP ์›์น™์„ ์ž˜ ๋”ฐ๋ฅด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๋‹จ์ผ ์ฑ…์ž„ ์›์น™์— ๋”ฐ๋ผ ApplicationEvent๋งŒ ๋ฐœํ–‰ํ•˜๊ณ , Kafka ์ „์†ก์€ OutboxBridgeEventListener๊ฐ€ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ด€์‹ฌ์‚ฌ ๋ถ„๋ฆฌ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1)

10-155: LGTM! ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ์˜ ๋™์ž‘์„ ์ž˜ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

ProductMetrics์˜ ์ฃผ์š” ๋™์ž‘(์ข‹์•„์š”, ํŒ๋งค๋Ÿ‰, ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ, ๋ฉฑ๋“ฑ์„ฑ, ๋ฒ„์ „ ๊ด€๋ฆฌ)์— ๋Œ€ํ•œ ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€๊ฐ€ ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java (1)

21-217: LGTM! ์„œ๋น„์Šค ๋ ˆ์ด์–ด์˜ ๋™์ž‘์„ ์ž˜ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฒ„์ „ ๊ธฐ๋ฐ˜ ์—…๋ฐ์ดํŠธ ๋กœ์ง(์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ ์Šคํ‚ต, ์ตœ์‹  ์ด๋ฒคํŠธ ์ ์šฉ), ๋ฉ”ํŠธ๋ฆญ ์ƒ์„ฑ/์—…๋ฐ์ดํŠธ, ์œ ํšจํ•˜์ง€ ์•Š์€ ์ˆ˜๋Ÿ‰ ๋ฌด์‹œ ๋“ฑ ์ฃผ์š” ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ์ž˜ ํ…Œ์ŠคํŠธ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java (1)

29-299: LGTM! OutboxEventPublisher์˜ ์ฃผ์š” ๋™์ž‘์„ ์ž˜ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฐฐ์น˜ ์ฒ˜๋ฆฌ, ๊ฐœ๋ณ„ ์‹คํŒจ ์‹œ ๊ณ„์† ์ง„ํ–‰, ์ƒํƒœ ์ „ํ™˜(PUBLISHED/FAILED), JSON ์—ญ์ง๋ ฌํ™” ์‹คํŒจ ์ฒ˜๋ฆฌ, ํŒŒํ‹ฐ์…˜ ํ‚ค ๊ฒ€์ฆ ๋“ฑ ์ค‘์š”ํ•œ ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ๋ชจ๋‘ ํ…Œ์ŠคํŠธ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java (1)

87-116: publishEvent ๋ฉ”์„œ๋“œ ๊ตฌํ˜„ ๊ฒ€ํ† 

Kafka ํ—ค๋”์— eventId์™€ version์„ ํฌํ•จ์‹œ์ผœ ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๋ฅผ ์ง€์›ํ•˜๋Š” ์„ค๊ณ„๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์œ„์—์„œ ์–ธ๊ธ‰ํ•œ ๋น„๋™๊ธฐ ์ „์†ก ์ด์Šˆ๋ฅผ ํ•ด๊ฒฐํ•˜๋ฉด ์ „์ฒด์ ์œผ๋กœ ์•ˆ์ •์ ์ธ ๊ตฌํ˜„์ด ๋ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java (1)

13-39: JPA ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์œ„์ž„ ๊ตฌํ˜„ LGTM

๋„๋ฉ”์ธ ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ JPA ๋ ˆํฌ์ง€ํ† ๋ฆฌ์— ๊น”๋”ํ•˜๊ฒŒ ์œ„์ž„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. findById์—์„œ ์กฐํšŒ ์‹คํŒจ ์‹œ ์˜ˆ์™ธ๋ฅผ ๋˜์ง€๋Š” ๊ฒƒ์€ ๋„๋ฉ”์ธ ๊ณ„์ธต์—์„œ Optional ๋Œ€์‹  ํ™•์ •๋œ ๊ฐ’์„ ๊ธฐ๋Œ€ํ•˜๋Š” ๊ฒฝ์šฐ์— ์ ํ•ฉํ•œ ์„ค๊ณ„์ž…๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java (1)

44-58: ๋ฒ„์ „ ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง LGTM

shouldUpdate(eventVersion) ์ฒดํฌ๋ฅผ ํ†ตํ•ด ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๋ฅผ ์Šคํ‚ตํ•˜๋Š” ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๊ฐ€ ์ž˜ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋น„๊ด€์  ๋ฝ๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์—ฌ ๋™์‹œ์„ฑ ์ œ์–ด๋„ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ํ•™์Šต๋œ ๋‚ด์šฉ์— ๋”ฐ๋ฅด๋ฉด, ์ด ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ๋Š” ์œ ์ฆˆ์ผ€์ด์Šค๋ณ„๋กœ ์˜๋„์ ์œผ๋กœ ๋‹ค๋ฅธ ๋ฝ ์ „๋žต์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1)

37-39: ๋น„๊ด€์  ๋ฝ ์ฟผ๋ฆฌ ๊ตฌํ˜„ LGTM

PESSIMISTIC_WRITE ๋ฝ ๋ชจ๋“œ์™€ ๋ช…์‹œ์  JPQL ์ฟผ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•œ findByProductIdForUpdate ๊ตฌํ˜„์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ํ•™์Šต๋œ ํŒจํ„ด์— ๋”ฐ๋ฅด๋ฉด ์ด ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ๋Š” ๋™์‹œ์„ฑ ์ œ์–ด๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ ๋น„๊ด€์  ๋ฝ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java (1)

51-68: @async์™€ @TransactionalEventListener ํŒจํ„ด์˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ณ ๋ ค

@Async์™€ @TransactionalEventListener(AFTER_COMMIT) ์กฐํ•ฉ์€ ์›๋ณธ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ํ›„ ๋น„๋™๊ธฐ๋กœ Outbox ์ €์žฅ์„ ์ˆ˜ํ–‰ํ•˜์—ฌ ์—๋Ÿฌ ๊ฒฉ๋ฆฌ๋ฅผ ์ž˜ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ์ด๋ฏธ @EnableAsync๊ฐ€ ๊ตฌ์„ฑ๋˜์–ด ์žˆ๊ณ  Spring Boot๊ฐ€ ์ž๋™์œผ๋กœ TaskExecutor๋ฅผ ์ œ๊ณตํ•˜๋ฏ€๋กœ ์ถ”๊ฐ€ ์„ค์ •์ด ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ void ๋ฐ˜ํ™˜ ํƒ€์ž…์˜ ๋ฉ”์„œ๋“œ์—์„œ๋Š” ๋ฐœ์ƒํ•œ ์˜ˆ์™ธ๊ฐ€ ํ˜ธ์ถœ์ž์—๊ฒŒ ์ „ํŒŒ๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ, ๋น„๋™๊ธฐ ์ž‘์—… ์ค‘ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ๋กœ๊น…๋งŒ ๋˜๊ณ  ์›๋ณธ ํŠธ๋žœ์žญ์…˜์—๋Š” ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java (1)

24-63: LGTM! ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ ํŒจํ„ด์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค UNIQUE ์ œ์•ฝ์กฐ๊ฑด์„ ํ™œ์šฉํ•œ ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ ๊ตฌํ˜„์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. isAlreadyHandled()์™€ markAsHandled() ์กฐํ•ฉ ์‚ฌ์šฉ ์‹œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” TOCTOU(Time-of-check to time-of-use) ์ด์Šˆ๋Š” UNIQUE ์ œ์•ฝ์กฐ๊ฑด์ด ์ตœ์ข…์ ์œผ๋กœ ๋ฐฉ์ง€ํ•˜๋ฏ€๋กœ, DataIntegrityViolationException์„ ์žฌ๋ฐœ์ƒ์‹œ์ผœ ํ˜ธ์ถœ์ž๊ฐ€ ๋™์‹œ์„ฑ ์ค‘๋ณต์„ ์ฒ˜๋ฆฌํ•˜๋„๋ก ํ•˜๋Š” ๋ฐฉ์‹์ด ์˜ฌ๋ฐ”๋ฆ…๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java (1)

21-38: LGTM! ํ‘œ์ค€ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์–ด๋Œ‘ํ„ฐ ํŒจํ„ด์ด ์ ์ ˆํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋กœ์˜ ๋‹จ์ˆœ ์œ„์ž„ ๊ตฌ์กฐ๊ฐ€ ๋ช…ํ™•ํ•˜๊ณ  ๊น”๋”ํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (3)

58-65: LGTM! ์ƒ์„ฑ์ž ์ดˆ๊ธฐํ™”๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

๋ชจ๋“  ์นด์šดํ„ฐ์™€ ๋ฒ„์ „์„ 0์œผ๋กœ ์ดˆ๊ธฐํ™”ํ•˜๊ณ  ์ƒ์„ฑ ์‹œ์ ์„ ๊ธฐ๋กํ•˜๋Š” ๋กœ์ง์ด ๋ช…ํ™•ํ•ฉ๋‹ˆ๋‹ค.


79-85: ์ข‹์•„์š” ๊ฐ์†Œ ๋กœ์ง์ด ์•ˆ์ „ํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์Œ์ˆ˜ ๋ฐฉ์ง€ ๊ฐ€๋“œ๊ฐ€ ์ ์ ˆํ•˜๋ฉฐ, ์กฐ๊ฑด ๋ถˆ์ถฉ์กฑ ์‹œ ์กฐ์šฉํžˆ ๋ฌด์‹œํ•˜๋Š” ๋ฐฉ์‹์€ ๋ฐฉ์–ด์  ์ฝ”๋”ฉ์œผ๋กœ ํ—ˆ์šฉ๋ฉ๋‹ˆ๋‹ค.


119-126: ๋ฒ„์ „ ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ์ˆœ์„œ ๋ณด์žฅ ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

shouldUpdate ๋ฉ”์„œ๋“œ๋Š” null ์ด๋ฒคํŠธ ๋ฒ„์ „์„ ํ—ˆ์šฉ(ํ•˜์œ„ ํ˜ธํ™˜์„ฑ)ํ•˜๋ฉด์„œ ์ด๋ฒคํŠธ ๋ฒ„์ „ ๋น„๊ต๋ฅผ ํ†ตํ•ด ์˜ค๋ž˜๋œ ์ด๋ฒคํŠธ๊ฐ€ ์ตœ์‹  ๋ฉ”ํŠธ๋ฆญ์„ ๋ฎ์–ด์“ฐ๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋กœ์ง์€ ProductMetricsService์—์„œ ๋น„๊ด€์  ๋ฝ(findByProductIdForUpdate)๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉ๋˜์–ด ๋™์‹œ์„ฑ ์ œ์–ด๊ฐ€ ๋ณด์žฅ๋ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (3)

22-68: LGTM! Outbox ์—”ํ‹ฐํ‹ฐ ๊ตฌ์กฐ๊ฐ€ ํŠธ๋žœ์žญ์…”๋„ ์•„์›ƒ๋ฐ•์Šค ํŒจํ„ด์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.

eventId์˜ UNIQUE ์ œ์•ฝ์กฐ๊ฑด์€ ์ค‘๋ณต ์ด๋ฒคํŠธ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ณ , (status, created_at) ์ธ๋ฑ์Šค๋Š” PENDING ์ด๋ฒคํŠธ์˜ ํšจ์œจ์ ์ธ ํด๋ง์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.


69-90: LGTM! ๋นŒ๋” ํŒจํ„ด ์ƒ์„ฑ์ž๊ฐ€ ์ ์ ˆํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์ดˆ๊ธฐ ์ƒํƒœ๋ฅผ PENDING์œผ๋กœ ์„ค์ •ํ•˜๊ณ  ์ƒ์„ฑ ์‹œ์ ์„ ๊ธฐ๋กํ•˜๋Š” ๋กœ์ง์ด ๋ช…ํ™•ํ•ฉ๋‹ˆ๋‹ค.


92-105: ์ƒํƒœ ์ „ํ™˜ ๋ฉ”์„œ๋“œ๊ฐ€ ๋‹จ์ˆœํ•˜๊ณ  ๋ช…ํ™•ํ•ฉ๋‹ˆ๋‹ค.

ํ˜„์žฌ ์ƒํƒœ๋ฅผ ๊ฒ€์ฆํ•˜์ง€ ์•Š๊ณ  ์ „ํ™˜์„ ํ—ˆ์šฉํ•˜๋Š” ๋ฐฉ์‹์€ ์•„์›ƒ๋ฐ•์Šค ํŒจํ„ด์—์„œ ์žฌ์‹œ๋„ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค. markAsPublished()๋ฅผ ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœํ•ด๋„ ๋ฉฑ๋“ฑ์ ์ด๋ฏ€๋กœ ๋ฌธ์ œ์—†์Šต๋‹ˆ๋‹ค.

modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java (2)

95-123: LGTM! ํ…Œ์ŠคํŠธ ํ† ํ”ฝ ์žฌ์ƒ์„ฑ ๋กœ์ง์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

3๊ฐœ ํŒŒํ‹ฐ์…˜, 1๊ฐœ ๋ ˆํ”Œ๋ฆฌ์นด, min.insync.replicas=1 ์„ค์ •์€ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์— ์ ํ•ฉํ•˜๋ฉฐ, ํ† ํ”ฝ๋ณ„ ์—๋Ÿฌ ํ•ธ๋“ค๋ง์„ ํ†ตํ•ด ๋ถ€๋ถ„ ์‹คํŒจ๋ฅผ ํ—ˆ์šฉํ•ฉ๋‹ˆ๋‹ค.


187-193: LGTM! AdminClient ์ƒ์„ฑ ๋กœ์ง์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

KafkaAdmin์˜ ์„ค์ •์—์„œ ๋ถ€ํŠธ์ŠคํŠธ๋žฉ ์„œ๋ฒ„๋ฅผ ๊ฐ€์ ธ์˜ค๋ฉฐ, localhost:19092 ํด๋ฐฑ์€ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ํ•ฉ๋ฆฌ์ ์ž…๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” KafkaTestContainersConfig๊ฐ€ ์˜ฌ๋ฐ”๋ฅธ ๋™์  ํฌํŠธ๋ฅผ ์„ค์ •ํ•˜๋ฏ€๋กœ ํด๋ฐฑ์ด ์‚ฌ์šฉ๋  ๊ฐ€๋Šฅ์„ฑ์€ ๋‚ฎ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java (4)

164-224: LGTM! ์ฃผ๋ฌธ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

OrderCreated ์ด๋ฒคํŠธ์˜ ์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ์ˆœํšŒํ•˜๋ฉฐ ๊ฐ ์ƒํ’ˆ์˜ ํŒ๋งค๋Ÿ‰์„ ์ฆ๊ฐ€์‹œํ‚ค๋Š” ๋กœ์ง์ด ์ •ํ™•ํ•˜๋ฉฐ, ์—๋Ÿฌ ํ•ธ๋“ค๋ง ํŒจํ„ด๋„ ์ผ๊ด€์„ฑ ์žˆ๊ฒŒ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.


256-312: LGTM! ์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง์ด ์ผ๊ด€์„ฑ ์žˆ๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๋‹ค๋ฅธ ๋ฆฌ์Šค๋„ˆ ๋ฉ”์„œ๋“œ๋“ค๊ณผ ๋™์ผํ•œ ํŒจํ„ด์„ ๋”ฐ๋ฅด๋ฉฐ, ProductViewed ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๊ฐ€ ๊ฐ„๋‹จ๋ช…๋ฃŒํ•ฉ๋‹ˆ๋‹ค.


360-387: LGTM! ํ—ค๋” ์ถ”์ถœ ๋กœ์ง์ด ์•ˆ์ „ํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

eventId์™€ version ์ถ”์ถœ ์‹œ null ์ฒ˜๋ฆฌ์™€ ํŒŒ์‹ฑ ์—๋Ÿฌ ํ•ธ๋“ค๋ง์ด ์ ์ ˆํ•˜๋ฉฐ, UTF-8 ์ธ์ฝ”๋”ฉ ์‚ฌ์šฉ๋„ ํ‘œ์ค€์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค.


129-147: ์—๋Ÿฌ ํ•ธ๋“ค๋ง ํŒจํ„ด์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

๊ฐœ๋ณ„ ๋ ˆ์ฝ”๋“œ ์—๋Ÿฌ๋ฅผ ๋กœ๊น… ํ›„ ๊ณ„์† ์ง„ํ–‰ํ•˜๊ณ , ๋ฐฐ์น˜ ๋ ˆ๋ฒจ ์—๋Ÿฌ๋Š” ์žฌ๋ฐœ์ƒ์‹œ์ผœ ์ปค๋ฐ‹์„ ๋ฐฉ์ง€ํ•˜๋Š” ๋ฐฉ์‹์ด ํ•ฉ๋ฆฌ์ ์ž…๋‹ˆ๋‹ค. DataIntegrityViolationException์„ ๋™์‹œ์„ฑ ์ค‘๋ณต์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒƒ๋„ ์˜ฌ๋ฐ”๋ฆ…๋‹ˆ๋‹ค. ์ด ๊ตฌํ˜„์€ ์ ์ ˆํ•œ At-Least-Once ์ „๋‹ฌ ๋ณด์žฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java (1)

35-76: LGTM! ์ œ๋„ค๋ฆญ ํƒ€์ž… ๋ณ€๊ฒฝ์œผ๋กœ ํƒ€์ž… ์•ˆ์ „์„ฑ์ด ํ–ฅ์ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

ํ‚ค ํƒ€์ž…์„ Object์—์„œ String์œผ๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ Kafka ๋ฉ”์‹œ์ง€์˜ ํŒŒํ‹ฐ์…˜ ํ‚ค๊ฐ€ ํ•ญ์ƒ ๋ฌธ์ž์—ด์ž„์„ ๋ช…์‹œ์ ์œผ๋กœ ํ‘œํ˜„ํ•˜๋ฉฐ, ์‹ค์ˆ˜๋กœ ๋‹ค๋ฅธ ํƒ€์ž…์˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.

@minor7295 minor7295 changed the title Feature/kafka (#36) [volume - 8] Decoupling with Kafka Dec 19, 2025
@minor7295 minor7295 self-assigned this Dec 19, 2025
@minor7295 minor7295 added the enhancement New feature or request label Dec 19, 2025
@minor7295 minor7295 marked this pull request as ready for review December 19, 2025 03:25
@minor7295 minor7295 force-pushed the base-pr-round8 branch 2 times, most recently from c123b34 to bf7b8e3 Compare December 19, 2025 03:57
* chore:kafka producer ์„ค์ •

* chore: kafka ํ† ํ”ฝ ์ž๋™ ์ƒ์„ฑ ์„ค์ • ์ถ”๊ฐ€

* feat: kafka event publisher, comsumer ์ถ”๊ฐ€

* test: ์ง‘๊ณ„ ๋„๋ฉ”์ธ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€

* feat: ์ง‘๊ณ„ ๋„๋ฉ”์ธ domain ๋ ˆ์ด์–ด ๊ตฌํ˜„

* feat: ์ง‘๊ณ„ ๋„๋ฉ”์ธ infra ๋ ˆ์ด์–ด ๊ตฌํ˜„

* chore: kafka ํ† ํ”ฝ ์ž๋™ ์ƒ์„ฑ ์„ค์ • ์ถ”๊ฐ€

* chore: kafka ๋นŒ๋“œ ์˜์กด์„ฑ ์ถ”๊ฐ€

* test: ์ง‘๊ณ„ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€

* feat: ์ง‘๊ณ„ ์„œ๋น„์Šค ๋กœ์ง ๊ตฌํ˜„

* test: kafka consumer ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€

* feat: kafka comsumer ๊ตฌํ˜„

* outbox ํŒจํ„ด ์ ์šฉ์œ„ํ•ด ๊ธฐ์กด kafka ์„ค์ • ์‚ญ์ œ

* test: outboxevent ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€

* feat: outbox ๋„๋ฉ”์ธ ๊ตฌํ˜„

* feat: outbox infrastructure repository๊ตฌํ˜„

* metric ์˜คํƒ€ ์ˆ˜์ •

* refactor: consumer ๊ด€๋ จ ๋กœ์ง๋“ค์€ commerce-streamer ๋ชจ๋“ˆ๋กœ ์ด๋™

* test: outbox ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€

* test: outbox ๊ตฌํ˜„

* outbox event listener ๊ตฌํ˜„

* feat: ์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ ์ถ”๊ฐ€

* feat: ์ƒํ’ˆ ์กฐํšŒ์‹œ ์ด๋ฒคํŠธ ๋ฐœํ–‰

* chore: kafka ์„ค์ • ์ˆ˜์ •

* fix: outbox ์ฒ˜๋ฆฌ๋˜์ง€ ์•Š๋Š” ์˜ค๋ฅ˜ ์ˆ˜์ •

* chore: ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์‹คํ–‰์‹œ kafka ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก test container ์„ค์ • ์ถ”๊ฐ€

* test: offset.reset: latest ์„ค์ •์ด ์ œ๋Œ€๋กœ ์ ์šฉ๋˜๋Š”์ง€ ํ™•์ธํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€

* test: kafka ํŒŒํ‹ฐ์…˜ ํ‚ค ์„ค์ •์— ๋Œ€ํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€

* chore: commerce-api ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ ์นดํ”„์นด ์‚ฌ์šฉํ•˜๋„๋ก ์„คใ„น์ •

* test: event id ๊ธฐ์ค€์œผ๋กœ ํ•œ ๋ฒˆ๋งŒ publish, consumeํ•˜๋Š” ๊ฒƒ์„ ๊ฒ€์ฆํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€

* chore: ์ถฉ๋Œ ๋ฐœ์ƒํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ˆ˜์ •

* feat: event id ๊ธฐ์ค€ 1ํšŒ ์ฒ˜๋ฆฌ๋˜๋„๋ก ๋กœ์ง ๊ตฌํ˜„

* test: ๋ฒ„์ „ ๊ธฐ์ค€์œผ๋กœ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ์ฒ˜๋ฆฌํ•˜๋„๋ก ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ˆ˜์ •

* feat: version ๊ธฐ์ค€์œผ๋กœ ์ตœ์‹  ์ด๋ฒคํŠธ๋งŒ ์ฒ˜๋ฆฌํ•˜๋„๋ก ํ•จ

* test: ์ค‘๋ณต ๋ฉ”์‹œ์ง€ ์žฌ์ „์†ก ์‹œ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ๋˜๋Š”์ง€ ๊ฒ€์ฆํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€

* feat: kafka ์ด๋ฒคํŠธ publish ํ•  ๋•Œ ์ฝœ๋ฐฑ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋ฒคํŠธ ์œ ์‹ค ๋ฐฉ์ง€

* feat: kafka๋ฉ”์‹œ์ง€ ํ—ค๋”์— event type ์ถ”๊ฐ€

* feat: ๋ฒ„์ „ ์กฐํšŒ์™€ ์ €์žฅ ์‚ฌ์ด์˜ ๊ฒฝ์Ÿ ์กฐ๊ฑด ๊ฐ€๋Šฅ์„ฑ ํ•ด๊ฒฐ

* feat: ์‹ ๊ทœ ์ƒํ’ˆ ๋“ฑ๋ก์‹œ event ๋ฐœํ–‰์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๊ฒฝํ•ฉ ๋ฌธ์ œ ์ˆ˜์ •
@looper88888
Copy link
Contributor

โœ… Checklist

๐ŸŽพ Producer
[ ] ๋„๋ฉ”์ธ(์• ํ”Œ๋ฆฌ์ผ€์ด์…˜) ์ด๋ฒคํŠธ ์„ค๊ณ„
[ ] Producer ์•ฑ์—์„œ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ๋ฐœํ–‰ (catalog-events, order-events, ๋“ฑ)
[ ] PartitionKey ๊ธฐ๋ฐ˜์˜ ์ด๋ฒคํŠธ ์ˆœ์„œ ๋ณด์žฅ
[ ] ๋ฉ”์„ธ์ง€ ๋ฐœํ–‰์ด ์‹คํŒจํ–ˆ์„ ๊ฒฝ์šฐ์— ๋Œ€ํ•ด ๊ณ ๋ฏผํ•ด๋ณด๊ธฐ

โšพ Consumer
[ ] Consumer ๊ฐ€ Metrics ์ง‘๊ณ„ ์ฒ˜๋ฆฌ
[ ] event_handled ํ…Œ์ด๋ธ”์„ ํ†ตํ•œ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ ๊ตฌํ˜„
[ ] ์žฌ๊ณ  ์†Œ์ง„ ์‹œ ์ƒํ’ˆ ์บ์‹œ ๊ฐฑ์‹ 
[ ] ์ค‘๋ณต ๋ฉ”์„ธ์ง€ ์žฌ์ „์†ก ํ…Œ์ŠคํŠธ โ†’ ์ตœ์ข… ๊ฒฐ๊ณผ๊ฐ€ ํ•œ ๋ฒˆ๋งŒ ๋ฐ˜์˜๋˜๋Š”์ง€ ํ™•์ธ

@coderabbitai ์ฒดํฌ๋ฆฌ์ŠคํŠธ ํ™•์ธํ•ด

@coderabbitai
Copy link

coderabbitai bot commented Dec 20, 2025

Tip

For best results, initiate chat on the files or code changes.

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
# Search for stock depletion related code
echo "=== Searching for StockDepleted events ==="
rg -i "stockdepleted" --type java -C 3

echo ""
echo "=== Searching for cache refresh or invalidation ==="
rg -i "cache.*refresh|cache.*invalidate|evict" --type java -C 3

echo ""
echo "=== Searching for product cache operations ==="
rg -i "product.*cache" --type java -C 3

echo ""
echo "=== Listing all event types in domain ==="
fd -e java -p "Event\.java" apps/

Length of output: 18813


@looper88888, ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ฒ€ํ† ํ–ˆ์Šต๋‹ˆ๋‹ค:

๐ŸŽพ Producer ๊ตฌํ˜„ ์ƒํƒœ

โœ… ๋„๋ฉ”์ธ(์• ํ”Œ๋ฆฌ์ผ€์ด์…˜) ์ด๋ฒคํŠธ ์„ค๊ณ„

  • ProductEvent.ProductViewed, LikeEvent.LikeAdded/LikeRemoved, OrderEvent.OrderCreated ๋“ฑ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ์ •์˜ ์™„๋ฃŒ
  • ์ถ”๊ฐ€๋กœ CouponEvent, PaymentEvent, PointEvent๋„ ์กด์žฌ

โœ… Producer ์•ฑ์—์„œ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ๋ฐœํ–‰

  • OutboxBridgeEventListener๊ฐ€ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹ ํ•˜์—ฌ OutboxEvent๋กœ ๋ณ€ํ™˜
  • OutboxEventPublisher๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ pending ์ด๋ฒคํŠธ๋ฅผ Kafka๋กœ ๋ฐœํ–‰
  • like-events, order-events, product-events ํ† ํ”ฝ์œผ๋กœ ๋ฐœํ–‰ ๊ตฌํ˜„๋จ

โœ… PartitionKey ๊ธฐ๋ฐ˜์˜ ์ด๋ฒคํŠธ ์ˆœ์„œ ๋ณด์žฅ

  • OutboxEvent์— partitionKey ํ•„๋“œ ์กด์žฌ
  • Kafka ๋ฉ”์‹œ์ง€ ํ—ค๋”์— KafkaHeaders.KEY๋กœ partitionKey ์„ค์ •๋จ
  • ๋™์ผ aggregateId(์˜ˆ: productId, orderId)์— ๋Œ€ํ•œ ์ˆœ์„œ ๋ณด์žฅ

โœ… ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰ ์‹คํŒจ ์‹œ ์ฒ˜๋ฆฌ

  • OutboxEventPublisher์—์„œ ๋ฐœํ–‰ ์‹คํŒจ ์‹œ OutboxStatus.FAILED๋กœ ๋งˆํ‚น
  • ์‹คํŒจํ•œ ์ด๋ฒคํŠธ๋Š” ๋กœ๊ทธ ๊ธฐ๋ก ํ›„ ๊ณ„์† ์ฒ˜๋ฆฌ (๋‹ค๋ฅธ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์ฐจ๋‹จ ์•ˆ ํ•จ)
  • ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ๋‚ด์—์„œ ์ƒํƒœ ๋ณ€๊ฒฝ ์ €์žฅ

โšพ Consumer ๊ตฌํ˜„ ์ƒํƒœ

โœ… Consumer๊ฐ€ Metrics ์ง‘๊ณ„ ์ฒ˜๋ฆฌ

  • ProductMetricsConsumer๊ฐ€ like-events, order-events, product-events ์†Œ๋น„
  • ProductMetricsService๋กœ likeCount, salesCount, viewCount ์ง‘๊ณ„
  • ProductMetrics ์—”ํ‹ฐํ‹ฐ์— pessimistic lock์œผ๋กœ ๋™์‹œ์„ฑ ์ œ์–ด

โœ… event_handled ํ…Œ์ด๋ธ”์„ ํ†ตํ•œ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ ๊ตฌํ˜„

  • EventHandled ์—”ํ‹ฐํ‹ฐ์™€ EventHandledService ๊ตฌํ˜„ ์™„๋ฃŒ
  • eventId ๊ธฐ๋ฐ˜ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€ (primary key๋กœ uniqueness ๋ณด์žฅ)
  • DataIntegrityViolationException ์ฒ˜๋ฆฌ๋กœ ๋™์‹œ ์ค‘๋ณต ์ด๋ฒคํŠธ ํ—ˆ์šฉ

โŒ ์žฌ๊ณ  ์†Œ์ง„ ์‹œ ์ƒํ’ˆ ์บ์‹œ ๊ฐฑ์‹ 

  • ํ˜„์žฌ PR์— StockDepletedEvent ๋˜๋Š” ์žฌ๊ณ  ์†Œ์ง„ ๊ด€๋ จ Consumer๊ฐ€ ์—†์Œ
  • ProductCacheService์˜ evict() ์ธํ”„๋ผ๋Š” ์กด์žฌํ•˜์ง€๋งŒ, ์žฌ๊ณ  ์†Œ์ง„ ์ด๋ฒคํŠธ ๊ตฌ๋…/์ฒ˜๋ฆฌ ๋กœ์ง ๋ˆ„๋ฝ
  • ๐Ÿ”ด ๊ตฌํ˜„ ํ•„์š”: StockDepleted ์ด๋ฒคํŠธ Consumer ์ถ”๊ฐ€ ๋ฐ ์บ์‹œ ๋ฌดํšจํ™” ๋กœ์ง ๊ตฌํ˜„

โœ… ์ค‘๋ณต ๋ฉ”์‹œ์ง€ ์žฌ์ „์†ก ํ…Œ์ŠคํŠธ

  • ProductMetricsConsumerTest์— ์ค‘๋ณต ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ํ…Œ์ŠคํŠธ ์กด์žฌ
  • ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ eventId๋Š” ์Šคํ‚ต๋˜๋Š” ๋กœ์ง ๊ฒ€์ฆ๋จ
  • EventHandledService.isAlreadyHandled() ์ฒดํฌ๋กœ ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ

๐Ÿ“ ์ข…ํ•ฉ ์š”์•ฝ

์™„๋ฃŒ: 7๊ฐœ ํ•ญ๋ชฉ
๋ฏธ์™„๋ฃŒ: 1๊ฐœ ํ•ญ๋ชฉ (์žฌ๊ณ  ์†Œ์ง„ ์‹œ ์ƒํ’ˆ ์บ์‹œ ๊ฐฑ์‹ )

์žฌ๊ณ  ์†Œ์ง„ ์บ์‹œ ๊ฐฑ์‹  ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜๋ ค๋ฉด:

  1. StockDepletedEvent consumer ๊ตฌํ˜„ ํ•„์š”
  2. ํ•ด๋‹น consumer์—์„œ productCacheService.evict() ํ˜ธ์ถœํ•˜์—ฌ ์ƒํ’ˆ ์บ์‹œ ๋ฌดํšจํ™”
  3. ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ ๊ถŒ์žฅ

๐Ÿง  Learnings used
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:50.009Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.

Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:16.424Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.

@minor7295 minor7295 merged commit 3af88f9 into Loopers-dev-lab:minor7295 Dec 21, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants