Skip to content

Conversation

@yeonjiyeon
Copy link
Collaborator

@yeonjiyeon yeonjiyeon commented Dec 19, 2025

๐Ÿ“Œ Summary

  • Kafka ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ•: commerce-api์™€ commerce-streamer ๊ฐ„์˜ ๋ฐ์ดํ„ฐ ์ „์†ก์„ ์œ„ํ•ด Kafka๋ฅผ ๋„์ž…ํ•˜๊ณ , ๋ถ„์‚ฐ ํ™˜๊ฒฝ์—์„œ์˜ ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์„ ํ•ด๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค.
  • Transactional Outbox ํŒจํ„ด ๊ตฌํ˜„: DB ํŠธ๋žœ์žญ์…˜๊ณผ ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰์˜ ์›์ž์„ฑ์„ ๋ณด์žฅํ•˜์—ฌ ๋ฉ”์‹œ์ง€ ์œ ์‹ค ์—†๋Š” At Least Once ๋ฐœํ–‰ ํ™˜๊ฒฝ์„ ์กฐ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์บ์‹œ ์ •ํ•ฉ์„ฑ ํ™•๋ณด: ์žฌ๊ณ  ์†Œ์ง„ ์ด๋ฒคํŠธ๋ฅผ ๊ตฌ๋…ํ•˜์—ฌ Redis ์บ์‹œ๋ฅผ ๋ฌดํšจํ™”(Evict)ํ•จ์œผ๋กœ์จ ์‚ฌ์šฉ์ž์—๊ฒŒ ์‹ค์‹œ๊ฐ„ ํ’ˆ์ ˆ ์ƒํƒœ๋ฅผ ๋…ธ์ถœํ•˜๋„๋ก ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
  • ๋ฉฑ๋“ฑ์„ฑ ๊ธฐ๋ฐ˜์˜ Exactly-Once ์‹คํ–‰: event_handled ํ…Œ์ด๋ธ”์„ ํ†ตํ•ด ์ค‘๋ณต ์œ ์ž…๋œ ์ด๋ฒคํŠธ๋ฅผ ๊ฑธ๋Ÿฌ๋‚ด์–ด ์ตœ์ข… ์ˆ˜์น˜๊ฐ€ ๋‹จ ํ•œ ๋ฒˆ๋งŒ ๋ฐ˜์˜๋˜๋„๋ก ์„ค๊ณ„ํ–ˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ’ฌ Review Points

  1. ์„ค๊ณ„ ํฌ์ธํŠธ: ๋ฐœํ–‰ ๋ณด์žฅ๊ณผ ์ค‘๋ณต ๋ฐฉ์ง€ (Producer & Consumer)
  • Producer: ๋„คํŠธ์›Œํฌ ์žฅ์• ๋กœ ์ธํ•œ ๋ฐœํ–‰ ์‹คํŒจ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด Outbox ํ…Œ์ด๋ธ”์— ์ด๋ฒคํŠธ๋ฅผ ๋จผ์ € ์ €์žฅํ•˜๊ณ , OutboxRelay ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ๋ฏธ๋ฐœํ–‰ ๊ฑด์„ ์žฌ์‹œ๋„ํ•˜๋„๋ก ํ•˜์—ฌ At Least Once๋ฅผ ๋ณด์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.
  • Consumer: ํ”„๋กœ๋“€์„œ์˜ ์žฌ์ „์†ก์œผ๋กœ ์ธํ•œ ์ค‘๋ณต ์ˆ˜์‹ ์„ ๋Œ€๋น„ํ•ด event_id PK ๊ธฐ๋ฐ˜์˜ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ๊ตฌํ˜„ํ•˜์—ฌ At Most Once ์‹คํ–‰์„ ๋ณด์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.

โœ… Checklist

Producer

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

Consumer

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

Summary by CodeRabbit

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

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

    • ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์ถ”์  ์‹œ์Šคํ…œ ์ถ”๊ฐ€ (์กฐํšŒ์ˆ˜, ์ข‹์•„์š”, ํŒ๋งค๋Ÿ‰)
    • ์ฃผ๋ฌธ ๋ฐ ์ƒํ’ˆ ์ด๋ฒคํŠธ ์นดํ”„์นด ์ŠคํŠธ๋ฆฌ๋ฐ ์ง€์›
  • ๊ฐœ์„  ์‚ฌํ•ญ

    • ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜ ์ ์šฉ์œผ๋กœ ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์•ˆ์ •์„ฑ ํ–ฅ์ƒ
    • ๊ฒฐ์ œ ๋ณต๊ตฌ ํ”„๋กœ์„ธ์Šค ๊ฐœ์„  (ํƒ€์ž„์กด ์ธ์‹ ์ฒ˜๋ฆฌ)
    • ์บ์‹œ ์‚ญ์ œ ๊ธฐ๋Šฅ ํ™•๋Œ€๋กœ ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” ๊ฐœ์„ 
  • ์‹œ์Šคํ…œ ์•ˆ์ •์„ฑ

    • ์•„์›ƒ๋ฐ•์Šค ํŒจํ„ด์œผ๋กœ ์ด๋ฒคํŠธ ์ „๋‹ฌ ์‹ ๋ขฐ์„ฑ ๊ฐ•ํ™”

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

- PaymentRepository ๋ฐ ๊ด€๋ จ ๋ฉ”์„œ๋“œ์—์„œ LocalDateTime ๋Œ€์‹  ZonedDateTime ์‚ฌ์šฉํ•˜๋„๋ก ์ˆ˜์ •
- ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ์˜ ํŠธ๋žœ์žญ์…˜ ์ „ํŒŒ ๋ฐฉ์‹์„ Propagation.REQUIRES_NEW๋กœ ๊ฐœ์„ 
- ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” ๋ฐ์ดํ„ฐ์†Œ์Šค ์„ค์ • ํŒŒ์ผ(jpa.yml, redis.yml) ์‚ญ์ œ
- KafkaConfig ํŒจํ‚ค์ง€๋ช… ์˜คํƒ€ ์ˆ˜์ • (confg โ†’ config)
- build.gradle.kts์— kafka ๋ชจ๋“ˆ ์˜์กด์„ฑ ์ถ”๊ฐ€ ๋ฐ application.yml ์„ค์ • ๋ฐ˜์˜
- `OutboxEvent` ์—”ํ‹ฐํ‹ฐ ๋ฐ JPA ๋งคํ•‘ ์ถ”๊ฐ€๋กœ Outbox ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ๊ด€๋ฆฌ ๊ตฌํ˜„
- `OutboxService`๋ฅผ ํ†ตํ•ด ์ด๋ฒคํŠธ ์ €์žฅ ๋ฐ ์ง๋ ฌํ™” ๋กœ์ง ์ฒ˜๋ฆฌ
- `OutboxRepository`๋กœ ๋น„๋™๊ธฐ ์ด๋ฒคํŠธ ์กฐํšŒ ๋ฐ ๊ด€๋ฆฌ ๊ฐ€๋Šฅ
- `OutboxEvent` ๊ตฌ์กฐ ํ™•์žฅ: ๊ณ ์œ  `eventId` ํ•„๋“œ ์ถ”๊ฐ€ ๋ฐ ์ €์žฅ ๋กœ์ง ๊ฐœ์„ .
- `LikeCreatedEvent`์˜ ์ƒ์„ฑ ๋ฉ”์„œ๋“œ(`of`) ๋„์ž…์œผ๋กœ eventId ์ž๋™ ์ƒ์„ฑ ๋ฐ ํƒ€์ž„์Šคํƒฌํ”„ ์ถ”๊ฐ€.
- Kafka ์—ฐ๋™์„ ์œ„ํ•œ `LikeEventOutboxHandler` ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€.
- `OutboxService`์— ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์™„๋ฃŒ ์ƒํƒœ ๊ด€๋ฆฌ ๋กœ์ง(`markPublished`) ์ถ”๊ฐ€.
- `LikeFacade` ๋ฐ `LikeCountAggregateListener`์—์„œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง ํ™•์žฅ ๋ฐ ๊ด€๋ จ ํด๋ž˜์Šค ํŒจํ‚ค์ง€ ๊ตฌ์กฐ ์ •๋ฆฌ.
- `LikeEventConsumer` ์ถ”๊ฐ€๋กœ Kafka ์ด๋ฒคํŠธ ์ˆ˜์‹  ๋ฐ ์ฒ˜๋ฆฌ ๋กœ์ง ๊ตฌํ˜„.
- `ProductMetricsService`๋ฅผ ํ†ตํ•ด ์ข‹์•„์š” ์ˆ˜ ์—…๋ฐ์ดํŠธ ์ฒ˜๋ฆฌ.
- `ProductMetrics` ๋ฐ `EventHandled` ์—”ํ‹ฐํ‹ฐ ์ถ”๊ฐ€๋กœ ์ด๋ฒคํŠธ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€ ๋ฐ ๋ฉ”ํŠธ๋ฆญ ์ €์žฅ.
- ๊ด€๋ จ JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค (`ProductMetricsRepository`, `EventHandledRepository`) ์ถ”๊ฐ€.
- ์ด์— ๋”ฐ๋ฅธ ์ปจ์Šˆ๋จธ ๋ฐ ํผ๋ธ”๋ฆฌ์…” ๊ตฌํ˜„์ฒด ์ˆ˜์ •
- OrderCreatedEvent, LikeEventOutboxHandler, LikeCountAggregateListener ํŒจํ‚ค์ง€ ๊ตฌ์กฐ ์ •๋ฆฌ
- ์ƒํ’ˆ ์กฐํšŒ ์•ก์…˜์„ ๊ธฐ๋กํ•˜๊ธฐ ์œ„ํ•œ ProductViewEvent ๋„์ž…
- ProductViewEvent๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  Kafka์™€ ์—ฐ๋™ํ•˜๋Š” ProductEventOutboxHandler ๊ตฌํ˜„
- ์ƒํ’ˆ ์ •๋ณด ์กฐํšŒ ์‹œ ProductFacade์—์„œ ProductViewEvent ๋ฐœํ–‰ํ•˜๋„๋ก ์ˆ˜์ •
- ์ด๋ฒคํŠธ ์ €์žฅ, ์ค‘๋ณต ์ œ๊ฑฐ, Kafka ๋ฐœํ–‰ ๋กœ์ง ํฌํ•จ
- `OrderEventOutboxHandler` ์ถ”๊ฐ€๋กœ ์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐ Kafka ๋ฐœํ–‰ ๋กœ์ง ๊ตฌํ˜„.
- `OrderSalesAggregateListener`๋ฅผ ํ†ตํ•ด ํŒ๋งค ์ง‘๊ณ„ Kafka ์ด๋ฒคํŠธ ๋ฐœํ–‰ ๋ฐ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ์ง€์›.
- `OrderCreatedEvent` ํ™•์žฅ: ๊ณ ์œ  `eventId` ์ถ”๊ฐ€ ๋ฐ ์ฃผ๋ฌธ ํ’ˆ๋ชฉ ์ „๋‹ฌ ์ •๋ณด ๊ตฌ์กฐ ๋ณ€๊ฒฝ.
- ์‹ ๊ทœ `SalesCountEvent` ์ถ”๊ฐ€๋กœ Kafka ๊ธฐ๋ฐ˜ ํŒ๋งค ๋ฉ”ํŠธ๋ฆญ ๊ด€๋ฆฌ ๊ฐ€๋Šฅ.
- `OrderFacade`, `PointPaymentEventListener`, `PgPaymentEventListener` ๋“ฑ ๊ด€๋ จ ํด๋ž˜์Šค ์ˆ˜์ • ๋ฐ ํŒจํ‚ค์ง€ ์ •๋ฆฌ.
- `LikeEventConsumer`๋ฅผ `MetricsEventConsumer`๋กœ ๋ณ€๊ฒฝํ•˜๊ณ  ์ƒํ’ˆ ์ง€ํ‘œ(์ข‹์•„์š”, ์กฐํšŒ์ˆ˜, ํŒ๋งค๋Ÿ‰) ์ฒ˜๋ฆฌ ์ง€์›.
- `ProductMetricsService` ํ™•์žฅ: ์กฐํšŒ์ˆ˜(`ProductViewEvent`), ํŒ๋งค๋Ÿ‰(`SalesCountEvent`) ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ๋กœ์ง ์ถ”๊ฐ€.
- `ProductMetrics` ์—”ํ‹ฐํ‹ฐ์— ์กฐํšŒ์ˆ˜์™€ ํŒ๋งค๋Ÿ‰ ์†์„ฑ ๋ฐ ๊ด€๋ จ ์—…๋ฐ์ดํŠธ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€.
- `OutboxEvent`, `ProductMetrics`, `EventHandled` ์—”ํ‹ฐํ‹ฐ์— @table ์• ๋„ˆํ…Œ์ด์…˜ ์ถ”๊ฐ€.
- `IdempotencyIntegrationTest` ๊ตฌํ˜„์œผ๋กœ ์ค‘๋ณต ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๊ฒ€์ฆ.
โ€ฆ ๋กœ์ง ๊ฐœ์„ 

- SalesCountEvent๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ๊ด€๋ จ ๋กœ์ง์„ ProductStockEvent๋กœ ์ „๋ฉด ๋Œ€์ฒด
- MetricsEventConsumer์—์„œ ProductStockEvent๋ฅผ ์ฒ˜๋ฆฌํ•˜๋„๋ก ์ˆ˜์ •
- ProductMetricsService๋ฅผ ํ™•์žฅํ•˜์—ฌ ์žฌ๊ณ  ๊ธฐ๋ฐ˜ ์ง€ํ‘œ ๊ด€๋ฆฌ ๋ฐ ์žฌ๊ณ  ์†Œ์ง„ ์‹œ Redis ์บ์‹œ ์‚ญ์ œ ๋กœ์ง ์ถ”๊ฐ€
- ProductRepository์— findStockById ๋ฉ”์„œ๋“œ, ProductService์— getStock ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
- Redis ์บ์‹œ์—์„œ ํŽ˜์ด์ง• ์‘๋‹ต์„ ๊ฐ์‹ธ๊ธฐ ์œ„ํ•œ PageWrapper ๋„์ž…
- ํŒจํ„ด ๊ธฐ๋ฐ˜ ์บ์‹œ ์‚ญ์ œ๋ฅผ ์ง€์›ํ•˜๋„๋ก RedisCacheHandler ์ด๋™ ๋ฐ ๊ธฐ๋Šฅ ํ™•์žฅ
- `OutboxEvent`์— `OutboxStatus` ๋ฐ `retryCount` ํ•„๋“œ ์ถ”๊ฐ€๋กœ ์ƒํƒœ ๊ด€๋ฆฌ ์ง€์›.
- ์‹คํŒจ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ `markFailed` ๋ฉ”์„œ๋“œ ๊ตฌํ˜„.
- `OutboxRelay` ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€๋กœ ์‹คํŒจ ์ด๋ฒคํŠธ ์žฌ๋ฐœํ–‰ ์Šค์ผ€์ค„๋ง ๋ฐ ์ฒ˜๋ฆฌ ๋กœ์ง ๊ตฌํ˜„.
- ๊ด€๋ จ ์„œ๋น„์Šค ๋ฐ ํ•ธ๋“ค๋Ÿฌ์— ์‹คํŒจ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ๋กœ์ง ํ†ตํ•ฉ (`markFailed` ํ˜ธ์ถœ ์ถ”๊ฐ€).
- Kafka ์„ค์ •์— enable.idempotence=true ๋ฐ acks=all ํ”„๋กœ๋“€์„œ ์˜ต์…˜ ์ถ”๊ฐ€
- OutboxService์—์„œ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import ์ œ๊ฑฐ
@coderabbitai
Copy link

coderabbitai bot commented Dec 19, 2025

Walkthrough

์ด PR์€ ์•„์›ƒ๋ฐ•์Šค ํŒจํ„ด์„ ๊ตฌํ˜„ํ•˜์—ฌ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ฐ•ํ™”ํ•ฉ๋‹ˆ๋‹ค. Kafka ๋ชจ๋“ˆ ์˜์กด์„ฑ์„ ์ถ”๊ฐ€ํ•˜๊ณ , ์ด๋ฒคํŠธ ์ง€์†์„ฑ ๋ฐ ์žฌ์‹œ๋„๋ฅผ ์œ„ํ•œ ์•„์›ƒ๋ฐ•์Šค ์ธํ”„๋ผ(์—”ํ‹ฐํ‹ฐ, ์„œ๋น„์Šค, ๋ฆฌํฌ์ง€ํ† ๋ฆฌ)๋ฅผ ๋„์ž…ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฒคํŠธ ํด๋ž˜์Šค๋ฅผ ์žฌ๊ตฌ์„ฑํ•˜๊ณ  ํ•ธ๋“ค๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉฐ, ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์ถ”์ ์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. ZonedDateTime ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๊ณผ ํŠธ๋žœ์žญ์…˜ ์ „ํŒŒ ์„ค์ •๋„ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.

Changes

Cohort / File(s) Summary
Build & Configuration
apps/commerce-api/build.gradle.kts, apps/commerce-api/src/main/resources/application.yml, apps/commerce-api/src/main/resources/kafka.yml, modules/kafka/src/main/resources/kafka.yml
Kafka ๋ชจ๋“ˆ ์˜์กด์„ฑ ์ถ”๊ฐ€, kafka.yml ์ž„ํฌํŠธ ์ถ”๊ฐ€, Resilience4j ์žฌ์‹œ๋„ ์„ค์ •(pg-client), ํ”„๋กœ๋“€์„œ ์ด๋คํฌํ…์‹œ ์„ค์ •(acks: all, enable.idempotence)
Outbox Infrastructure
apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java, apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java, apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java
์•„์›ƒ๋ฐ•์Šค JPA ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ, OutboxStatus ์—ด๊ฑฐํ˜• ์ถ”๊ฐ€, OutboxService(์ €์žฅ, ํ‘œ์‹œ ๋ฉ”์„œ๋“œ), OutboxRepository ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ
Event Relay & Processing
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java, apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java
OutboxRelay ์Šค์ผ€์ค„๋œ ์ปดํฌ๋„ŒํŠธ(60์ดˆ ๊ฐ„๊ฒฉ ์žฌ์ „์†ก), ์ž„ํฌํŠธ ์ •๋ฆฌ
Like Event Restructuring
apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java (์‚ญ์ œ), apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java (์‹ ๊ทœ), apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java, modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java
LikeCreatedEvent๋ฅผ com.loopers.application.like.event๋กœ ์ด๋™, eventId/timestamp ํ•„๋“œ ์ถ”๊ฐ€, ์ •์  ํŒฉํ† ๋ฆฌ of() ์ถ”๊ฐ€, LikeCountEvent ๋ ˆ์ฝ”๋“œ ์‹ ๊ทœ
Like Event Handlers
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java, apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java, apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java
LikeFacade๋Š” ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ, LikeEventOutboxHandler ์‹ ๊ทœ(์•„์›ƒ๋ฐ•์Šค ๋ฐ Kafka ๋ฐœํ–‰), LikeCountAggregateListener๋Š” ApplicationEventPublisher ์ถ”๊ฐ€, performAggregation ๋ฐ˜ํ™˜๊ฐ’ ์ถ”๊ฐ€
Order Event Restructuring
apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java (์‚ญ์ œ), apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java (์‹ ๊ทœ)
OrderCreatedEvent๋ฅผ com.loopers.application.order.event๋กœ ์ด๋™, eventId/userId/items/timestamp ํ•„๋“œ ์ถ”๊ฐ€, OrderItemInfo ๋‚ด๋ถ€ ๋ ˆ์ฝ”๋“œ ์ถ”๊ฐ€, ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ of() ๊ตฌํ˜„
Order Event Handlers & Listeners
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java, apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java, apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java
OrderFacade๋Š” ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ ๋ฐ ์ธ์ž ํ™•์žฅ, OrderEventOutboxHandler ์‹ ๊ทœ(Kafka ๋ฐœํ–‰), OrderSalesAggregateListener ์‹ ๊ทœ(์žฌ๊ณ  ์ด๋ฒคํŠธ ๋ฐœํ–‰)
Payment & Point Transactional Updates
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java, apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java
@Transactional(propagation = REQUIRES_NEW) ์ถ”๊ฐ€, OrderCreatedEvent ์ž„ํฌํŠธ ๊ฒฝ๋กœ ๋ณ€๊ฒฝ, PointPaymentEventListener์— UserService ์ถ”๊ฐ€, LocalDateTime โ†’ ZonedDateTime ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜
Product Service & Metrics
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java, apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java, apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java
ProductFacade๋Š” ProductViewEvent ๋ฐœํ–‰ ์ถ”๊ฐ€, ProductService๋Š” getStock() ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€, ProductRepository๋Š” findStockById() ์ถ”๊ฐ€, ProductEventOutboxHandler ์‹ ๊ทœ
Payment Repository Timeline Updates
apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
findAllByStatusAndCreatedAtBefore ๋ฉ”์„œ๋“œ ์‹œ๊ทธ๋‹ˆ์ฒ˜ LocalDateTime โ†’ ZonedDateTime ๋ณ€๊ฒฝ
Config File Cleanup
apps/commerce-api/src/main/resources/jpa.yml, apps/commerce-api/src/main/resources/redis.yml
jpa.yml ๋ฐ redis.yml ์„ค์ • ํŒŒ์ผ ์‚ญ์ œ(์™ธ๋ถ€ ์„ค์ •์œผ๋กœ ์ด๋™ ์ถ”์ •)
Commerce Streamer - Metrics Domain
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
EventHandled ์—”ํ‹ฐํ‹ฐ ์‹ ๊ทœ(์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€), ProductMetrics ์—”ํ‹ฐํ‹ฐ ์‹ ๊ทœ(์ข‹์•„์š”, ์กฐํšŒ, ํŒ๋งค ์ˆ˜ ์ถ”์ )
Commerce Streamer - Metrics Services & Repositories
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
ProductMetricsService ์‹ ๊ทœ(์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง), EventHandledRepository ์‹ ๊ทœ, ProductMetricsRepository ์‹ ๊ทœ
Commerce Streamer - Kafka Consumer
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java, apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java
MetricsEventConsumer ์‹ ๊ทœ(catalog-events ๋ฆฌ์Šค๋‹), ์ž„ํฌํŠธ ๊ฒฝ๋กœ ์ˆ˜์ •, ์ด๋คํฌํ…์‹œ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
Kafka Event Types
modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java, modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java, modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java
KafkaConfig ํŒจํ‚ค์ง€ ์ด๋ฆ„ ์ˆ˜์ •(confg โ†’ config), ProductViewEvent/ProductStockEvent ๋ ˆ์ฝ”๋“œ ์‹ ๊ทœ(ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ํฌํ•จ)
Redis Module Enhancements
modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java, modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java
RedisCacheHandler ํŒจํ‚ค์ง€ ์ด๋™(support.cache โ†’ core.cache), delete()/deleteByPattern() ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€, PageWrapper ํด๋ž˜์Šค ์‹ ๊ทœ(ํŽ˜์ด์ง€๋„ค์ด์…˜ ๋ž˜ํผ)

Sequence Diagram(s)

์•„์›ƒ๋ฐ•์Šค ์ด๋ฒคํŠธ ํ๋ฆ„

sequenceDiagram
    participant App as Application<br/>(Facade)
    participant Pub as ApplicationEventPublisher
    participant Outbox as OutboxService
    participant Repo as OutboxRepository
    participant Relay as OutboxRelay<br/>(Scheduled)
    participant Kafka as KafkaTemplate
    
    App->>Pub: publishEvent(DomainEvent)
    Pub->>Outbox: saveEvent(aggregateType,<br/>aggregateId, event)
    Outbox->>Repo: save(OutboxEvent)
    Repo-->>Outbox: OutboxEvent(status=INIT)
    
    Note over Relay: 60์ดˆ ์ฃผ๊ธฐ<br/>์‹คํ–‰
    Relay->>Repo: findTop10ByStatusIn<br/>(INIT, FAILED)
    Repo-->>Relay: List<OutboxEvent>
    loop Each Event
        Relay->>Relay: deserialize & load<br/>event class
        Relay->>Pub: publishEvent(DomainEvent)
        Pub->>Kafka: send(topic, event)
        alt Success
            Kafka-->>Relay: success callback
            Relay->>Outbox: markPublished(eventId)
        else Failure
            Kafka-->>Relay: error callback
            Relay->>Outbox: markFailed(eventId)
        end
    end
Loading

์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ํ๋ฆ„

sequenceDiagram
    participant Kafka as KafkaConsumer<br/>(MetricsEventConsumer)
    participant Svc as ProductMetricsService
    participant Repo as ProductMetricsRepository
    participant Cache as RedisCacheHandler
    participant EvtRepo as EventHandledRepository
    
    Kafka->>Svc: processLikeCountEvent<br/>(LikeCountEvent)
    Svc->>EvtRepo: isAlreadyHandled(eventId)
    alt Already Processed
        EvtRepo-->>Svc: true
        Svc->>Kafka: ack()
    else First Time
        EvtRepo-->>Svc: false
        Svc->>Repo: getOrCreateMetrics(productId)
        Svc->>Repo: updateLikeCount(newCount)
        Repo-->>Svc: ProductMetrics
        Svc->>EvtRepo: save(EventHandled)
        Svc->>Kafka: ack()
        Note over Svc: productViewEvent<br/>& salesCountEvent<br/>์œ ์‚ฌํ•œ ํ๋ฆ„
    end
Loading

Estimated code review effort

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

์ฃผ์˜ ๊นŠ๊ฒŒ ๊ฒ€ํ† ํ•  ์˜์—ญ:

  • ์•„์›ƒ๋ฐ•์Šค ํŒจํ„ด ์ผ๊ด€์„ฑ: OutboxService, OutboxRelay, ๊ฐ *EventOutboxHandler์˜ ์ด๋ฒคํŠธ ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” ๋ฐ ์žฌ์‹œ๋„ ๋กœ์ง ๊ฒ€์ฆ
  • ํŠธ๋žœ์žญ์…˜ ์ „ํŒŒ ์„ค์ •: CouponUsageEventListener, PgPaymentEventListener, PointPaymentEventListener์˜ REQUIRES_NEW ์ „ํŒŒ ์„ค์ •์ด ์˜๋„๋œ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€ ๋‹ฌ์„ฑํ•˜๋Š”์ง€ ํ™•์ธ
  • ์ด๋ฒคํŠธ ํด๋ž˜์Šค ์ด๋™์˜ ์˜ํ–ฅ: LikeCreatedEvent, OrderCreatedEvent๋ฅผ ์ƒˆ ํŒจํ‚ค์ง€๋กœ ์ด๋™ํ•œ ํ›„ ๋ชจ๋“  ์ž„ํฌํŠธ ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ ๋ฐ ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ ์ผ๊ด€์„ฑ ํ™•์ธ
  • ์ด๋คํฌํ…์‹œ ๋ณด์žฅ: EventHandled ์—”ํ‹ฐํ‹ฐ์™€ ProductMetricsService์˜ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€ ๋กœ์ง์ด ๋ฉ€ํ‹ฐ ์Šค๋ ˆ๋“œ/๋ถ„์‚ฐ ํ™˜๊ฒฝ์—์„œ ์•ˆ์ „ํ•œ์ง€ ํ™•์ธ
  • Kafka ๋ฉ”์‹œ์ง€ ์ˆœ์„œ: catalog-events ํ† ํ”ฝ์—์„œ productId ํŒŒํ‹ฐ์…˜ ํ‚ค ์‚ฌ์šฉ์ด ์ˆœ์„œ ๋ณด์žฅ ์š”๊ตฌ์‚ฌํ•ญ ์ถฉ์กฑํ•˜๋Š”์ง€ ๊ฒ€์ฆ
  • ZonedDateTime ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜: ํƒ€์ž„์กด ์ธ์‹ ํƒ€์ž„์Šคํƒฌํ”„๊ฐ€ PaymentRecoveryScheduler์˜ 5๋ถ„ ์œˆ๋„์šฐ ๋กœ์ง์— ๋ฏธ์น˜๋Š” ์˜ํ–ฅ ํ™•์ธ

Possibly related PRs

Suggested labels

enhancement

Poem

๐Ÿฐ ์•„์›ƒ๋ฐ•์Šค์˜ ์ƒ์ž๋ฅผ ์—ด๋ฉฐ, ์ด๋ฒคํŠธ๊ฐ€ ์ถค์„ ์ถ˜๋‹ค๋„ค,
Kafka์˜ ํ๋ฆ„์— ์‹ค๋ ค, ๋ฉ”ํŠธ๋ฆญ์€ ๊นจ์–ด๋‚œ๋‹ค.
์ข‹์•„์š”์™€ ์กฐํšŒ์ˆ˜, ํŒ๋งค๋Ÿ‰์ด ๋ชจ์—ฌ,
์ด๋คํฌํ…ํŠธํ•œ ์†์œผ๋กœ ์ค‘๋ณต์„ ๋ง‰์œผ๋‹ˆ,
์šฐ๋ฆฌ ์ƒํ’ˆ์˜ ์ด์•ผ๊ธฐ, ์ด์ œ ์™„๋ฒฝํ•˜๊ฒŒ ์ „ํ•ด์ง„๋‹ค! ๐ŸŽ‰

Pre-merge checks and finishing touches

โŒ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage โš ๏ธ Warning Docstring coverage is 1.54% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check โ“ Inconclusive PR ์ œ๋ชฉ์ด ๋„ˆ๋ฌด ๋ชจํ˜ธํ•˜๊ณ  ๊ตฌ์ฒด์ ์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. 'Feature/week8'์€ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์˜ ์ฃผ์š” ๋ชฉ์ ์„ ๋ช…ํ™•ํžˆ ์ „๋‹ฌํ•˜์ง€ ๋ชปํ•ฉ๋‹ˆ๋‹ค. Kafka ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ํŒŒ์ดํ”„๋ผ์ธ ๊ตฌ์ถ• ๊ฐ™์€ ๊ตฌ์ฒด์ ์ธ ๊ธฐ๋Šฅ์„ ์„ค๋ช…ํ•˜๋Š” ์ œ๋ชฉ์œผ๋กœ ๋ณ€๊ฒฝํ•ด์ฃผ์„ธ์š”. ์˜ˆ: 'Implement Kafka-based event pipeline with Transactional Outbox pattern'
โœ… Passed checks (1 passed)
Check name Status Explanation
Description check โœ… Passed PR ์„ค๋ช…์ด ํ•„์ˆ˜ ํ…œํ”Œ๋ฆฟ ํ•ญ๋ชฉ์„ ์™„๋ฒฝํ•˜๊ฒŒ ์ถฉ์กฑํ•ฉ๋‹ˆ๋‹ค. ์š”์•ฝ, ๊ฒ€ํ†  ํฌ์ธํŠธ, ์ฒดํฌ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋ชจ๋‘ ํฌํ•จ๋˜์–ด ์žˆ๊ณ  ๊ตฌ์ฒด์ ์ž…๋‹ˆ๋‹ค.
โœจ Finishing touches
  • ๐Ÿ“ Generate docstrings
๐Ÿงช Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

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: 20

Caution

Some comments are outside the diff and canโ€™t be posted inline due to platform limitations.

โš ๏ธ Outside diff range comments (6)
apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java (1)

67-75: ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๋ฒ”์œ„๊ฐ€ PgPaymentEventListener์™€ ๋ถˆ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค.

PgPaymentEventListener๋Š” Exception์„ catchํ•˜์ง€๋งŒ, ์ด ๋ฆฌ์Šค๋„ˆ๋Š” CoreException๋งŒ catchํ•ฉ๋‹ˆ๋‹ค. processor.process()์—์„œ ์˜ˆ๊ธฐ์น˜ ์•Š์€ ์˜ˆ์™ธ(์˜ˆ: RuntimeException)๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด failPayment๊ฐ€ ํ˜ธ์ถœ๋˜์ง€ ์•Š๊ณ  ์‹คํŒจ ์ด๋ฒคํŠธ๋„ ๋ฐœํ–‰๋˜์ง€ ์•Š์•„ ์ฃผ๋ฌธ์ด ์ผ๊ด€์„ฑ ์—†๋Š” ์ƒํƒœ๋กœ ๋‚จ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ผ๊ด€์„ฑ ์žˆ๋Š” ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ์ˆ˜์ • ์ œ์•ˆ
-    } catch (CoreException e) {
+    } catch (Exception e) {
       orderService.failPayment(event.orderId());
       eventPublisher.publishEvent(new PaymentRequestFailedEvent(
           event.orderId(),
           event.couponId(),
           e.getMessage()
       ));
-
     }
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java (1)

48-49: ์˜ˆ์™ธ๋ฅผ ๋ฌด์‹œํ•˜๋Š” ๋นˆ catch ๋ธ”๋ก์„ ์ˆ˜์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๋นˆ catch ๋ธ”๋ก์ด ๋ชจ๋“  ์˜ˆ์™ธ๋ฅผ ์กฐ์šฉํžˆ ์‚ผ์ผœ๋ฒ„๋ ค, ๊ฒฐ์ œ ๋ณต๊ตฌ ์‹คํŒจ๋ฅผ ์ถ”์ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. PG ํด๋ผ์ด์–ธํŠธ ์˜ค๋ฅ˜, ๊ฒฐ์ œ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์‹คํŒจ ๋“ฑ์ด ๋ฐœ์ƒํ•ด๋„ ์–ด๋– ํ•œ ๋กœ๊ทธ๋‚˜ ์•Œ๋ฆผ ์—†์ด ๋ฌด์‹œ๋ฉ๋‹ˆ๋‹ค.

์ตœ์†Œํ•œ ์˜ˆ์™ธ๋ฅผ ๋กœ๊น…ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค:

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •์‚ฌํ•ญ
       } catch (Exception e) {
+        log.error("Failed to recover payment: paymentId={}, orderId={}", 
+                  payment.getId(), payment.getOrderId(), e);
       }

๋” ๋‚˜์€ ๋ฐฉ๋ฒ•์œผ๋กœ, ๋ชจ๋‹ˆํ„ฐ๋ง์„ ์œ„ํ•ด ๋ฉ”ํŠธ๋ฆญ์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ๋„ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”:

} catch (Exception e) {
  log.error("Failed to recover payment: paymentId={}, orderId={}", 
            payment.getId(), payment.getOrderId(), e);
  // Optional: meterRegistry.counter("payment.recovery.failure").increment();
}
apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (2)

41-44: ์˜ˆ์™ธ ์ฒ˜๋ฆฌ์˜ ๋ถˆ๋ช…ํ™•์„ฑ โ€” ์˜ˆ์™ธ ์œ ํ˜•๋ณ„ ์ฒ˜๋ฆฌ์™€ ๋กœ๊น… ์ถ”๊ฐ€ ํ•„์š”

๋ชจ๋“  ์˜ˆ์™ธ๋ฅผ ๋™์ผํ•˜๊ฒŒ ์ฒ˜๋ฆฌํ•˜๋ฉฐ ๋กœ๊น… ์—†์ด ์กฐ์šฉํžˆ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋งŒ ์ฆ๊ฐ€์‹œํ‚ต๋‹ˆ๋‹ค. ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋‹ค์–‘ํ•œ ์‹คํŒจ ์›์ธ์„ ๊ตฌ๋ถ„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค:

  • ClassNotFoundException: ์ด๋ฒคํŠธ ํƒ€์ž…์ด classpath์—์„œ ๋ฐœ๊ฒฌ๋˜์ง€ ์•Š์Œ (์„ค์ • ์˜ค๋ฅ˜)
  • JsonMappingException: ์ด๋ฒคํŠธ ์ง๋ ฌํ™” ๋ถˆ์ผ์น˜ (์Šคํ‚ค๋งˆ ๋ฒ„์ „ ํ˜ธํ™˜์„ฑ)
  • Kafka ์ ‘์† ์˜ค๋ฅ˜ (์ผ์‹œ์  ์žฌ์‹œ๋„ ๊ฐ€๋Šฅ)
  • ์Šคํ”„๋ง ์ด๋ฒคํŠธ ํผ๋ธ”๋ฆฌ์…” ์˜ค๋ฅ˜

ํ˜„์žฌ ๊ตฌ์กฐ๋กœ๋Š” ์žฌ์‹œ๋„ ๋ถˆ๊ฐ€๋Šฅํ•œ ์˜ค๋ฅ˜(์˜ˆ: ClassNotFoundException)๋„ MAX_RETRY_COUNT๊นŒ์ง€ ๋ฐ˜๋ณต ์ˆ˜ํ–‰ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์˜ˆ์™ธ ์œ ํ˜•๋ณ„ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€
  } catch (Exception e) {
+   logger.error("Failed event retry failed: eventId={}, attempt={}/{}, exception={}", 
+       failedEvent.getId(), failedEvent.getRetryCount() + 1, MAX_RETRY_COUNT, e.getMessage(), e);
+   
+   if (e instanceof ClassNotFoundException) {
+     logger.warn("Unrecoverable error: event class not found. Moving to permanent failure.");
+     failedEvent.markAsUnrecoverable();
+     failedEventRepository.save(failedEvent);
+     return;
+   }
+   
    failedEvent.incrementRetryCount(); 
    failedEventRepository.save(failedEvent);
  }

1-23: ์‹คํŒจํ•œ ์ด๋ฒคํŠธ ์žฌ์‹œ๋„ ์ค‘ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ์— ๋กœ๊น… ์ถ”๊ฐ€ ํ•„์ˆ˜

lines 41-44์˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ตฌ๊ฐ„์—์„œ ๋กœ๊น…์ด ๋ชจ๋ฒ” ์‚ฌ๋ก€์ž„์—๋„ ๋ถˆ๊ตฌํ•˜๊ณ  ๋กœ๊น…์ด ์—†์Šต๋‹ˆ๋‹ค. ObjectMapper์˜ ์—ญ์ง๋ ฌํ™” ์‹คํŒจ(line 35-36)์™€ Class.forName ์˜ค๋ฅ˜(line 34) ๋“ฑ ๋‹ค์–‘ํ•œ ์ด์œ ๋กœ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š”๋ฐ, ์ด๋ฅผ ๋กœ๊น…ํ•˜์ง€ ์•Š์œผ๋ฉด ๋ถ„์‚ฐ ์‹œ์Šคํ…œ์—์„œ ์˜ค๋ฅ˜ ์›์ธ์„ ์ถ”์ ํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฒƒ์ด ๊ถŒ์žฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.

๋˜ํ•œ @transactional ๊ฒฝ๊ณ„ ๋‚ด์—์„œ ApplicationEventPublisher.publishEvent() ํ˜ธ์ถœ ์‹œ, ํŠธ๋žœ์žญ์…˜์ด ๋กค๋ฐฑ๋˜๋ฉด ์ด๋ฒคํŠธ๊ฐ€ ๋‘ ๋ฒˆ ๋ฐœํ–‰๋  ์ˆ˜ ์žˆ์–ด ์ •ํ™•์„ฑ(At Most Once)์„ ๋ณด์žฅํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ด๋ฒคํŠธ ๋ฐœํ–‰ ํ›„ ํŠธ๋žœ์žญ์…˜์ด ๋กค๋ฐฑ๋˜๋Š” ๋ฌธ์ œ๋Š” Spring์—์„œ ์•Œ๋ ค์ง„ ๋ฌธ์ œ์ด๋ฉฐ, Spring Modulith์˜ transactional outbox ํŒจํ„ด์œผ๋กœ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ ์‚ฌ์šฉ ์ค‘์ธ EventInboxAspect ํŒจํ„ด๊ณผ ์ผ๊ด€์„ฑ ์žˆ๋Š” ์ ‘๊ทผ์„ ๊ฒ€ํ† ํ•˜์‹ญ์‹œ์˜ค.

apps/commerce-api/src/main/resources/application.yml (1)

39-49: FeignException.NotFound์„ ignore-exceptions์— ๋‹ค์‹œ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

ํ˜„์žฌ ์„ค์ •์—์„œ FeignException.NotFound๊ฐ€ ignore-exceptions์— ์—†์–ด์„œ, 404 ์‘๋‹ต๋„ ์žฌ์‹œ๋„ ๋Œ€์ƒ์ด ๋ฉ๋‹ˆ๋‹ค.

LoopersPgExecutor.execute() ๋ฉ”์„œ๋“œ๋ฅผ ๋ณด๋ฉด, HTTP ์š”์ฒญ์ด ์„ฑ๊ณตํ–ˆ์„ ๋•Œ๋งŒ ์‘๋‹ต์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. 404 ์˜ค๋ฅ˜๋Š” Feign ํด๋ผ์ด์–ธํŠธ๊ฐ€ FeignException.NotFound๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค๋ฏ€๋กœ, ์ด ์˜ˆ์™ธ๊ฐ€ resilience4j ์žฌ์‹œ๋„ ๋ฉ”์ปค๋‹ˆ์ฆ˜๊นŒ์ง€ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค. 404๋Š” ์š”์ฒญํ•œ ์—”๋“œํฌ์ธํŠธ๋‚˜ ๋ฆฌ์†Œ์Šค๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ๋œป์œผ๋กœ, ์žฌ์‹œ๋„ํ•ด๋„ ์„ฑ๊ณตํ•  ๊ฐ€๋Šฅ์„ฑ์ด ๋‚ฎ์Šต๋‹ˆ๋‹ค.

๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์—๋Ÿฌ(400 ๋“ฑ)์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ 404๋„ ์žฌ์‹œ๋„ ๋Œ€์ƒ์—์„œ ์ œ์™ธํ•˜์—ฌ ๋ถˆํ•„์š”ํ•œ ์žฌ์‹œ๋„ ์‹œ๋„๋ฅผ ๋ฐฉ์ง€ํ•˜์„ธ์š”:

ignore-exceptions:
  - com.loopers.support.error.CoreException
  - feign.FeignException.BadRequest
  - feign.FeignException.NotFound
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (1)

23-60: Product ์—”ํ‹ฐํ‹ฐ์˜ likeCount๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์ง€ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

LikeCreatedEvent.of(productId, 1)๋ฅผ ๋ฐœํ–‰ํ•˜์ง€๋งŒ, Product ์—”ํ‹ฐํ‹ฐ์˜ likeCount ํ•„๋“œ๋ฅผ ์‹ค์ œ๋กœ ์ฆ๊ฐ€/๊ฐ์†Œ์‹œํ‚ค๋Š” ๋กœ์ง์ด ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค:

  • Line 32: likeService.save()๋Š” Like ๋ ˆ์ฝ”๋“œ๋งŒ ์ƒ์„ฑ
  • product.increaseLikeCount() ๋˜๋Š” ProductService.increaseLikeCount(productId) ํ˜ธ์ถœ์ด ์—†์Œ
  • Line 42, 58: ๋ฐ˜ํ™˜๋˜๋Š” product.getLikeCount()๋Š” ์—…๋ฐ์ดํŠธ๋˜์ง€ ์•Š์€ ๊ฐ’

์ด๋กœ ์ธํ•ด ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์ง‘๊ณ„์™€ DB ์ƒํƒœ ๊ฐ„ ๋ถˆ์ผ์น˜๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
   @Transactional
   public LikeInfo like(long userId, long productId) {
     Optional<Like> existingLike = likeService.findLike(userId, productId);
-    Product product = productService.getProduct(productId);
 
     if (existingLike.isPresent()) {
+      Product product = productService.getProduct(productId);
       return LikeInfo.from(existingLike.get(), product.getLikeCount());
     }
 
     Like newLike = likeService.save(userId, productId);
+    int updatedCount = productService.increaseLikeCount(productId);
 
     eventPublisher.publishEvent(LikeCreatedEvent.of(productId, 1));
 
     eventPublisher.publishEvent(new LikeActionTrackEvent(
         userId,
         productId,
         "LIKE"
     ));
 
-    return LikeInfo.from(newLike, product.getLikeCount());
+    return LikeInfo.from(newLike, updatedCount);
   }

   @Transactional
   public int unLike(long userId, long productId) {
 
     likeService.unLike(userId, productId);
+    int updatedCount = productService.decreaseLikeCount(productId);
 
     eventPublisher.publishEvent(LikeCreatedEvent.of(productId, -1));
 
     eventPublisher.publishEvent(new LikeActionTrackEvent(
         userId,
         productId,
         "UNLIKE"
     ));
 
-    return productService.getProduct(productId).getLikeCount();
+    return updatedCount;
   }
๐Ÿงน Nitpick comments (5)
modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java (1)

11-17: ํƒ€์ž„์Šคํƒฌํ”„ ์ฒ˜๋ฆฌ ์ผ๊ด€์„ฑ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

System.currentTimeMillis()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํƒ€์ž„์Šคํƒฌํ”„๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. PR ์„ค๋ช…์—์„œ ์–ธ๊ธ‰๋œ ZonedDateTime ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๊ณผ ๋‹ฌ๋ฆฌ, ์ด ์ด๋ฒคํŠธ๋Š” long ํƒ€์ž…์˜ ์—ํฌํฌ ๋ฐ€๋ฆฌ์ดˆ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ณ ๋ ค ์‚ฌํ•ญ:

  • ํƒ€์ž„์กด ์ •๋ณด ๋ถ€์žฌ: ์—ํฌํฌ ํƒ€์ž„์Šคํƒฌํ”„๋Š” UTC๋กœ ๊ฐ„์ฃผ๋˜์ง€๋งŒ ๋ช…์‹œ์ ์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ๋‹ค๋ฅธ ์ด๋ฒคํŠธ๋“ค๊ณผ์˜ ์ผ๊ด€์„ฑ: ์‹œ์Šคํ…œ ์ „๋ฐ˜์—์„œ ZonedDateTime์„ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด ์ด ์ด๋ฒคํŠธ๋„ ๋งž์ถ”๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.
  • ์ง๋ ฌํ™”: long์€ JSON ์ง๋ ฌํ™”๊ฐ€ ๊ฐ„๋‹จํ•˜์ง€๋งŒ, ISO-8601 ๋ฌธ์ž์—ด์ด ๋” ๋ช…ํ™•ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋˜ํ•œ, productId์— ๋Œ€ํ•œ null ๊ฒ€์ฆ์ด ์—†์Šต๋‹ˆ๋‹ค. ํ•„์š”ํ•˜๋‹ค๋ฉด ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ์—์„œ ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

๐Ÿ”Ž ZonedDateTime ์‚ฌ์šฉ ์ œ์•ˆ
 package com.loopers.event;
 
+import java.time.ZonedDateTime;
 import java.util.UUID;
 
 public record ProductViewEvent(
     String eventId,
     Long productId,
-    long timestamp
+    ZonedDateTime timestamp
 ) {
 
   public static ProductViewEvent from(Long productId) {
     return new ProductViewEvent(
         UUID.randomUUID().toString(),
         productId,
-        System.currentTimeMillis()
+        ZonedDateTime.now()
     );
   }
 }
apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java (1)

18-29: ํ…Œ์ŠคํŠธ ๊ตฌ์„ฑ์„ ๋‹จ์ˆœํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

RedisConfig๋ฅผ importํ•˜๋ฉด์„œ ๋™์‹œ์— RedisCacheHandler๋ฅผ ๋ชจํ‚นํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๋ชจํ‚น๋œ ๋นˆ์„ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด RedisConfig import๊ฐ€ ๋ถˆํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋ฆฌํŒฉํ† ๋ง ์ œ์•ˆ
 @SpringBootTest
-@Import(RedisConfig.class)
 class IdempotencyIntegrationTest {

๋˜๋Š” ์‹ค์ œ Redis ์—ฐ๋™์ด ํ•„์š”ํ•˜๋‹ค๋ฉด RedisCacheHandler ๋ชจํ‚น์„ ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)

17-29: ProductEventOutboxHandler์™€์˜ ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด @Async ์ถ”๊ฐ€๋ฅผ ๊ฒ€ํ† ํ•˜์„ธ์š”.

ProductEventOutboxHandler๋Š” @Async ์–ด๋…ธํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•˜์ง€๋งŒ, ์ด ํ•ธ๋“ค๋Ÿฌ์™€ OrderEventOutboxHandler ๋ชจ๋‘ ๋ˆ„๋ฝ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ํ›„ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด @Async๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด ์•„ํ‚คํ…์ฒ˜ ์ผ๊ด€์„ฑ์„ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

+  @Async
   @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
   public void handle(LikeCreatedEvent event) {
apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java (1)

36-36: ํƒ€์ž„์Šคํƒฌํ”„ ์ดˆ๊ธฐํ™” ๋ฐฉ์‹ ๊ฐœ์„  ๊ณ ๋ ค

createdAt ํ•„๋“œ๋ฅผ LocalDateTime.now()๋กœ ํ•„๋“œ ์„ ์–ธ ์‹œ์ ์— ์ดˆ๊ธฐํ™”ํ•˜๋Š” ๊ฒƒ์€ ์ž‘๋™ํ•˜์ง€๋งŒ, JPA ์ƒ๋ช…์ฃผ๊ธฐ์™€ ๋” ๋ช…ํ™•ํ•˜๊ฒŒ ํ†ตํ•ฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋Œ€์•ˆ ์ œ์‹œ

์˜ต์…˜ 1: @CreationTimestamp ์‚ฌ์šฉ (Hibernate ์ „์šฉ)

+  import org.hibernate.annotations.CreationTimestamp;
+
+  @CreationTimestamp
-  private LocalDateTime createdAt = LocalDateTime.now();
+  private LocalDateTime createdAt;

์˜ต์…˜ 2: @PrePersist ์ฝœ๋ฐฑ

-  private LocalDateTime createdAt = LocalDateTime.now();
+  private LocalDateTime createdAt;
+
+  @PrePersist
+  protected void onCreate() {
+    this.createdAt = LocalDateTime.now();
+  }

์˜ต์…˜ 3: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ธฐ๋ณธ๊ฐ’ (์„ ํ˜ธ)

ALTER TABLE outbox_event 
MODIFY COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
+  @Column(insertable = false, updatable = false)
-  private LocalDateTime createdAt = LocalDateTime.now();
+  private LocalDateTime createdAt;
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)

54-56: ์บ์‹œ ๋ฌดํšจํ™” ๋ฒ”์œ„๊ฐ€ ๊ณผ๋„ํ•˜๊ฒŒ ๋„“์Œ

deleteByPattern("product:list")๋Š” ๋ชจ๋“  ์ƒํ’ˆ ๋ชฉ๋ก ์บ์‹œ๋ฅผ ์‚ญ์ œํ•˜์—ฌ, ์žฌ๊ณ  ์†Œ์ง„๋œ ๋‹จ์ผ ์ƒํ’ˆ๊ณผ ๋ฌด๊ด€ํ•œ ๋‹ค๋ฅธ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ๊นŒ์ง€ ์บ์‹œ ๋ฏธ์Šค๋ฅผ ์œ ๋ฐœํ•ฉ๋‹ˆ๋‹ค.

ProductService(apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java)๋ฅผ ๋ณด๋ฉด ์บ์‹œ ํ‚ค๊ฐ€ product:list:{ํŽ˜์ด์ง• ์ •๋ณด}์™€ product:list:brand:{brandId} ํ˜•ํƒœ๋กœ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. ํŠน์ • ์ƒํ’ˆ์˜ ์žฌ๊ณ  ๋ณ€๊ฒฝ์€ ํ•ด๋‹น ์ƒํ’ˆ์ด ํฌํ•จ๋œ ์บ์‹œ๋งŒ ๋ฌดํšจํ™”ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

๋” ๋‚˜์€ ์ ‘๊ทผ๋ฒ•:

  1. ์ƒํ’ˆ ID๋ฅผ ์บ์‹œ ํ‚ค์— ํฌํ•จ์‹œ์ผœ ์ •๋ฐ€ํ•œ ๋ฌดํšจํ™” (product:list:contains:{productId})
  2. ๋˜๋Š” ์บ์‹œ TTL์— ์˜์กดํ•˜๊ณ  ์‹ค์‹œ๊ฐ„ ์žฌ๊ณ ๋Š” ์ƒ์„ธ ์กฐํšŒ ์บ์‹œ ๋ฌดํšจํ™”๋งŒ ์ˆ˜ํ–‰
๐Ÿ”Ž ์ƒ์„ธ ์บ์‹œ๋งŒ ๋ฌดํšจํ™”ํ•˜๋Š” ๋ฐฉ์•ˆ
  if (event.currentStock() <= 0) {
    redisCacheHandler.delete("product:detail:" + event.productId());
-   redisCacheHandler.deleteByPattern("product:list");
+   // ๋ชฉ๋ก ์บ์‹œ๋Š” TTL(5๋ถ„)์— ์˜์กดํ•˜๊ฑฐ๋‚˜, 
+   // ์ƒํ’ˆ๋ณ„ ๋ชฉ๋ก ์บ์‹œ ๋งคํ•‘ ํ…Œ์ด๋ธ”์„ ์œ ์ง€ํ•˜์—ฌ ์„ ํƒ์  ๋ฌดํšจํ™”
  }
๐Ÿ“œ Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between ed219b7 and 91e7b8c.

๐Ÿ“’ Files selected for processing (47)
  • apps/commerce-api/build.gradle.kts (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java (0 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (3 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java (0 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java (3 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (3 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java (3 hunks)
  • apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java (2 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java (1 hunks)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java (2 hunks)
  • apps/commerce-api/src/main/resources/application.yml (2 hunks)
  • apps/commerce-api/src/main/resources/jpa.yml (0 hunks)
  • apps/commerce-api/src/main/resources/redis.yml (0 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.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/ProductMetricsService.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java (1 hunks)
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java (1 hunks)
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java (1 hunks)
  • modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java (1 hunks)
  • modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java (1 hunks)
  • modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java (1 hunks)
  • modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java (1 hunks)
  • modules/kafka/src/main/resources/kafka.yml (1 hunks)
  • modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java (2 hunks)
  • modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java (1 hunks)
๐Ÿ’ค Files with no reviewable changes (4)
  • apps/commerce-api/src/main/resources/jpa.yml
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java
  • apps/commerce-api/src/main/resources/redis.yml
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java
๐Ÿงฐ Additional context used
๐Ÿง  Learnings (11)
๐Ÿ““ 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-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-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.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/resources/kafka.yml
  • modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java
๐Ÿ“š Learning: 2025-12-07T12:45:45.556Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 52
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/Payment.kt:160-181
Timestamp: 2025-12-07T12:45:45.556Z
Learning: In the Payment.confirmPayment method in com.loopers.domain.payment.Payment, the when-branch order is intentionally designed with business logic checks before timeout: (1) check SUCCESS/FAILED status first, (2) check for null match (business failure), (3) check timeout only as last resort for PENDING transactions. If matched transaction is PENDING and not timed out, no branch matches and payment stays IN_PROGRESS for scheduler retry. This design prioritizes business outcome determination over time-based fallback.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
  • apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java
๐Ÿ“š Learning: 2025-11-30T15:44:28.336Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 48
File: apps/commerce-api/src/main/kotlin/com/loopers/application/order/OrderFacade.kt:80-83
Timestamp: 2025-11-30T15:44:28.336Z
Learning: User ghojeong prefers explicit state management over transaction rollback for handling payment failures in order creation flow. When payment fails after order creation, the order should be explicitly marked as cancelled and persisted, rather than relying on transaction rollback.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.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-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java
  • modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java
  • modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java
๐Ÿ“š 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/product/event/ProductEventOutboxHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.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-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.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/MetricsEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java
๐Ÿ“š Learning: 2025-11-20T13:22:51.519Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 98
File: core/domain/src/main/java/com/loopers/core/domain/order/DefaultCoupon.java:15-62
Timestamp: 2025-11-20T13:22:51.519Z
Learning: In the order domain coupon implementation, DefaultCoupon serves as an internal helper/DTO class to carry AbstractCoupon's base fields during database reconstruction. It is used as an intermediate object when converting CouponEntity to specific coupon types (RateDiscountCoupon or AmountDiscountCoupon). The actual discount information (rate/amount) is stored in separate tables (rate_discount_coupon and amount_discount_coupon) and fetched via RateDiscountCouponJpaRepository and AmountDiscountCouponJpaRepository. DefaultCoupon is never returned to callers directly.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
๐Ÿ“š Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java
๐Ÿงฌ Code graph analysis (7)
apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (6)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
  • RequiredArgsConstructor (13-40)
apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (1)
  • Component (14-47)
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java (1)
  • Component (15-49)
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)
  • Component (10-30)
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (1)
  • Component (10-27)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
  • result (7-19)
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java (4)
apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (1)
  • Component (14-47)
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)
  • Component (10-30)
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (1)
  • Component (10-27)
apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (1)
  • Component (12-33)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (3)
apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java (1)
  • Entity (15-54)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
  • Entity (11-47)
apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
  • name (14-87)
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (5)
apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (1)
  • Component (14-47)
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java (1)
  • Component (15-49)
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (1)
  • Component (10-27)
apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (1)
  • Component (12-33)
apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java (1)
  • Component (17-57)
apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java (2)
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java (1)
  • RequiredArgsConstructor (24-91)
apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java (1)
  • Component (23-71)
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java (2)
apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java (1)
  • Component (10-30)
apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java (1)
  • Component (12-33)
modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java (1)
modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java (1)
  • PageWrapper (8-31)
๐Ÿ”‡ Additional comments (29)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java (1)

3-3: Import ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ ํ™•์ธ

KafkaConfig์˜ ํŒจํ‚ค์ง€๋ช… ์ˆ˜์ •์— ๋”ฐ๋ผ import ๋ฌธ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

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

1-1: ํŒจํ‚ค์ง€๋ช… ์˜คํƒ€ ์ˆ˜์ • ์™„๋ฃŒ

ํŒจํ‚ค์ง€๋ช…์˜ ์˜คํƒ€(confg โ†’ config) ์ˆ˜์ •์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ฝ”๋“œ๋ฒ ์ด์Šค ์ „์ฒด์—์„œ ์ด์ „ ํŒจํ‚ค์ง€๋ช… "com.loopers.confg"์— ๋Œ€ํ•œ ์ฐธ์กฐ๊ฐ€ ๋‚จ์•„์žˆ์ง€ ์•Š์œผ๋ฉฐ, ์ƒˆ๋กœ์šด ํŒจํ‚ค์ง€๋ช… "com.loopers.config.kafka"๋กœ ๋ชจ๋“  ์ฐธ์กฐ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java (3)

11-12: LGTM!

UserService ์˜์กด์„ฑ ์ถ”๊ฐ€๊ฐ€ PgPaymentEventListener์™€ ๋™์ผํ•œ ํŒจํ„ด์„ ๋”ฐ๋ฅด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฒคํŠธ ํŽ˜์ด๋กœ๋“œ์—์„œ ์ง์ ‘ User ๊ฐ์ฒด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๋Œ€์‹  ์„œ๋น„์Šค๋ฅผ ํ†ตํ•ด ์กฐํšŒํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ „ํ™˜ํ•œ ๊ฒƒ์€ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹œ์ ์˜ ์ตœ์‹  ์‚ฌ์šฉ์ž ์ƒํƒœ๋ฅผ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

Also applies to: 31-31


34-35: LGTM!

AFTER_COMMIT ํŽ˜์ด์ฆˆ์™€ REQUIRES_NEW ์ „ํŒŒ ์„ค์ •์˜ ์กฐํ•ฉ์ด ์˜ฌ๋ฐ”๋ฆ…๋‹ˆ๋‹ค. ์›๋ณธ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ํ›„ ๋…๋ฆฝ์ ์ธ ์ƒˆ ํŠธ๋žœ์žญ์…˜์—์„œ ๊ฒฐ์ œ ์ฒ˜๋ฆฌ๊ฐ€ ์ˆ˜ํ–‰๋˜๋ฉฐ, ์ด๋Š” PgPaymentEventListener์™€ ์ผ๊ด€๋œ ํŒจํ„ด์ž…๋‹ˆ๋‹ค.


47-53: LGTM!

์ด๋ฒคํŠธ ํŽ˜์ด๋กœ๋“œ์—์„œ userId๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹œ์ ์˜ ์ตœ์‹  ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด์žฅํ•˜๋ฉฐ, PgPaymentEventListener์™€ ๋™์ผํ•œ ํŒจํ„ด์ž…๋‹ˆ๋‹ค.

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

17-17: ProductRepository ์ธํ„ฐํŽ˜์ด์Šค์— ์ •์˜๋œ findStockById ๋ฉ”์„œ๋“œ๊ฐ€ ๊ตฌํ˜„์ฒด์—์„œ ๋ˆ„๋ฝ๋จ

ProductRepository ์ธํ„ฐํŽ˜์ด์Šค(line 17)์— int findStockById(Long id);๊ฐ€ ์„ ์–ธ๋˜์–ด ์žˆ์œผ๋‚˜, ProductRepositoryImpl๊ณผ ProductJpaRepository์—์„œ ์ด๋ฅผ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ProductService.getStock()์ด ์ด ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋ ค๊ณ  ํ•˜๋ฏ€๋กœ ํ˜„์žฌ ์ฝ”๋“œ๋Š” ์ปดํŒŒ์ผ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ProductJpaRepository์— @Query ์–ด๋…ธํ…Œ์ด์…˜์œผ๋กœ ํ•ด๋‹น ์ฟผ๋ฆฌ๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ , ProductRepositoryImpl์—์„œ ์ด๋ฅผ ์œ„์ž„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

โ›” Skipped due to learnings
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 + ์žฌ์‹œ๋„)์„ ์‚ฌ์šฉํ•œ๋‹ค.
apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java (1)

5-5: JPA ํƒ€์ž„์กด ์ฒ˜๋ฆฌ ํ™•์ธ

ZonedDateTime์œผ๋กœ์˜ ๋ณ€๊ฒฝ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. Spring Data JPA๋Š” ZonedDateTime ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ฟผ๋ฆฌ์—์„œ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ปฌ๋Ÿผ ํƒ€์ž…์ด ํƒ€์ž„์กด ์ •๋ณด๋ฅผ ์ €์žฅํ•  ์ˆ˜ ์žˆ๋Š”์ง€(์˜ˆ: TIMESTAMP WITH TIME ZONE) ๋˜๋Š” JPA AttributeConverter๊ฐ€ ์ ์ ˆํžˆ ๊ตฌ์„ฑ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

Also applies to: 12-12

apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java (1)

6-6: ๊ตฌํ˜„์ฒด ์—…๋ฐ์ดํŠธ ํ™•์ธ ์™„๋ฃŒ

์ธํ„ฐํŽ˜์ด์Šค ๋ณ€๊ฒฝ์— ๋งž์ถฐ ๊ตฌํ˜„์ฒด๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ZonedDateTime ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ JPA repository๋กœ ์ •ํ™•ํžˆ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค.

Also applies to: 29-31

apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java (1)

3-3: ํƒ€์ž„์กด ์ธ์‹ datetime ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ™•์ธ ํ•„์š”

LocalDateTime์—์„œ ZonedDateTime์œผ๋กœ์˜ ๋ณ€๊ฒฝ์ด ๊ฒฐ์ œ ๋„๋ฉ”์ธ์—์„œ ์ผ๊ด€๋˜๊ฒŒ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ ๋‹ค์Œ ์‚ฌํ•ญ๋“ค์„ ํ™•์ธํ•˜์„ธ์š”:

  1. ํƒ€์ž„์กด ์ „๋žต ๋ช…ํ™•ํ™”: BaseEntity์—์„œ ZonedDateTime.now()๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์œผ๋‚˜, ๋ถ„์‚ฐ ์‹œ์Šคํ…œ์—์„œ ์š”๊ตฌ๋˜๋Š” ์ผ๊ด€๋œ ํƒ€์ž„์กด ์ „๋žต(์˜ˆ: UTC)์ด ๋ช…์‹œ์ ์œผ๋กœ ์ ์šฉ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธ ํ•„์š”
  2. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค persistence ๋™์ž‘: Hibernate๊ฐ€ ZonedDateTime์„ ํ‘œ์ค€ TIMESTAMP ์ปฌ๋Ÿผ์— ์ €์žฅํ•  ๋•Œ offset๋งŒ ๋ณด์กด๋˜๊ณ  timezone ID๋Š” ์ €์žฅ๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ, ์ด๋Ÿฌํ•œ ๋™์ž‘์ด ์˜๋„๋œ ๊ฒƒ์ธ์ง€ ํ™•์ธ
  3. ํƒ€์ž„์กด ์ •๋ณด ํ•„์š”์„ฑ: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— timezone ID๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋ฉด TIMESTAMP WITH TIMEZONE ์ปฌ๋Ÿผ ์‚ฌ์šฉ ๋˜๋Š” ๋ณ„๋„ ํ•„๋“œ ์ถ”๊ฐ€ ๊ฒ€ํ† 
apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java (2)

24-46: ๋‘ ๊ฐœ์˜ ๋‹ค๋ฅธ ์žฌ์‹œ๋„ ๋ฉ”์ปค๋‹ˆ์ฆ˜์˜ ์˜๋„ ๋ช…ํ™•ํ™” ํ•„์š”

ํ˜„์žฌ ์ฝ”๋“œ์—๋Š” ๋‘ ๊ฐ€์ง€ ๋ณ„๊ฐœ์˜ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๊ฒฝ๋กœ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค:

  1. OutboxRelay (60์ดˆ ์ฃผ๊ธฐ): OutboxEvent ํ…Œ์ด๋ธ”์˜ ๋ชจ๋“  ๋ฏธ๋ฐœํ–‰ ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ โ†’ ํ‘œ์ค€ Outbox ํŒจํ„ด
  2. DeadLetterQueueProcessor (300์ดˆ ์ฃผ๊ธฐ): FailedEvent ํ…Œ์ด๋ธ”์˜ ์‹คํŒจํ•œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ โ†’ CouponUsageEventListener, LikeCountAggregateListener ๋“ฑ์—์„œ ํŠน์ • ์˜ˆ์™ธ(๋‚™๊ด€์  ์ž ๊ธˆ, ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์˜ค๋ฅ˜)๊ฐ€ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ๋งŒ ํ˜ธ์ถœ

๋‘ ๋ฉ”์ปค๋‹ˆ์ฆ˜์€ ๊ฐ™์€ ์ด๋ฒคํŠธ๋ฅผ ์ค‘๋ณต ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์œผ๋‚˜, ์œ ์‚ฌํ•œ ์žฌ์‹œ๋„ ๊ตฌ์กฐ(์ตœ๋Œ€ 5ํšŒ, ๋™์ผํ•œ ๋กœ์ง)๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๋‘ ๊ณ„์ธต์˜ ์—ญํ•  ๋ถ„๋‹ด์ด ๋ช…ํ™•ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ, ๋‹ค์Œ์„ ๊ฒ€ํ† ํ•˜์„ธ์š”:

  • DeadLetterQueueProcessor๊ฐ€ ํ•„์š”ํ•œ๊ฐ€, ์•„๋‹ˆ๋ฉด OutboxRelay๋งŒ์œผ๋กœ ์ถฉ๋ถ„ํ•œ๊ฐ€?
  • ๋‘ ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด ๋ชจ๋‘ ํ•„์š”ํ•˜๋‹ค๋ฉด ๊ฐ๊ฐ์˜ ์ฑ…์ž„์„ ๋ช…ํ™•ํžˆ ํ•˜๊ณ , ์ฝ”๋“œ ์ฃผ์„์œผ๋กœ ์„ค๊ณ„ ์˜๋„๋ฅผ ๋ฌธ์„œํ™”ํ•  ๊ฒƒ

24-39: applicationEventPublisher๋Š” Kafka๊ฐ€ ์•„๋‹Œ in-memory ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜์ด๋ฏ€๋กœ ํŠธ๋žœ์žญ์…˜ ๋™๊ธฐํ™” ์šฐ๋ ค๋Š” ๋ถ€์ ์ ˆํ•จ

์ฝ”๋“œ ๊ฒ€ํ† ์—์„œ ์ œ์‹œํ•œ Kafka ํŠธ๋žœ์žญ์…˜ + DB ํŠธ๋žœ์žญ์…˜ ์›์ž์„ฑ ๋ฌธ์ œ๋Š” ์ด ๊ตฌํ˜„์— ์ ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. eventPublisher๋Š” ApplicationEventPublisher๋กœ, Spring์˜ in-memory ๋™๊ธฐ ์ด๋ฒคํŠธ ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด๋ฉฐ, ์‹ค์ œ Kafka ์ „์†ก์€ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)๋ฅผ ํ†ตํ•ด OutboxHandler์—์„œ ์ด๋ฃจ์–ด์ง‘๋‹ˆ๋‹ค.

๋‹ค๋งŒ ๋‹ค์Œ ๊ฐœ์„ ์ด ๊ณ ๋ คํ•  ๊ฐ€์น˜๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค:

  • ์ค„ 38-39์˜ ๋ฐœํ–‰-์‚ญ์ œ ์ˆœ์„œ: ๋ฐœํ–‰ ํ›„ ์‚ญ์ œ ์‹คํŒจ ์‹œ FailedEvent ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋‚จ์•„ ๋‹ค์Œ ์žฌ์‹œ๋„์—์„œ ์ค‘๋ณต ๋ฐœํ–‰๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์‚ญ์ œ ๋ณด์žฅ ๋˜๋Š” ์‚ญ์ œ ๋จผ์ € ์ฒ˜๋ฆฌ๋ฅผ ๊ฒ€ํ† ํ•˜์„ธ์š”.
  • ๊ด‘๋ฒ”์œ„ํ•œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ: ๋ชจ๋“  Exception์„ ๋™์ผํ•˜๊ฒŒ ์ทจ๊ธ‰ํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ์‹ค์ œ ์˜ˆ์™ธ ๋ฉ”์‹œ์ง€ ๋กœ๊น…์ด ์—†์Šต๋‹ˆ๋‹ค. ๋ณต๊ตฌ ๋ถˆ๊ฐ€๋Šฅํ•œ ์˜ค๋ฅ˜(ClassNotFoundException, JSON ํŒŒ์‹ฑ ์˜ค๋ฅ˜)์™€ ์ผ์‹œ์  ์˜ค๋ฅ˜๋ฅผ ๊ตฌ๋ถ„ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

Likely an incorrect or invalid review comment.

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

5-5: LGTM!

Kafka ๋ชจ๋“ˆ ์˜์กด์„ฑ ์ถ”๊ฐ€๊ฐ€ ์ ์ ˆํ•˜๋ฉฐ, ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜ ๊ตฌํ˜„์„ ์œ„ํ•œ ์˜ฌ๋ฐ”๋ฅธ ํ†ตํ•ฉ์ž…๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java (1)

20-22: AFTER_COMMIT ๋ฆฌ์Šค๋„ˆ์˜ ํŠธ๋žœ์žญ์…˜ ์ „ํŒŒ ์ „๋žต์„ ๊ฒ€ํ† ํ•˜์„ธ์š”.

REQUIRES_NEW ์ „ํŒŒ ์†์„ฑ์ด ์ถ”๊ฐ€๋˜์–ด ์ฟ ํฐ ํ™•์ •์ด ๋…๋ฆฝ์ ์ธ ํŠธ๋žœ์žญ์…˜์—์„œ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ์ด๋Š” ๋ถ€๋ชจ ํŠธ๋žœ์žญ์…˜(์ฃผ๋ฌธ/๊ฒฐ์ œ)์ด ์ด๋ฏธ ์ปค๋ฐ‹๋œ ํ›„ ์‹คํ–‰๋˜๋ฏ€๋กœ, ์ฟ ํฐ ํ™•์ • ์‹คํŒจ ์‹œ ์ฃผ๋ฌธ/๊ฒฐ์ œ๋Š” ์„ฑ๊ณต ์ƒํƒœ๋กœ ๋‚จ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

ํ˜„์žฌ ๊ตฌ์กฐ:

  1. ์ฃผ๋ฌธ/๊ฒฐ์ œ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ โ†’ AFTER_COMMIT ์ด๋ฒคํŠธ ๋ฐœํ–‰
  2. ์ƒˆ ํŠธ๋žœ์žญ์…˜์—์„œ ์ฟ ํฐ ํ™•์ • ์‹œ๋„
  3. ์‹คํŒจ ์‹œ FailedEventStore.scheduleRetry๋กœ ์žฌ์‹œ๋„ ์˜ˆ์•ฝ

์ด ํŒจํ„ด์€ ์ตœ์ข… ์ผ๊ด€์„ฑ(eventual consistency)์„ ์ œ๊ณตํ•˜์ง€๋งŒ, ์ฟ ํฐ ํ™•์ •๊ณผ ๊ฒฐ์ œ ๊ฐ„ ์ผ์‹œ์  ๋ถˆ์ผ์น˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋น„์ฆˆ๋‹ˆ์Šค ์š”๊ตฌ์‚ฌํ•ญ์— ๋”ฐ๋ผ ์ด๋Ÿฌํ•œ ํŠธ๋ ˆ์ด๋“œ์˜คํ”„๊ฐ€ ํ—ˆ์šฉ๋˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”.

apps/commerce-api/src/main/resources/application.yml (1)

26-26: LGTM!

Kafka ์„ค์ • ํŒŒ์ผ ์ž„ํฌํŠธ๊ฐ€ ์ถ”๊ฐ€๋˜์–ด ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜ ํ†ตํ•ฉ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)

35-36: getProductInfo() ๋ฉ”์„œ๋“œ์— @Transactional ์–ด๋…ธํ…Œ์ด์…˜ ์ถ”๊ฐ€ ํ•„์š”

ProductViewEvent ๋ฐœํ–‰ ๋กœ์ง์ด ProductEventOutboxHandler์˜ Transactional Outbox ํŒจํ„ด์œผ๋กœ ์ ์ ˆํžˆ ํ†ตํ•ฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ํ˜„์žฌ getProductInfo() ๋ฉ”์„œ๋“œ๋Š” @Transactional ์–ด๋…ธํ…Œ์ด์…˜์ด ์—†์–ด ๋‹ค์Œ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

  1. TransactionalEventListener ์ž‘๋™ ๋ถˆ๊ฐ€: ํ•ธ๋“ค๋Ÿฌ์˜ @TransactionalEventListener(phase = AFTER_COMMIT)๋Š” ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ๊ฐ€ ํ•„์š”ํ•œ๋ฐ, ๋ฉ”์„œ๋“œ์— ํŠธ๋žœ์žญ์…˜์ด ์—†์œผ๋ฉด AFTER_COMMIT ์‹œ์ ์ด ๋ช…ํ™•ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  2. ์ด๋ฒคํŠธ ์ˆœ์„œ ๋ณด์žฅ ๋ถˆ๊ฐ€: ํŠธ๋žœ์žญ์…˜์ด ์—†์œผ๋ฉด ์ด๋ฒคํŠธ๊ฐ€ ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์ „์— ๋ฐœํ–‰๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฉ”์„œ๋“œ์— @Transactional ์–ด๋…ธํ…Œ์ด์…˜์„ ์ถ”๊ฐ€ํ•˜์—ฌ ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ์ •์˜ํ•˜์„ธ์š”.

โ›” Skipped due to learnings
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 + ์žฌ์‹œ๋„)์„ ์‚ฌ์šฉํ•œ๋‹ค.
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java (1)

3-4: ํŒจํ‚ค์ง€ ๊ตฌ์กฐ ๊ฐœ์„ ์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ํด๋ž˜์Šค๋“ค์„ ๋ณ„๋„์˜ event ์„œ๋ธŒํŒจํ‚ค์ง€๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ์ฝ”๋“œ ๊ตฌ์กฐ๊ฐ€ ๋ช…ํ™•ํ•ด์กŒ์Šต๋‹ˆ๋‹ค.

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

1-10: ๋ฉฑ๋“ฑ์„ฑ ๊ตฌํ˜„์„ ์œ„ํ•œ ํ‘œ์ค€ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์ž…๋‹ˆ๋‹ค.

EventHandled ์—”ํ‹ฐํ‹ฐ์˜ ๋ฐ์ดํ„ฐ ์•ก์„ธ์Šค๋ฅผ ์œ„ํ•œ ํ‘œ์ค€ JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋กœ, ์ค‘๋ณต ์ด๋ฒคํŠธ ํ•„ํ„ฐ๋ง์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java (1)

1-5: ์•„์›ƒ๋ฐ•์Šค ํŒจํ„ด์˜ ์ƒํƒœ ๊ด€๋ฆฌ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

INIT, PUBLISHED, FAILED ์„ธ ๊ฐ€์ง€ ์ƒํƒœ๋กœ ์•„์›ƒ๋ฐ•์Šค ์ด๋ฒคํŠธ์˜ ๋ผ์ดํ”„์‚ฌ์ดํด์„ ๋ช…ํ™•ํ•˜๊ฒŒ ํ‘œํ˜„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java (1)

1-9: ์ด๋ฒคํŠธ ํŽ˜์ด๋กœ๋“œ ์ •์˜๊ฐ€ ๋ช…ํ™•ํ•ฉ๋‹ˆ๋‹ค.

Java record๋ฅผ ์‚ฌ์šฉํ•œ ๋ถˆ๋ณ€ ์ด๋ฒคํŠธ ๊ตฌ์กฐ๋กœ, eventId, productId, currentLikeCount ํ•„๋“œ๊ฐ€ ๋ช…ํ™•ํ•˜๊ฒŒ ์ •์˜๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

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

4-4: ์บ์‹œ ํ•ธ๋“ค๋Ÿฌ ํŒจํ‚ค์ง€ ์ด๋™์ด ๋ฐ˜์˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

RedisCacheHandler์˜ ํŒจํ‚ค์ง€๊ฐ€ support.cache์—์„œ core.cache๋กœ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

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

1-10: ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„๋ฅผ ์œ„ํ•œ ํ‘œ์ค€ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ์ž…๋‹ˆ๋‹ค.

ProductMetrics ์—”ํ‹ฐํ‹ฐ์˜ CRUD ์ž‘์—…์„ ์œ„ํ•œ ํ‘œ์ค€ JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋กœ, ์ œํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์„œ๋น„์Šค์—์„œ ํ™œ์šฉ๋ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java (1)

1-1: ํŒจํ‚ค์ง€ ์žฌ๊ตฌ์„ฑ์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ํด๋ž˜์Šค๋ฅผ event ์„œ๋ธŒํŒจํ‚ค์ง€๋กœ ์ด๋™ํ•˜์—ฌ ์ฝ”๋“œ ๊ตฌ์กฐ๊ฐ€ ๊ฐœ์„ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋กœ์ง์€ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

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

40-63: ์ด ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ๋กœ ๋ฉฑ๋“ฑ์„ฑ์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ProductStockEvent.of() ๋ฉ”์„œ๋“œ๋Š” ํ˜ธ์ถœ๋  ๋•Œ๋งˆ๋‹ค ์ƒˆ๋กœ์šด UUID๋ฅผ ์ƒ์„ฑํ•˜์ง€๋งŒ, ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋Š” ์ฒซ ๋ฒˆ์งธ ์ค„์—์„œ firstEvent ์ธ์Šคํ„ด์Šค๋ฅผ ํ•œ ๋ฒˆ๋งŒ ์ƒ์„ฑํ•œ ํ›„ ๋™์ผํ•œ ์ธ์Šคํ„ด์Šค๋ฅผ ๋‘ ๋ฒˆ ์žฌ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ eventId๋Š” ๋‘ ํ˜ธ์ถœ์—์„œ ๋™์ผํ•˜๋ฉฐ, ํ…Œ์ŠคํŠธ๋Š” ์˜๋„ํ•œ ๋Œ€๋กœ ์ค‘๋ณต ์ด๋ฒคํŠธ์˜ ๋ฉฑ๋“ฑ์„ฑ์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.

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

11-25: ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ์—”ํ‹ฐํ‹ฐ ์„ค๊ณ„๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

eventId๋ฅผ ๊ธฐ๋ณธ ํ‚ค๋กœ ์‚ฌ์šฉํ•˜์—ฌ ์ค‘๋ณต ์ด๋ฒคํŠธ ํ•„ํ„ฐ๋ง์„ ํšจ๊ณผ์ ์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ธฐ์กด ์ฝ”๋“œ๋ฒ ์ด์Šค์˜ OutboxEvent, ProductMetrics์™€ ๋™์ผํ•˜๊ฒŒ LocalDateTime์„ ์‚ฌ์šฉํ•˜์—ฌ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java (1)

5-21: ์ด๋ฒคํŠธ ๋ ˆ์ฝ”๋“œ ์„ค๊ณ„๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

Kafka ์ง๋ ฌํ™”์— ์ ํ•ฉํ•œ long ํƒ€์ž…์˜ ํƒ€์ž„์Šคํƒฌํ”„์™€ UUID ๊ธฐ๋ฐ˜ eventId ์ƒ์„ฑ ํŒจํ„ด์ด ๋‹ค๋ฅธ ์ด๋ฒคํŠธ(LikeCreatedEvent, ProductViewEvent)์™€ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java (1)

27-27: getStock() ํ˜ธ์ถœ ์‹œ์ ์— ๋Œ€ํ•œ ๊ณ ๋ ค๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

AFTER_COMMIT ์‹œ์ ์— getStock()์„ ํ˜ธ์ถœํ•˜๋ฏ€๋กœ, ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์‹œ์ ๊ณผ ์žฌ๊ณ  ์กฐํšŒ ์‹œ์  ์‚ฌ์ด์— ๋‹ค๋ฅธ ์ฃผ๋ฌธ์— ์˜ํ•ด ์žฌ๊ณ ๊ฐ€ ๋ณ€๊ฒฝ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฉ”ํŠธ๋ฆญ ์šฉ๋„๋ผ๋ฉด ํ—ˆ์šฉ ๊ฐ€๋Šฅํ•˜์ง€๋งŒ, ์ •ํ™•ํ•œ ์žฌ๊ณ  ์Šค๋ƒ…์ƒท์ด ํ•„์š”ํ•˜๋‹ค๋ฉด ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์บก์ฒ˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java (1)

6-21: ์ด๋ฒคํŠธ ์„ค๊ณ„๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

DomainEvent ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•˜์—ฌ ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ํ™•๋ณดํ–ˆ๊ณ , ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ํŒจํ„ด์ด ๋‹ค๋ฅธ ์ด๋ฒคํŠธ๋“ค๊ณผ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. productId ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ Long์—์„œ long์œผ๋กœ ์–ธ๋ฐ•์‹ฑ๋˜๋Š” ๋ถ€๋ถ„์€ null ์ž…๋ ฅ ์‹œ fail-fast ๋™์ž‘์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java (1)

10-19: Repository ๊ตฌํ˜„์ด ์˜ฌ๋ฐ”๋ฆ„

Spring Data JPA์˜ ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ ๋„ค์ด๋ฐ ๊ทœ์น™์„ ์ •ํ™•ํ•˜๊ฒŒ ๋”ฐ๋ฅด๊ณ  ์žˆ์œผ๋ฉฐ, outbox ํŒจํ„ด์˜ ์š”๊ตฌ์‚ฌํ•ญ(eventId ์กฐํšŒ, ์žฌ์‹œ๋„ ๋Œ€์ƒ ์ด๋ฒคํŠธ ์กฐํšŒ)์„ ์ถฉ์กฑํ•ฉ๋‹ˆ๋‹ค.

findTop10ByStatusInAndRetryCountLessThanOrderByCreatedAtAsc ๋ฉ”์„œ๋“œ๋ช…์ด ๊ธธ์ง€๋งŒ, Spring Data์˜ ์ž๋™ ์ฟผ๋ฆฌ ์ƒ์„ฑ ๊ทœ์น™์— ๋”ฐ๋ฅธ ๊ฒƒ์œผ๋กœ ๋ช…ํ™•ํ•˜๊ณ  ํƒ€์ž… ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java (1)

66-75: ์ด๋ฒคํŠธ ํŽ˜์ด๋กœ๋“œ ํ™•์žฅ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ ์šฉ๋จ

OrderCreatedEvent.of()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ orderItems, cardNo, couponId ๋“ฑ ์ถ”๊ฐ€ ์ปจํ…์ŠคํŠธ๋ฅผ ํฌํ•จํ•˜๋„๋ก ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ๋‹ค์šด์ŠคํŠธ๋ฆผ ์ด๋ฒคํŠธ ์†Œ๋น„์ž๊ฐ€ ๋” ํ’๋ถ€ํ•œ ์ •๋ณด๋ฅผ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

OrderCreatedEvent์˜ ๋ชจ๋“  ํ•„๋“œ(String, Long, List, PaymentType enum)๋Š” Spring Kafka์˜ JsonSerializer์™€ Jackson ObjectMapper๋ฅผ ํ†ตํ•ด ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•˜๋ฉฐ, JacksonConfig์—์„œ READ_ENUMS_USING_TO_STRING ๋ฐ NON_NULL ํฌํ•จ ์„ค์ •์ด ์ ์ ˆํžˆ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์–ด Kafka๋กœ์˜ ์ง๋ ฌํ™” ๋ฐ ์ „์†ก์ด ๋ฌธ์ œ์—†์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.

Comment on lines +34 to +42
try {
Class<?> eventClass = Class.forName(outbox.getEventType());
Object originalEvent = objectMapper.readValue(outbox.getPayload(), eventClass);

eventPublisher.publishEvent(originalEvent);

log.info("[Outbox Relay] ์ด๋ฒคํŠธ ์žฌ๋ฐœํ–‰ ์„ฑ๊ณต: {}", outbox.getEventId());
outbox.markPublished();

Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

์žฌ๋ฐœํ–‰ ๋กœ์ง์ด ๋ฌดํ•œ ๋ฃจํ”„๋ฅผ ์œ ๋ฐœํ•  ์ˆ˜ ์žˆ์Œ

ApplicationEventPublisher.publishEvent()๋กœ ์—ญ์ง๋ ฌํ™”๋œ ์ด๋ฒคํŠธ๋ฅผ ์žฌ๋ฐœํ–‰ํ•˜๋ฉด, ์›๋ณธ @TransactionalEventListener ํ•ธ๋“ค๋Ÿฌ๋“ค(์˜ˆ: ProductEventOutboxHandler, LikeEventOutboxHandler)์ด ๋‹ค์‹œ ํŠธ๋ฆฌ๊ฑฐ๋ฉ๋‹ˆ๋‹ค. ์ด๋Š”:

  1. ๋™์ผ ์ด๋ฒคํŠธ๊ฐ€ outbox ํ…Œ์ด๋ธ”์— ์ค‘๋ณต ์ €์žฅ๋ฉ๋‹ˆ๋‹ค
  2. Kafka๋กœ ์ค‘๋ณต ๋ฐœํ–‰์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค
  3. ์žฌ์‹œ๋„ ๋กœ์ง์˜ ๋ณธ๋ž˜ ๋ชฉ์ (๋ฐœํ–‰ ์‹คํŒจ ๋ณต๊ตฌ)์ด ๋‹ฌ์„ฑ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค

OutboxRelay์˜ ๋ชฉ์ ์€ outbox์— ์ €์žฅ๋˜์—ˆ์œผ๋‚˜ Kafka ๋ฐœํ–‰์— ์‹คํŒจํ•œ ์ด๋ฒคํŠธ๋ฅผ ์žฌ์ „์†กํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ApplicationEventPublisher๊ฐ€ ์•„๋‹ˆ๋ผ ์ง์ ‘ KafkaTemplate์„ ์‚ฌ์šฉํ•˜์—ฌ Kafka๋กœ๋งŒ ์žฌ๋ฐœํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ๊ถŒ์žฅ ์ˆ˜์ • ๋ฐฉ์•ˆ
 @Component
 @RequiredArgsConstructor
 @Slf4j
 public class OutboxRelay {

   private final OutboxRepository outboxRepository;
-  private final ApplicationEventPublisher eventPublisher;
+  private final KafkaTemplate<Object, Object> kafkaTemplate;
   private final ObjectMapper objectMapper;

   @Scheduled(fixedDelay = 60000)
   @Transactional
   public void resendPendingEvents() {
     List<OutboxEvent> pendingEvents = outboxRepository.findTop10ByStatusInAndRetryCountLessThanOrderByCreatedAtAsc(
         List.of(OutboxStatus.INIT, OutboxStatus.FAILED), 5
     );

     if (pendingEvents.isEmpty()) return;

     for (OutboxEvent outbox : pendingEvents) {
       try {
         Class<?> eventClass = Class.forName(outbox.getEventType());
         Object originalEvent = objectMapper.readValue(outbox.getPayload(), eventClass);

-        eventPublisher.publishEvent(originalEvent);
+        // Determine topic and key based on event type/aggregate
+        String topic = determineTopicFromAggregateType(outbox.getAggregateType());
+        kafkaTemplate.send(topic, outbox.getAggregateId(), originalEvent)
+            .whenComplete((result, ex) -> {
+              if (ex == null) {
+                outbox.markPublished();
+              } else {
+                log.error("[Outbox Relay] Kafka ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId(), ex);
+                outbox.markFailed();
+              }
+              outboxRepository.save(outbox);
+            });

-        log.info("[Outbox Relay] ์ด๋ฒคํŠธ ์žฌ๋ฐœํ–‰ ์„ฑ๊ณต: {}", outbox.getEventId());
-        outbox.markPublished();

       } catch (Exception e) {
         log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId(), e);
         outbox.markFailed();
+        outboxRepository.save(outbox);
       }
     }
   }
+
+  private String determineTopicFromAggregateType(String aggregateType) {
+    return switch (aggregateType) {
+      case "PRODUCT_VIEW", "PRODUCT_METRICS" -> "catalog-events";
+      case "ORDER" -> "order-events";
+      default -> throw new IllegalArgumentException("Unknown aggregate type: " + aggregateType);
+    };
+  }
 }

Committable suggestion skipped: line range outside the PR's diff.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
around lines 34-42, remove the use of
ApplicationEventPublisher.publishEvent(...) which re-triggers
@TransactionalEventListener handlers and causes duplicate outbox/Kafka writes;
instead use the project's KafkaTemplate to publish directly to the appropriate
Kafka topic using the outbox payload (or topic name stored on the outbox), wait
for successful send/ack (or handle the Future callback) and only call
outbox.markPublished() after the send succeeds; also catch and log send failures
(and avoid re-publishing via ApplicationEventPublisher) so retries are handled
without invoking application event listeners.

Comment on lines +43 to +46
} catch (Exception e) {
log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId());
outbox.markFailed();
}
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐ŸŸก Minor

์˜ˆ์™ธ ์ •๋ณด๊ฐ€ ๋กœ๊ทธ์— ๋ˆ„๋ฝ๋จ

์žฌ๋ฐœํ–‰ ์‹คํŒจ ์‹œ ์˜ˆ์™ธ ๊ฐ์ฒด๋ฅผ ๋กœ๊น…ํ•˜์ง€ ์•Š์•„ ์‹คํŒจ ์›์ธ ํŒŒ์•…์ด ์–ด๋ ต์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
       } catch (Exception e) {
-        log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId());
+        log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId(), e);
         outbox.markFailed();
       }
๐Ÿ“ Committable suggestion

โ€ผ๏ธ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (Exception e) {
log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId());
outbox.markFailed();
}
} catch (Exception e) {
log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId(), e);
outbox.markFailed();
}
๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java
around lines 43 to 46, the catch block logs the eventId but omits the exception
details; update the log.error call to include the caught exception so stacktrace
and message are recorded (e.g., log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}",
outbox.getEventId(), e)); keep calling outbox.markFailed() after logging.

Comment on lines +9 to +19
public record OrderCreatedEvent(
String eventId,
Long orderId,
Long userId,
List<OrderItemInfo> items,
long finalAmount,
PaymentType paymentType,
String cardType,
String cardNo,
Long couponId
) {
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

find . -type f -name "*.java" | grep -i order | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1779


๐Ÿ Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1600


๐Ÿ Script executed:

rg -n "OrderCreatedEvent" -A 3 -B 1 --type java | head -50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5711


๐Ÿ Script executed:

rg -n "log\." --type java | grep -i "ordercreated\|cardno" -C 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

rg -n "KafkaTemplate\|kafkaTemplate\|send" --type java -C 3 | grep -A 5 -B 5 "OrderCreatedEvent" | head -40

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

rg -n "KafkaTemplate\|kafkaTemplate" --type java -A 5 -B 2 | head -60

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1255


๐Ÿ Script executed:

rg -n "serializer\|Serializer" --type java | grep -i "order\|kafka"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

fd "application\\.yml\|application\\.yaml" | xargs cat -n

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

rg -n "@JsonIgnore\|@Transient\|mask\|encrypt" --type java | grep -i "card\|sensitive"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๋ฏผ๊ฐํ•œ ๊ฒฐ์ œ ์ •๋ณด(cardNo) ๋…ธ์ถœ - ์ฆ‰์‹œ ์ˆ˜์ • ํ•„์ˆ˜

OrderCreatedEvent์˜ cardNo ํ•„๋“œ๊ฐ€ Kafka ๋ฉ”์‹œ์ง€๋กœ ๊ทธ๋Œ€๋กœ ๋ฐœํ–‰๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. OrderEventOutboxHandler.java 18๋ฒˆ์งธ ์ค„์—์„œ ์ „์ฒด ์ด๋ฒคํŠธ ๊ฐ์ฒด๋ฅผ ๋งˆ์Šคํ‚น ์—†์ด ์ „์†กํ•˜๋ฏ€๋กœ, ์นด๋“œ ๋ฒˆํ˜ธ๊ฐ€ ํ‰๋ฌธ์œผ๋กœ Kafka์— ์ €์žฅ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

์ด๋Š” PCI-DSS ๊ทœ์ • ์œ„๋ฐ˜์ž…๋‹ˆ๋‹ค. ์ฆ‰์‹œ ๋‹ค์Œ์„ ์ˆ˜์ •ํ•˜์„ธ์š”:

  • cardNo๋ฅผ OrderCreatedEvent์—์„œ ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜ ๋ณ„๋„์˜ ๋ณด์•ˆ ์ด๋ฒคํŠธ๋กœ ๋ถ„๋ฆฌ
  • ๊ฒฐ์ œ ์ •๋ณด๋Š” PaymentRequestedEvent ๋“ฑ ๋ณ„๋„ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ๋กœ๋งŒ ์ฒ˜๋ฆฌ
  • ๋ฏผ๊ฐํ•œ ๊ฒฐ์ œ ๋ฐ์ดํ„ฐ๋Š” ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜์—์„œ ์ œ์™ธ
๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java
around lines 9-19, the OrderCreatedEvent currently contains the sensitive cardNo
field which is being published to Kafka unmasked; remove cardNo from this event
(or replace it with non-sensitive data such as token or cardLast4) and move any
full payment details into a separate secure payment-only event (e.g.,
PaymentRequestedEvent) or out-of-band secure store; update all
producers/handlers (including OrderEventOutboxHandler) to stop
serializing/publishing cardNo and to use the new event or token-only fields
instead so no raw PAN is emitted to Kafka.

Comment on lines +16 to +26
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreatedEvent event) {
kafkaTemplate.send("order-events", String.valueOf(event.orderId()), event)
.whenComplete((result, ex) -> {
if (ex == null) {
outboxService.markPublished(event.eventId());
} else {
outboxService.markFailed(event.eventId());
}
});
}
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

Outbox ํŒจํ„ด์ด ๋ถˆ์™„์ „ํ•˜๊ฒŒ ๊ตฌํ˜„๋จ

์ด ํ•ธ๋“ค๋Ÿฌ๋Š” outboxService.saveEvent() ํ˜ธ์ถœ์„ ๋ˆ„๋ฝํ•˜์—ฌ ๋‹ค๋ฅธ ํ•ธ๋“ค๋Ÿฌ๋“ค(LikeEventOutboxHandler, ProductEventOutboxHandler)๊ณผ ์ผ๊ด€์„ฑ์ด ์—†์Šต๋‹ˆ๋‹ค:

ํ˜„์žฌ ๊ตฌํ˜„:

public void handle(OrderCreatedEvent event) {
    kafkaTemplate.send("order-events", ...)  // saveEvent() ํ˜ธ์ถœ ์—†์Œ
        .whenComplete(...);
}

๋‹ค๋ฅธ ํ•ธ๋“ค๋Ÿฌ๋“ค์˜ ํŒจํ„ด (LikeEventOutboxHandler):

public void handle(LikeCreatedEvent event) {
    outboxService.saveEvent("PRODUCT_METRICS", ..., event);  // โœ“ outbox์— ๋จผ์ € ์ €์žฅ
    kafkaTemplate.send("catalog-events", ...)
        .whenComplete(...);
}

๋ฌธ์ œ์ :

  1. Outbox ํ…Œ์ด๋ธ”์— ์ด๋ฒคํŠธ๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์•„ Kafka ๋ฐœํ–‰ ์‹คํŒจ ์‹œ OutboxRelay๊ฐ€ ์žฌ์‹œ๋„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค
  2. At Least Once ์ „๋‹ฌ ๋ณด์žฅ์ด ๊นจ์ง‘๋‹ˆ๋‹ค
  3. ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹๊ณผ ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰ ๊ฐ„์˜ ์›์ž์„ฑ์ด ๋ณด์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค
๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
   @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
   public void handle(OrderCreatedEvent event) {
+    outboxService.saveEvent("ORDER", String.valueOf(event.orderId()), event);
+
     kafkaTemplate.send("order-events", String.valueOf(event.orderId()), event)
         .whenComplete((result, ex) -> {

๋‹จ, ์ด ์ˆ˜์ •๋„ ProductEventOutboxHandler์—์„œ ์ง€์ ํ•œ ํŠธ๋žœ์žญ์…˜ ์ „ํŒŒ ๋ฌธ์ œ๋ฅผ ๋™์ผํ•˜๊ฒŒ ๊ฐ–๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทผ๋ณธ์ ์ธ ํ•ด๊ฒฐ์„ ์œ„ํ•ด์„œ๋Š” outbox ์ €์žฅ์„ AFTER_COMMIT ์ด์ „์— ์ˆ˜ํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java
around lines 16-26, the handler omits persisting the event to the outbox; add a
call to outboxService.saveEvent(...) at the start of handle(OrderCreatedEvent)
(before kafkaTemplate.send) using the same outbox category/topic key pattern as
other handlers (e.g. "ORDER_EVENTS"), include the event payload and id
(event.eventId()) so the outbox row exists for retries, then keep the existing
kafkaTemplate.send(...).whenComplete(...) logic and continue to call
outboxService.markPublished(event.eventId()) or
outboxService.markFailed(event.eventId()) in the completion callback; ensure the
saveEvent call runs inside the transaction (i.e., before AFTER_COMMIT send) to
mirror other handlers.

Comment on lines +23 to +37
public void handleOrderCreated(OrderCreatedEvent event) {

event.items().forEach(item -> {

int currentStock = productService.getStock(item.productId());
ProductStockEvent kafkaEvent = ProductStockEvent.of(
item.productId(),
item.quantity(),
currentStock
);

outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent);
eventPublisher.publishEvent(kafkaEvent);
});
}
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐ŸŸ  Major

์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋กœ์ง์ด ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

LikeCountAggregateListener๋Š” try-catch์™€ failedEventStore.scheduleRetry()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‹คํŒจํ•œ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜์ง€๋งŒ, ์ด ๋ฆฌ์Šค๋„ˆ์—๋Š” ์—๋Ÿฌ ์ฒ˜๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. forEach ๋‚ด์—์„œ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ํ›„์† ์•„์ดํ…œ ์ฒ˜๋ฆฌ๊ฐ€ ์ค‘๋‹จ๋˜๊ณ , ์‹คํŒจํ•œ ์ด๋ฒคํŠธ๊ฐ€ ์žฌ์‹œ๋„๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
+  private final FailedEventStore failedEventStore;
+
   @Async
   @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
   public void handleOrderCreated(OrderCreatedEvent event) {
 
     event.items().forEach(item -> {
-
-      int currentStock = productService.getStock(item.productId());
-      ProductStockEvent kafkaEvent = ProductStockEvent.of(
-          item.productId(),
-          item.quantity(),
-          currentStock
-      );
-
-      outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent);
-      eventPublisher.publishEvent(kafkaEvent);
+      try {
+        int currentStock = productService.getStock(item.productId());
+        ProductStockEvent kafkaEvent = ProductStockEvent.of(
+            item.productId(),
+            item.quantity(),
+            currentStock
+        );
+
+        outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent);
+        eventPublisher.publishEvent(kafkaEvent);
+      } catch (Exception e) {
+        failedEventStore.scheduleRetry(event, "Stock event failed: " + e.getMessage());
+      }
     });
   }

Committable suggestion skipped: line range outside the PR's diff.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java
around lines 23-37, the per-item processing lacks error handling so an exception
in one item will stop the loop and no retry is scheduled; wrap the body of the
forEach for each item in a try-catch, move saving/publishing inside the try, and
in the catch call the failedEventStore.scheduleRetry(...) method (matching the
pattern/signature used in LikeCountAggregateListener), log the error with
context (productId, quantity) and continue so remaining items are processed.

Comment on lines +21 to +58
@KafkaListener(
topics = "catalog-events",
groupId = "metrics-group"
)
public void consumeLikeCount(ConsumerRecord<String, LikeCountEvent> record, Acknowledgment ack) {
try {
metricsService.processLikeCountEvent(record.value());
ack.acknowledge();
} catch (Exception e) {
log.error("์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", e.getMessage());
}
}

@KafkaListener(
topics = "catalog-events",
groupId = "metrics-group"
)
public void consumeProductView(ProductViewEvent event, Acknowledgment ack) {
try {
metricsService.processProductViewEvent(event);
ack.acknowledge();
} catch (Exception e) {
log.error("์กฐํšŒ์ˆ˜ ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", event.eventId(), e);
}
}

@KafkaListener(
topics = "catalog-events",
groupId = "metrics-group"
)
public void consumeSalesCount(ProductStockEvent event, Acknowledgment ack) {
try {
metricsService.processSalesCountEvent(event);
ack.acknowledge();
} catch (Exception e) {
log.error("ํŒ๋งค๋Ÿ‰ ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", event.eventId(), e);
}
}
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

# Find all references to catalog-events topic
rg -n "catalog-events" --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 848


๐Ÿ Script executed:

# Find KafkaTemplate usage and how events are being sent
rg -n "kafkaTemplate" --type java -A 5 -B 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 7095


๐Ÿ Script executed:

# Find MetricsEventConsumer and related service class
fd -t f "MetricsEventConsumer\|MetricsService" --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 259


๐Ÿ Script executed:

# Check for EventInboxAspect and @InboxEvent usage
rg -n "EventInboxAspect\|@InboxEvent" --type java -B 2 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Find the metricsService implementation
fd -t f "metricsService\|MetricsService" -g "*.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 181


๐Ÿ Script executed:

# Find MetricsService implementation
rg -n "class MetricsService" --type java -A 30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Find event class definitions
rg -n "class LikeCountEvent\|class ProductViewEvent\|class ProductStockEvent" --type java -B 2 -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check if there's type information in message deserialization config
rg -n "ObjectMapper\|TypeIdStrategy\|ClassPathXmlApplicationContext" --type java modules/kafka

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Look for test cases showing actual behavior
fd -type f -name "*MetricsEventConsumer*Test*"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 258


๐Ÿ Script executed:

# Check if there's any message filtering or type validation in consumers
rg -n "instanceof\|getClass\|getLikeCount\|getProductView\|getSalesCount" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java -B 3 -A 3

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Find MetricsService class
rg -n "class MetricsService" -A 30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Find event class definitions
rg -n "record LikeCountEvent\|class LikeCountEvent\|record ProductViewEvent\|class ProductViewEvent" -B 2 -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Look at the full MetricsEventConsumer file
cat -n apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2235


๐Ÿ Script executed:

# Check how Spring deserializes messages - look for ObjectMapper or Kafka config
rg -n "ObjectMapper\|KafkaListenerContainerFactory\|MessageConverter" modules/kafka/src

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Search for any event type definitions
find . -name "*Event.java" -o -name "*Event.kt" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1051


๐Ÿ Script executed:

# Check the Kafka config for message converter settings
cat -n modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4441


๐Ÿ Script executed:

# Look at the event class definitions
cat -n modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 279


๐Ÿ Script executed:

# Check ProductViewEvent
cat -n modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 555


๐Ÿ Script executed:

# Check ProductStockEvent
cat -n modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 700


๐Ÿ Script executed:

# Check for EventInboxAspect usage and @InboxEvent in service methods
rg -n "EventInboxAspect\|@InboxEvent\|ProductMetricsService" -B 2 -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Find ProductMetricsService implementation
find . -name "ProductMetricsService.java" -o -name "*MetricsService*.java" | grep -v test

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 180


๐Ÿ Script executed:

# Check how ProductStockEvent is produced
rg -n "ProductStockEvent" -B 5 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 13309


๐Ÿ Script executed:

# View the full ProductMetricsService file
cat -n apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2982


๐Ÿ Script executed:

# Check if there's any @InboxEvent or EventInboxAspect usage anywhere
rg -n "@InboxEvent\|EventInboxAspect" -B 3 -A 3

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check IncreaseProductViewKafkaConsumer to understand the pattern mentioned in learning
cat -n apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 245


๐Ÿ Script executed:

# Find the IncreaseProductViewKafkaConsumer file
find . -name "IncreaseProductViewKafkaConsumer.java"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Look for any other consumer classes to understand the pattern
find . -name "*KafkaConsumer.java" -type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 181


๐Ÿ Script executed:

# Check if ProductStockEvent is actually being sent to catalog-events
rg -n "send.*ProductStockEvent\|ProductStockEvent.*send" -B 5 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check what event type is actually sent by LikeEventOutboxHandler
cat -n apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1369


๐Ÿ Script executed:

# Check what LikeCreatedEvent looks like
cat -n apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 712


๐Ÿ Script executed:

# Check if ProductStockEvent is actually sent to Kafka
rg -n "kafkaTemplate.send.*ProductStockEvent\|ProductStockEvent.*kafkaTemplate" -B 3 -A 3

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check OrderSalesAggregateListener to see if ProductStockEvent is sent
cat -n apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1661


๐Ÿ Script executed:

# Check if LikeCountEvent and LikeCreatedEvent are related
rg -n "LikeCountEvent\|LikeCreatedEvent" -B 2 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check ProductEventOutboxHandler to see what it sends
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1501


๐Ÿ Script executed:

# Look for any event conversion or mapping
rg -n "LikeCountEvent\|ProductViewEvent" apps/commerce-api -B 3 -A 3

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Search for who actually sends ProductStockEvent to Kafka
rg -n "ProductStockEvent" -B 5 -A 5 | grep -A 10 -B 10 "kafkaTemplate\|send"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check if there's a listener for ProductStockEvent that sends it to Kafka
rg -n "@EventListener\|@TransactionalEventListener" apps/commerce-api -A 20 | grep -A 20 "ProductStockEvent"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Verify the actual event type mismatch - check what LikeEventOutboxHandler sends
rg -n "send.*LikeCreatedEvent\|LikeCreatedEvent.*send" -B 3 -A 3

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check if there's any conversion from LikeCreatedEvent to LikeCountEvent
rg -n "new LikeCountEvent\|LikeCountEvent.of\|LikeCountEvent.from" -B 3 -A 3

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check if OrderSalesAggregateListener or any other handler sends ProductStockEvent to Kafka
rg -n "kafkaTemplate.*send\|KafkaTemplate" apps/commerce-api -B 5 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Check all TransactionalEventListener handlers that deal with events
rg -n "@TransactionalEventListener" apps/commerce-api -A 25

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 21153


๐Ÿ Script executed:

# Verify what events are actually published to catalog-events topic
rg -n "topics = \"catalog-events\"" -B 10 -A 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4907


๐Ÿ Script executed:

# Check if there's any mapping/conversion between event types
rg -n "LikeCreatedEvent\|LikeCountEvent\|ProductViewEvent\|ProductStockEvent" apps/commerce-api/src/main/java/com/loopers/application -l

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Confirm what LikeCountAggregateListener publishes
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java | head -50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2200


๐Ÿ Script executed:

# Check if there's a listener/handler for LikeCountEvent that sends it to Kafka
rg -n "LikeCountEvent" -B 5 -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 13453


๐Ÿ Script executed:

# Verify if OrderSalesAggregateListener sends ProductStockEvent to Kafka anywhere
rg -n "OrderSalesAggregateListener\|ProductStockEvent" apps/commerce-api/src/main/java/com/loopers/application/order -B 3 -A 15 | grep -A 15 "kafkaTemplate\|send"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Final verification - check if there's any listener that converts LikeCountEvent to send to Kafka
rg -n "@TransactionalEventListener\|@EventListener" apps/commerce-api -A 30 | grep -B 5 -A 25 "LikeCountEvent"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

# Confirm ProductStockEvent is never sent to Kafka
rg -n "kafkaTemplate.*send.*ProductStockEvent\|ProductStockEvent.*kafkaTemplate" -r

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 501


๐Ÿ Script executed:

# Check if there's any handler that listens to LikeCountEvent and sends it to Kafka
rg -n "LikeCountEvent" apps/commerce-api -B 5 -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4572


likeCount ๋ฆฌ์Šค๋„ˆ์˜ ํƒ€์ž… ๋ถˆ์ผ์น˜ ๋ฐ ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ฉ”์ปค๋‹ˆ์ฆ˜ ๋ถ€์žฌ

์„ธ ๊ฐœ์˜ @KafkaListener ๋ฉ”์„œ๋“œ๊ฐ€ ๋™์ผํ•œ catalog-events ํ† ํ”ฝ๊ณผ metrics-group ๊ทธ๋ฃน ID๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์‹ฌ๊ฐํ•œ ๋ฌธ์ œ๋“ค์ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํƒ€์ž… ๋ถˆ์ผ์น˜:

  • LikeEventOutboxHandler๋Š” LikeCreatedEvent๋ฅผ Kafka๋กœ ๋ฐœํ–‰ํ•ฉ๋‹ˆ๋‹ค.
  • LikeCountAggregateListener๋Š” LikeCreatedEvent๋ฅผ ์ฒ˜๋ฆฌํ•˜์—ฌ LikeCountEvent๋ฅผ ์ƒ์„ฑํ•˜์ง€๋งŒ, ์ด๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ด๋ฒคํŠธ๋กœ๋งŒ ๋ฐœํ–‰๋˜๋ฉฐ Kafka๋กœ ์ „์†ก๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • ๊ฒฐ๊ณผ์ ์œผ๋กœ consumeLikeCount๋Š” LikeCountEvent๋ฅผ ๊ธฐ๋Œ€ํ•˜์ง€๋งŒ LikeCreatedEvent๋ฅผ ์ˆ˜์‹ ํ•˜๋ฏ€๋กœ ์—ญ์ง๋ ฌํ™”์— ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค.

๋น„๊ธฐ๋Šฅ์  ๋ฆฌ์Šค๋„ˆ:

  • OrderSalesAggregateListener๋Š” ProductStockEvent๋ฅผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ด๋ฒคํŠธ๋กœ๋งŒ ๋ฐœํ–‰ํ•˜๋ฏ€๋กœ Kafka๋กœ ์ „์†ก๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • consumeSalesCount๋Š” ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›์„ ์ˆ˜ ์—†์–ด ๋™์ž‘ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์—๋Ÿฌ ์ฒ˜๋ฆฌ ์œ„๋ฐ˜:

  • ๋ชจ๋“  ๋ฆฌ์Šค๋„ˆ์—์„œ ack.acknowledge()๊ฐ€ try ๋ธ”๋ก ๋‚ด์—์„œ ์‹คํ–‰๋˜๋ฏ€๋กœ, ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ์—๋„ ๋ฉ”์‹œ์ง€๋Š” ์ด๋ฏธ ํ™•์ธ๋œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค.
  • ์‹คํŒจํ•œ ๋ฉ”์‹œ์ง€๋Š” ์˜๊ตฌ์ ์œผ๋กœ ์†์‹ค๋˜๋ฉฐ ์žฌ์‹œ๋„ ๋ฉ”์ปค๋‹ˆ์ฆ˜์ด ์—†์Šต๋‹ˆ๋‹ค.
  • ๋‹ค๋ฅธ ์ปจ์Šˆ๋จธ๋“ค์ฒ˜๋Ÿผ ์„œ๋น„์Šค ๋ฉ”์„œ๋“œ์— @InboxEvent ์–ด๋…ธํ…Œ์ด์…˜์„ ์ ์šฉํ•˜์—ฌ EventInboxAspect ๊ธฐ๋ฐ˜์˜ ์ค‘์•™ํ™”๋œ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ํ™œ์šฉํ•˜์„ธ์š”.
๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java
around lines 21โ€“58, the three @KafkaListener methods are mismatched with what is
actually published to Kafka and lack centralized inbox-based error handling:
change the consumeLikeCount signature to consume the actual Kafka payload (e.g.,
LikeCreatedEvent) or modify the producer to publish LikeCountEvent to Kafka so
types align; remove or convert consumeSalesCount/consumeProductView to
application-event listeners if those events are not published to Kafka (or
ensure those events are published to the catalog-events topic); remove
per-method try/ack anti-pattern by annotating the underlying metricsService
processing methods with @InboxEvent so EventInboxAspect handles retries/failures
and only acknowledge (ack.acknowledge()) after successful processing; finally
update log calls to pass the caught exception (e) as the last parameter so stack
traces are logged.

Comment on lines +29 to +31
} catch (Exception e) {
log.error("์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", e.getMessage());
}
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ๋ฉ”์‹œ์ง€ ์†์‹ค ๋ฐฉ์ง€ ํ•„์š”

ํ˜„์žฌ ์—๋Ÿฌ ํ•ธ๋“ค๋ง์€ ์˜ˆ์™ธ๋ฅผ ๋กœ๊น…๋งŒ ํ•˜๊ณ  ack.acknowledge()๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์ง€๋งŒ, try-catch ๋ธ”๋ก ๋ฐ–์œผ๋กœ ์˜ˆ์™ธ๊ฐ€ ์ „ํŒŒ๋˜์ง€ ์•Š์•„ Kafka๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ์žฌ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. PR ๋ชฉํ‘œ์—์„œ ์–ธ๊ธ‰ํ•œ At Least Once ๋ณด์žฅ์ด ์œ„๋ฐ˜๋ฉ๋‹ˆ๋‹ค.

๋˜ํ•œ, ํ•™์Šต๋œ ํŒจํ„ด์— ๋”ฐ๋ฅด๋ฉด ์ด ์ฝ”๋“œ๋ฒ ์ด์Šค๋Š” EventInboxAspect๋ฅผ ํ†ตํ•œ ์ค‘์•™ ์ง‘์ค‘์‹ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ์ปจ์Šˆ๋จธ์—์„œ ๊ฐœ๋ณ„์ ์œผ๋กœ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ๊ตฌํ˜„ํ•˜๋ฉด ๋กœ์ง์ด ์ค‘๋ณต๋ฉ๋‹ˆ๋‹ค.

Based on learnings, the codebase uses EventInboxAspect for centralized error handling in Kafka consumers.

๐Ÿ”Ž ์ค‘์•™ ์ง‘์ค‘์‹ ์—๋Ÿฌ ์ฒ˜๋ฆฌ ์ ์šฉ ์ œ์•ˆ

์„œ๋น„์Šค ๋ฉ”์„œ๋“œ์— @InboxEvent ์–ด๋…ธํ…Œ์ด์…˜์„ ์ถ”๊ฐ€ํ•˜์—ฌ EventInboxAspect๊ฐ€ ์—๋Ÿฌ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋„๋ก ๋ณ€๊ฒฝ:

  @KafkaListener(topics = "catalog-events", groupId = "metrics-group")
  public void consumeLikeCount(ConsumerRecord<String, LikeCountEvent> record, Acknowledgment ack) {
-   try {
      metricsService.processLikeCountEvent(record.value());
      ack.acknowledge();
-   } catch (Exception e) {
-     log.error("์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", e.getMessage());
-   }
  }

๊ทธ๋ฆฌ๊ณ  ProductMetricsService์˜ ๋ฉ”์„œ๋“œ์— @InboxEvent ์ถ”๊ฐ€:

@InboxEvent  // Aspect๊ฐ€ ์‹คํŒจ ์‹œ EventInbox์— ์ €์žฅ
@Transactional
public void processLikeCountEvent(LikeCountEvent event) {
  // ...
}

Also applies to: 42-44, 55-57

๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java
around lines 29-31 (and similarly 42-44, 55-57), the consumer currently catches
exceptions and logs them which swallows errors and prevents EventInboxAspect
from centrally handling failures and guaranteeing At-Least-Once delivery; remove
the local try-catch so exceptions are allowed to propagate, stop calling
ack.acknowledge() on caught errors, and instead annotate the corresponding
service processing method in ProductMetricsService with @InboxEvent (and keep it
@Transactional) so EventInboxAspect will persist failures to EventInbox and
manage acknowledgements/retries centrally. Ensure no local duplicate
error-handling logic remains in these consumer methods.

Comment on lines +18 to +20
acks: all
properties:
enable.idempotence: true
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐ŸŸ  Major

enable.idempotence ์†์„ฑ์„ ๋ธŒ๋ž˜ํ‚ท ํ‘œ๊ธฐ๋ฒ•์œผ๋กœ ์ˆ˜์ •ํ•˜์„ธ์š”.

spring.kafka.producer.properties ๋‚ด Kafka ํด๋ผ์ด์–ธํŠธ ์†์„ฑ์€ YAML์—์„œ ๋ธŒ๋ž˜ํ‚ท ํ‘œ๊ธฐ๋ฒ•์„ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋ฉฐ (์˜ˆ: "[spring.json.add.type.headers]": false), ํ˜„์žฌ ์ฝ”๋“œ์˜ enable.idempotence: true๋Š” ์˜๋„ํ•œ ๋Œ€๋กœ ์ž‘๋™ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. YAML์ด ์ ์„ ์ค‘์ฒฉ ํ‚ค๋กœ ํŒŒ์‹ฑํ•˜์—ฌ Kafka ํ”„๋กœ๋“€์„œ์— ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ „๋‹ฌ๋˜์ง€ ์•Š์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•˜์„ธ์š”:

properties:
  "[enable.idempotence]": true
๐Ÿค– Prompt for AI Agents
modules/kafka/src/main/resources/kafka.yml lines 18-20: ํ˜„์žฌ
spring.kafka.producer.properties์— ์„ค์ •๋œ enable.idempotence: true๋Š” YAML์ด ์  ํ‘œ๊ธฐ๋ฒ•์„ ์ค‘์ฒฉ
ํ‚ค๋กœ ํ•ด์„ํ•ด Kafka ํด๋ผ์ด์–ธํŠธ์— ์ „๋‹ฌ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค; ํ•ด๋‹น ํ‚ค๋ฅผ ๋ธŒ๋ž˜ํ‚ท ํ‘œ๊ธฐ๋ฒ•์œผ๋กœ ๋ณ€๊ฒฝํ•ด YAML์—์„œ ๋ฌธ์ž์—ด ํ‚ค๋กœ ์ทจ๊ธ‰๋˜๋„๋ก ํ•˜๊ณ  ๊ฐ’์€
true๋กœ ์œ ์ง€ํ•˜์„ธ์š” (์˜ˆ: "[enable.idempotence]": true).

Comment on lines +8 to +31
public class PageWrapper<T> {
private List<T> content;
private long totalElements;
private int pageNumber;
private int pageSize;

public PageWrapper() {}

public PageWrapper(Page<T> page) {
this.content = page.getContent();
this.totalElements = page.getTotalElements();
this.pageNumber = page.getNumber();
this.pageSize = page.getSize();
}

public Page<T> toPage() {
return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements);
}

public List<T> getContent() { return content; }
public long getTotalElements() { return totalElements; }
public int getPageNumber() { return pageNumber; }
public int getPageSize() { return pageSize; }
}
Copy link

Choose a reason for hiding this comment

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

๐Ÿ› ๏ธ Refactor suggestion | ๐ŸŸ  Major

์—ญ์ง๋ ฌํ™”๋ฅผ ์œ„ํ•œ setter ๋ฉ”์„œ๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

PageWrapper ํด๋ž˜์Šค๋Š” Spring Data์˜ Page ๊ฐ์ฒด๋ฅผ ์ง๋ ฌํ™” ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜ํ•˜๊ธฐ ์œ„ํ•œ ๋ž˜ํผ์ž…๋‹ˆ๋‹ค.

ํ˜„์žฌ ๊ตฌ์กฐ์˜ ๋ฌธ์ œ์ :

  1. Setter ๋ถ€์žฌ: ํ•„๋“œ๊ฐ€ private์ด๊ณ  setter๊ฐ€ ์—†์–ด Jackson๊ณผ ๊ฐ™์€ ์ง๋ ฌํ™” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ์—ญ์ง๋ ฌํ™” ์‹œ ํ•„๋“œ๋ฅผ ์ฑ„์šธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. @JsonProperty ์–ด๋…ธํ…Œ์ด์…˜์ด๋‚˜ setter ๋ฉ”์„œ๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
  2. NPE ์œ„ํ—˜: toPage() ๋ฉ”์„œ๋“œ๋Š” content, totalElements ๋“ฑ์ด null์ด ์•„๋‹ˆ๋ผ๊ณ  ๊ฐ€์ •ํ•˜์ง€๋งŒ, ์—ญ์ง๋ ฌํ™” ์‹คํŒจ ์‹œ null์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  3. ์œ ํšจ์„ฑ ๊ฒ€์ฆ ๋ถ€์žฌ: pageSize๊ฐ€ 0์ด๊ฑฐ๋‚˜ ์Œ์ˆ˜์ธ ๊ฒฝ์šฐ PageRequest.of()์—์„œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
๐Ÿ”Ž Setter ์ถ”๊ฐ€ ๋ฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์ œ์•ˆ
 public class PageWrapper<T> {
   private List<T> content;
   private long totalElements;
   private int pageNumber;
   private int pageSize;
 
   public PageWrapper() {}
 
   public PageWrapper(Page<T> page) {
     this.content = page.getContent();
     this.totalElements = page.getTotalElements();
     this.pageNumber = page.getNumber();
     this.pageSize = page.getSize();
   }
 
   public Page<T> toPage() {
+    if (content == null) {
+      throw new IllegalStateException("content must not be null");
+    }
+    if (pageSize <= 0) {
+      throw new IllegalStateException("pageSize must be greater than 0");
+    }
     return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements);
   }
 
   public List<T> getContent() { return content; }
   public long getTotalElements() { return totalElements; }
   public int getPageNumber() { return pageNumber; }
   public int getPageSize() { return pageSize; }
+
+  public void setContent(List<T> content) { this.content = content; }
+  public void setTotalElements(long totalElements) { this.totalElements = totalElements; }
+  public void setPageNumber(int pageNumber) { this.pageNumber = pageNumber; }
+  public void setPageSize(int pageSize) { this.pageSize = pageSize; }
 }
๐Ÿค– Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java
around lines 8-31, add public setter methods (or annotate fields with
@JsonProperty) so Jackson can populate content, totalElements, pageNumber and
pageSize during deserialization, and harden toPage() against null/invalid values
by treating null content as Collections.emptyList(), null totalElements as 0L,
null/negative pageNumber as 0, and replacing pageSize <= 0 with 1 before calling
PageRequest.of(...); ensure the default no-arg constructor remains and perform
these simple validations inside toPage() to avoid NPEs and
IllegalArgumentException from PageRequest.

Comment on lines +58 to +63
public void deleteByPattern(String pattern) {
Set<String> keys = redisTemplate.keys(pattern + "*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

Redis KEYS ๋ช…๋ น์–ด๋Š” ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ ์„ฑ๋Šฅ ๋ฌธ์ œ ์œ ๋ฐœ

redisTemplate.keys(pattern + "*")๋Š” O(N) ๋ธ”๋กœํ‚น ์—ฐ์‚ฐ์œผ๋กœ, Redis์˜ ๋ชจ๋“  ํ‚ค๋ฅผ ์Šค์บ”ํ•˜๋Š” ๋™์•ˆ ๋‹ค๋ฅธ ๋ชจ๋“  ์ž‘์—…์„ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. ๋Œ€๊ทœ๋ชจ ๋ฐ์ดํ„ฐ์…‹์—์„œ๋Š” ์ˆ˜ ์ดˆ๊ฐ„ Redis๊ฐ€ ์‘๋‹ต ๋ถˆ๊ฐ€ ์ƒํƒœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋Š” SCAN ๋ช…๋ น์–ด๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋น„๋ธ”๋กœํ‚น ๋ฐฉ์‹์œผ๋กœ ํ‚ค๋ฅผ ์ˆœํšŒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž SCAN ๊ธฐ๋ฐ˜ ๊ตฌํ˜„ ์ œ์•ˆ
  public void deleteByPattern(String pattern) {
-   Set<String> keys = redisTemplate.keys(pattern + "*");
-   if (keys != null && !keys.isEmpty()) {
-     redisTemplate.delete(keys);
-   }
+   redisTemplate.execute((RedisCallback<Void>) connection -> {
+     ScanOptions options = ScanOptions.scanOptions()
+         .match(pattern + "*")
+         .count(100)  // ๋ฐฐ์น˜ ํฌ๊ธฐ
+         .build();
+     
+     Cursor<byte[]> cursor = connection.scan(options);
+     while (cursor.hasNext()) {
+       connection.del(cursor.next());
+     }
+     cursor.close();
+     return null;
+   });
  }
๐Ÿค– Prompt for AI Agents
In modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java
around lines 58 to 63, calling redisTemplate.keys(pattern + "*") uses the
blocking O(N) KEYS command which can freeze Redis in production; replace it with
a SCAN-based, non-blocking iteration: use RedisTemplate/ConnectionFactory to
open a cursor (ScanOptions.match(pattern + "*")) and iterate through results,
collect keys in batches (e.g., 100-1000) and call delete on each batch, ensuring
the cursor is closed in a finally block and handling potential exceptions; this
will perform incremental, non-blocking scans and safe batched deletes suitable
for production.

@yeonjiyeon yeonjiyeon merged commit 4dbd4b0 into Loopers-dev-lab:yeonjiyeon Dec 26, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant