Skip to content

Conversation

@sieun0322
Copy link
Collaborator

@sieun0322 sieun0322 commented Dec 26, 2025

๐Ÿ“Œ Summary

์ƒํ’ˆ ์ธ๊ธฐ๋„ ๊ธฐ๋ฐ˜ ๋žญํ‚น ์‹œ์Šคํ…œ ๊ตฌํ˜„

์กฐํšŒ์ˆ˜ยท์ข‹์•„์š”ยทํŒ๋งค๋Ÿ‰์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒํ’ˆ ์ธ๊ธฐ๋„๋ฅผ ๊ณ„์‚ฐํ•˜๊ณ ,
Redis๋ฅผ ํ™œ์šฉํ•œ ์‹ค์‹œ๊ฐ„ ๋žญํ‚น๊ณผ DB์— ์˜๊ตฌ ์ €์žฅ๋˜๋Š” ์ผ๊ฐ„ ๋žญํ‚น์„ ํ•จ๊ป˜ ์ œ๊ณตํ•˜๋Š” ์‹œ์Šคํ…œ์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

์ฃผ์š” ๋ณ€๊ฒฝ ์‚ฌํ•ญ

  • ์ƒํ’ˆ ๋žญํ‚น ์กฐํšŒ API ๊ตฌํ˜„ (RankingV1Controller)
  • ์ƒํ’ˆ ๋ชฉ๋ก / ์ƒํ’ˆ ์ƒ์„ธ API์— ๋žญํ‚น ์ •๋ณด ํฌํ•จ
  • Redis ๊ธฐ๋ฐ˜ ์‹ค์‹œ๊ฐ„ ๋žญํ‚น ์ง‘๊ณ„ ๋ฐ DB ์˜์†ํ™” ๋กœ์ง ๊ตฌํ˜„
  • ์ผ๊ฐ„ ๋žญํ‚น ์ ์ˆ˜ Carry Over ๋ฐฐ์น˜ ์‹œ์Šคํ…œ ์ถ”๊ฐ€ (ScoreCarryOverScheduler)

๐Ÿ’ฌ Review Points

1๏ธโƒฃ 10๋ถ„ ๋‹จ์œ„ ์ƒํ’ˆ ์ง€ํ‘œ ์‹ค์‹œ๊ฐ„ Redis ์ง‘๊ณ„

์กฐํšŒ์ˆ˜, ์ข‹์•„์š” ์ˆ˜, ํŒ๋งค๋Ÿ‰์„ 10๋ถ„ ๋‹จ์œ„ time-bucket์œผ๋กœ ๋ถ„๋ฆฌํ•ด Redis์— ์ง‘๊ณ„ํ•ฉ๋‹ˆ๋‹ค.

product_views:{productId}::{yyyyMMddHHmm}
product_likes:{productId}::{yyyyMMddHHmm}
product_sales:{productId}::{yyyyMMddHHmm}
  • TTL: 2์ผ
  • ์‹ค์‹œ๊ฐ„ ๋žญํ‚น ๊ณ„์‚ฐ ์‹œ ํ•„์š”ํ•œ ์ตœ์†Œ ๋‹จ์œ„ ๋ฐ์ดํ„ฐ๋งŒ ์œ ์ง€
  • DB ๋ถ€ํ•˜ ์—†์ด ๊ณ ๋นˆ๋„ ์ด๋ฒคํŠธ๋ฅผ ์•ˆ์ •์ ์œผ๋กœ ์ˆ˜์šฉ
  • time-bucket ์€ commerce-api ์—์„œ kafka ๋กœ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœํ–‰๋œ ์‹œ๊ฐ ๊ธฐ์ค€

2๏ธโƒฃ Redis โ†’ DB ์ง‘๊ณ„ ์ €์žฅ (MetricsBatchProcessor)

Redis์— ๋ˆ„์ ๋œ ์ง‘๊ณ„ ๋ฐ์ดํ„ฐ๋ฅผ batch ๋ฐฉ์‹์œผ๋กœ DB์— ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.

  • redisTemplate.getAndDelete(key)๋ฅผ ์‚ฌ์šฉํ•ด

    • ์ฝ๊ธฐ์™€ ์‚ญ์ œ๋ฅผ ๋™์‹œ์— ์ฒ˜๋ฆฌ
    • ์ค‘๋ณต ์ง‘๊ณ„ ๋ฐฉ์ง€
  • product_metrics ํ…Œ์ด๋ธ”์—๋Š” delta ์—…๋ฐ์ดํŠธ ๋ฐฉ์‹์œผ๋กœ ์ €์žฅ

  • "product_likes:*" ์™€ ๊ฐ™์€ ํŒจํ„ด ๊ธฐ๋ฐ˜์œผ๋กœ Redis ์ง‘๊ณ„ ํ‚ค๋ฅผ ์กฐํšŒํ•ด

    • ๊ฐ ์ง€ํ‘œ๋ณ„ ์ฆ๊ฐ€๋ถ„์„ ๊ณ„์‚ฐ ํ›„ ๋ฐ˜์˜

DB์— ์ €์žฅ๋œ ์ง‘๊ณ„ ๋ฐ์ดํ„ฐ๋Š” ์ฝํž˜๊ณผ ๋™์‹œ์— Redis์—์„œ ์ œ๊ฑฐ๋˜๋ฉฐ,
์ดํ›„ ๋ฐœ์ƒํ•˜๋Š” ์ด๋ฒคํŠธ๋Š” ์ƒˆ๋กœ์šด bucket์œผ๋กœ ๋‹ค์‹œ ์ง‘๊ณ„๋ฉ๋‹ˆ๋‹ค.


3๏ธโƒฃ ์‹ค์‹œ๊ฐ„ ์ƒํ’ˆ๋ณ„ ์ผ๊ฐ„ ๋žญํ‚น Redis ์ง‘๊ณ„

์ƒํ’ˆ ์ธ๊ธฐ๋„ ์ ์ˆ˜๋Š” ๊ฐ€์ค‘์น˜ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ณ„์‚ฐ๋˜๋ฉฐ,
์ผ์ž๋ณ„ ZSET์œผ๋กœ ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

ranking:all:{yyyyMMdd}
VIEW_WEIGHT  = 0.1
LIKE_WEIGHT  = 0.2
ORDER_WEIGHT = 0.6
TTL_DAYS     = 2
  • ZSET score๋ฅผ ํ™œ์šฉํ•ด ์‹ค์‹œ๊ฐ„ ์ •๋ ฌ
  • ์ผ๊ฐ„ ๋‹จ์œ„๋กœ ํ‚ค๋ฅผ ๋ถ„๋ฆฌํ•ด ๋žญํ‚น ๊ณ„์‚ฐ ๋ฒ”์œ„ ๋ช…ํ™•ํ™”
  • TTL์„ ํ†ตํ•ด ์ž๋™ ์ •๋ฆฌ

4๏ธโƒฃ ScoreCarryOverScheduler (์ฝœ๋“œ์Šคํƒ€ํŠธ ์™„ํ™” ๋ฐฐ์น˜)

์ฝœ๋“œ์Šคํƒ€ํŠธ ์™„ํ™”๋ฅผ ์œ„ํ•ด ์ ์ˆ˜ Carry Over ์ „๋žต์„ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.

  1. ๋งค์ผ 23:50

    • ๋‹น์ผ ๋žญํ‚น ์ ์ˆ˜๋ฅผ
    • ๋‹ค์Œ ๋‚  ๋žญํ‚น ํ‚ค(ranking:all:{yyyyMMdd+1})์— ์ผ์ • ๋น„์œจ๋กœ ๋ฐ˜์˜
  2. ์ผ๊ฐ„ ๋žญํ‚น ๊ฒฐ๊ณผ๋ฅผ daily_ranking ํ…Œ์ด๋ธ”์— ์˜๊ตฌ ์ €์žฅ

    • ์ด๋ ฅ ๊ด€๋ฆฌ ๋ฐ ํ†ต๊ณ„ ๋ถ„์„ ์šฉ๋„

Q. Carry Over ์‹œ์ (11:50) ๊ณผ ์ž์ • (00:00) ์ง์ „ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ

ํ˜„์žฌ ScoreCarryOverScheduler๋Š” ๋งค์ผ 23:50์— ์‹คํ–‰๋˜์–ด
๋‹น์ผ ์ง‘๊ณ„๋œ ๋žญํ‚น ์ ์ˆ˜๋ฅผ ๋‹ค์Œ ๋‚  ๋žญํ‚น ํ‚ค์— ์ผ์ • ๋น„์œจ๋กœ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.

์ด ๊ตฌ์กฐ์—์„œ๋Š” 23:50 ~ 24:00 ์‚ฌ์ด์— ๋ฐœ์ƒํ•œ ๋‹น์ผ ์ด๋ฒคํŠธ ๋ฐ์ดํ„ฐ๊ฐ€
๋‹ค์Œ ๋‚  ๋žญํ‚น ์ ์ˆ˜์—๋Š” ํฌํ•จ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

ํ•ด๋‹น ๋ฐ์ดํ„ฐ์˜ ์˜ํ–ฅ๋„๊ฐ€ ๋ฏธ๋ฏธํ•  ๊ฒƒ ๊ฐ™์•„ ์šฐ์„  ๋ณด์™„ํ•˜๋Š” ๋กœ์ง์„ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ์ž์ • ์ง์ „์—๋„ ์‚ฌ์šฉ์ž๊ฐ€ ๊ฝค ๋งŽ์„ ์‹œ๊ฐ์œผ๋กœ ๋ณด์—ฌ ์ƒ๋žตํ•˜๊ณ  ๊ฐ€๋„ ๋˜๋Š” ๋ถ€๋ถ„์ธ์ง€ ๊ถ๊ธˆํ•ฉ๋‹ˆ๋‹ค. ๋งŒ์•ฝ ๋ณด์™„ํ•œ๋‹ค๋ฉด ์–ด๋–ค ๋ฐฉ์‹์ด ์žˆ์„์ง€ ๊ถ๊ธˆํ•ฉ๋‹ˆ๋‹ค.

๊ฒ€ํ† ํ–ˆ๋˜ ๋Œ€์•ˆ (๋ฏธ์ฑ„ํƒ)

์ดˆ๊ธฐ ์„ค๊ณ„ ๋‹จ๊ณ„์—์„œ๋Š” ์•„๋ž˜์™€ ๊ฐ™์€ ๋ฐฉ์‹๋„ ๊ณ ๋ คํ–ˆ์Šต๋‹ˆ๋‹ค.

  1. 23:50 ์‹œ์ ์˜ ๋žญํ‚น ZSET ์Šค๋ƒ…์ƒท์„ ์ €์žฅ
  2. ๋‹น์ผ ์ด๋ฒคํŠธ ๋ฐ˜์˜์ด ๋ชจ๋‘ ์™„๋ฃŒ๋  ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋˜๋Š” ์‹œ๊ฐ
    (์˜ˆ: 00:10)์˜ ZSET ์Šค๋ƒ…์ƒท์„ ๋‹ค์‹œ ์กฐํšŒ
  3. ๋‘ ์Šค๋ƒ…์ƒท ๊ฐ„์˜ **์ ์ˆ˜ ๋ณ€ํ™”๋Ÿ‰(delta)**์„ ๊ณ„์‚ฐ
  4. ํ•ด๋‹น ๋ณ€ํ™”๋Ÿ‰์„ ๋‹ค์Œ ๋‚  ๋žญํ‚น ์ ์ˆ˜์— ์ถ”๊ฐ€ ๋ฐ˜์˜
  • ๊ตฌํ˜„ ๋ณต์žก๋„ ๋Œ€๋น„ ํšจ๊ณผ๊ฐ€ ์ œํ•œ์ ์ผ ์ˆ˜ ์žˆ๊ณ 
  • ์ž์ • ์ง์ „ ๋ฐ์ดํ„ฐ์˜ ์ค‘์š”๋„๊ฐ€ ์‹ค์ œ๋กœ ์–ผ๋งˆ๋‚˜ ๋˜๋Š”์ง€ ๋ช…ํ™•ํ•˜์ง€ ์•Š์•„
    ํ•ด๋‹น ๋ฐฉ์‹์€ ์ ์šฉํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Q. redisTemplate.getAndDelete

redisTemplate.getAndDelete ์‚ฌ์šฉ์‹œ, ๋™์‹œ์„ฑ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ๋ถ€๋ถ„์€ ์—†๋Š”์ง€, ์•ˆ์ „ํ•œ ๋ฐฉ์‹์ธ์ง€ ๊ถ๊ธˆํ•ฉ๋‹ˆ๋‹ค.

Q. 1ํšŒ ์กฐํšŒ ์ ์ˆ˜๋Š” 0.1, 10,000์› ์ฃผ๋ฌธ ์ ์ˆ˜๋Š” 6000

์‚ฌ์šฉ์ž์˜ ์ˆ˜์— ๋”ฐ๋ผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์ง€๋งŒ, ์กฐํšŒ์ˆ˜์— ๋น„ํ•ด ์ฃผ๋ฌธ์œผ๋กœ ๋ฐ›๋Š” ์ธ๊ธฐ ์ ์ˆ˜๊ฐ€ ์ƒ๊ฐ๋ณด๋‹ค ํฐ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ์ธ๊ธฐ์˜ ์ฒ™๋„๋Š” ํŒ๋งค๋Ÿ‰์ธ ๋А๋‚Œ์ด ๋“œ๋Š”๋ฐ, ์‹ค์ œ ์‹œ์Šคํ…œ์—์„œ๋„ ํŒ๋งค๋Ÿ‰์˜ ์ค‘์š”๋„๊ฐ€ ์ด์ •๋„ ์ธ์ง€ ๊ถ๊ธˆํ•ฉ๋‹ˆ๋‹ค.

โœ… Checklist

๐Ÿ“ˆ Ranking Consumer

  • ๋žญํ‚น ZSET ์˜ TTL, ํ‚ค ์ „๋žต์„ ์ ์ ˆํ•˜๊ฒŒ ๊ตฌ์„ฑํ•˜์˜€๋‹ค
  • ๋‚ ์งœ๋ณ„๋กœ ์ ์žฌํ•  ํ‚ค๋ฅผ ๊ณ„์‚ฐํ•˜๋Š” ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค์—ˆ๋‹ค
  • ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•œ ํ›„, ZSET ์— ์ ์ˆ˜๊ฐ€ ์ ์ ˆํ•˜๊ฒŒ ๋ฐ˜์˜๋œ๋‹ค

โšพ Ranking API

  • ๋žญํ‚น Page ์กฐํšŒ ์‹œ ์ •์ƒ์ ์œผ๋กœ ๋žญํ‚น ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค
  • ๋žญํ‚น Page ์กฐํšŒ ์‹œ ๋‹จ์ˆœํžˆ ์ƒํ’ˆ ID ๊ฐ€ ์•„๋‹Œ ์ƒํ’ˆ์ •๋ณด๊ฐ€ Aggregation ๋˜์–ด ์ œ๊ณต๋œ๋‹ค
  • ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ ํ•ด๋‹น ์ƒํ’ˆ์˜ ์ˆœ์œ„๊ฐ€ ํ•จ๊ป˜ ๋ฐ˜ํ™˜๋œ๋‹ค (์ˆœ์œ„์— ์—†๋‹ค๋ฉด null)

๐Ÿ“Ž References

https://dan.naver.com/25/sessions/681

- bucket_time(product_metrics) ์ปฌ๋Ÿผ ์ถ”๊ฐ€
- event_id ์ธ๋ฐ•์Šค ํ…Œ์ด๋ธ” ์œ ๋‹ˆํฌ ์ œ์•ฝ ์ถ”๊ฐ€
- ๋™์ผ ์œ ์ €์˜ ๊ณผ๋„ํ•œ ์กฐํšŒ ๋ฐฉ์ง€(10๋ถ„ ๋‹น ์ตœ๋Œ€ 4ํšŒ)
- eventTime ์ถ”๊ฐ€
- redis ์‹ค์‹œ๊ฐ„ ์ง‘๊ณ„ -> DB ๋ฐฐ์น˜ ์ €์žฅ
- ์ƒํ’ˆ ์ƒ์„ธ ๋žญํ‚น ์ถ”๊ฐ€
@coderabbitai
Copy link

coderabbitai bot commented Dec 26, 2025

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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

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

๊ฐœ์š”

์ด PR์€ ์ƒํ’ˆ ๋žญํ‚น ์‹œ์Šคํ…œ์„ ๋„์ž…ํ•˜๊ณ  ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘์„ ๊ตฌ์กฐํ™”ํ•ฉ๋‹ˆ๋‹ค. Redis ๊ธฐ๋ฐ˜ ์ผ์ผ ๋žญํ‚น, ๋ฒ„ํ‚ท ์‹œ๊ฐ„๋ณ„ ๋ฉ”ํŠธ๋ฆญ ์ถ”์ , ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ์Šค์ผ€์ค„๋Ÿฌ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉฐ, ProductWithLikeCount๋ฅผ ProductListItem์œผ๋กœ ๋Œ€์ฒดํ•˜๊ณ  ๋žญํ‚น ์ •๋ณด๋ฅผ ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก ์‘๋‹ต์— ํ†ตํ•ฉํ•ฉ๋‹ˆ๋‹ค.

๋ณ€๊ฒฝ ์‚ฌํ•ญ

์ง‘ํ•ฉ / ํŒŒ์ผ(๋“ค) ๋ณ€๊ฒฝ ์‚ฌํ•ญ ์š”์•ฝ
์ฃผ๋ฌธ ์ด๋ฒคํŠธ ์Šคํ‚ค๋งˆ
OrderCreatedEvent.java
OrderItemData ๋ ˆ์ฝ”๋“œ์˜ price ํ•„๋“œ๋ฅผ unitPrice๋กœ ๋ณ€๊ฒฝ
Kafka ๋ฐœํ–‰์ž
KafkaEventPublisher.java
ProducerRecord์— "eventTime" ํ—ค๋” ์ถ”๊ฐ€ (outboxEvent.getCreatedAt().toString().getBytes())
์ƒํ’ˆ ๋žญํ‚น ๋„๋ฉ”์ธ
ProductRankingService.java, RankingService.java, RankInfo.java
Redis ZSET ๊ธฐ๋ฐ˜ ์ผ์ผ ์ƒํ’ˆ ๋žญํ‚น ๊ณ„์‚ฐ (์กฐํšŒ, ์ข‹์•„์š”, ํŒ๋งค ๊ฐ€์ค‘์น˜ ์ ์šฉ) ๋ฐ ์ ์ˆ˜ ๊ด€๋ฆฌ
์ผ์ผ ๋žญํ‚น ์ €์žฅ์†Œ
DailyRanking.java, DailyRankingRepository.java, DailyRankingJpaRepository.java, DailyRankingRepositoryImpl.java
์ผ์ผ ์ƒํ’ˆ ๋žญํ‚น ์œ„์น˜์™€ ์ ์ˆ˜๋ฅผ DB์— ์ €์žฅ (ranking_date, product_id ๋ณตํ•ฉ ์ธ๋ฑ์Šค)
์ƒํ’ˆ ์บ์‹œ & ์กฐํšŒ ์„œ๋น„์Šค
ProductCacheService.java, ProductQueryService.java, ProductFacade.java
RankingService ์˜์กด์„ฑ ์ถ”๊ฐ€; getProductList ๋ฐ˜ํ™˜ ํƒ€์ž…์„ Page<ProductWithLikeCount>์—์„œ Page<ProductListItem>์œผ๋กœ ๋ณ€๊ฒฝ; getProductListByProductIds(userId, productIds) ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€; ๊ฐ ์ƒํ’ˆ์— rank ์ •๋ณด ํฌํ•จ
์ƒํ’ˆ DTO ๋ณ€๊ฒฝ
ProductListItem.java, ProductDetailInfo.java
์ƒˆ๋กœ์šด ProductListItem ๋ ˆ์ฝ”๋“œ ์ถ”๊ฐ€ (id, name, price, likeCount, rank); ProductDetailInfo์— rank ํ•„๋“œ ์ถ”๊ฐ€
ProductWithLikeCount ์ œ๊ฑฐ
ProductWithLikeCount.java
๊ณต๊ฐœ ํƒ€์ž… ๋ฐ factory ๋ฉ”์„œ๋“œ ์‚ญ์ œ
์ƒํ’ˆ API ์ธํ„ฐํŽ˜์ด์Šค & ์ปจํŠธ๋กค๋Ÿฌ
ProductV1ApiSpec.java, ProductV1Controller.java, ProductV1Dto.java
getProducts ๋ฐ˜ํ™˜ ํƒ€์ž…์„ ProductListsResponse๋กœ ๋ณ€๊ฒฝ; ProductListResponse์— rank ํ•„๋“œ ์ถ”๊ฐ€; ProductDetailResponse์— rank ํ•„๋“œ ์ถ”๊ฐ€; ProductWithLikeCount import๋ฅผ ProductListItem์œผ๋กœ ๋ณ€๊ฒฝ
์ƒˆ๋กœ์šด ๋žญํ‚น API
RankingV1ApiSpec.java, RankingV1Controller.java
GET /api/v1/rankings ์—”๋“œํฌ์ธํŠธ ์ถ”๊ฐ€ (date, size, page ํŒŒ๋ผ๋ฏธํ„ฐ ์ง€์›)
๋žญํ‚น ์กฐํšŒ ํŒŒ์‚ฌ๋“œ
RankingFacade.java
Redis ZSET์—์„œ ๋‚ ์งœ๋ณ„ ์ƒํ’ˆ ๋žญํ‚น ์กฐํšŒ ๋ฐ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ฒ˜๋ฆฌ
๋ฉ”ํŠธ๋ฆญ ๋„๋ฉ”์ธ ๋ชจ๋ธ
ProductMetrics.java
๊ณ ์œ  ์ œ์•ฝ ์กฐ๊ฑด ์ œ๊ฑฐ (productId); ๋ฒ„ํ‚ท ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ์„ค๊ณ„ (bucketTimeKey, viewCount, likeCount, salesRevenue); ์ƒˆ๋กœ์šด ์ƒ์„ฑ์ž ProductMetrics(Long productId, String bucketTime)
๋ฉ”ํŠธ๋ฆญ ์ €์žฅ์†Œ
ProductMetricsRepository.java, ProductMetricsJpaRepository.java, ProductMetricsRepositoryImpl.java
findByProductIdAndBucketTimeKey(Long, String) ์ถ”๊ฐ€; ์ฆ๋ถ„ ๋ฉ”์„œ๋“œ๋ฅผ ๋ธํƒ€ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ณ€๊ฒฝ (incrementViewCountByDelta, incrementLikeCountByDelta, incrementSalesRevenueByDelta)
๋ฉ”ํŠธ๋ฆญ ์„œ๋น„์Šค
ProductMetricsService.java
๋ฐฐ์น˜ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ (batchUpdateViewCounts, batchUpdateLikeCounts, batchUpdateSalesRevenues); ๋ฒ„ํ‚ท ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ์ƒ์„ฑ/์—…๋ฐ์ดํŠธ ๋กœ์ง
๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ์Šค์ผ€์ค„๋Ÿฌ
MetricsBatchProcessor.java
1๋ถ„๋งˆ๋‹ค Redis ๋ฉ”ํŠธ๋ฆญ์„ DB๋กœ ์ง‘๊ณ„ (์กฐํšŒ, ์ข‹์•„์š”, ํŒ๋งค์•ก); ํŒจํ„ด ๊ธฐ๋ฐ˜ ํ‚ค ์Šค์บ” ๋ฐ ์›์ž์  ์‚ญ์ œ
์ผ์ผ ๋žญํ‚น ์Šค์ผ€์ค„๋Ÿฌ
ScoreCarryOverScheduler.java
์ผ์ผ 23:50์— ์˜ค๋Š˜ ๋žญํ‚น์„ DB์— ์ €์žฅํ•˜๊ณ  ๋‚ด์ผ์— 20% ์ ์ˆ˜ ์ด์›”
์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ์ž
LikeEventProcessor.java, ViewEventProcessor.java, OrderEventProcessor.java
ProductMetricsService โ†’ ProductRankingService๋กœ ๊ต์ฒด; Redis ๋ฒ„ํ‚ท ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ์นด์šดํŒ…; eventTime ์ธ์‹ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ; ์ค‘๋ณต ์ œ๊ฑฐ ๋กœ์ง ๊ฐœ์„ 
์ด๋ฒคํŠธ ํ•ธ๋“ค๋ง
EventHandled.java, EventHandledService.java, EventHandledRepository.java, EventHandledRepositoryImpl.java
eventTime ํ•„๋“œ ์ถ”๊ฐ€ (ZonedDateTime); eventId ์—ด์— unique ์ œ์•ฝ ์ถ”๊ฐ€; saveEvents(Collection<ConsumerRecord>) ๋ฐฐ์น˜ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
Kafka ์ปจ์Šˆ๋จธ
CatalogEventsConsumer.java, OrderEventsConsumer.java
eventId๋ณ„ ์ค‘๋ณต ์ œ๊ฑฐ ๋‹จ๊ณ„ ์ถ”๊ฐ€; ๋ฐฐ์น˜ ์ €์žฅ ์ „ ์ตœ์‹  ๋ ˆ์ฝ”๋“œ๋งŒ ์œ ์ง€; extractEventIdFromHeaders ํ—ฌํผ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€
ํ…Œ์ŠคํŠธ ์—…๋ฐ์ดํŠธ
ProductFacadeIntegrationTest.java, ProductListIntegrationTest.java
Page<ProductWithLikeCount> โ†’ Page<ProductListItem> ํƒ€์ž… ์—…๋ฐ์ดํŠธ

์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ

sequenceDiagram
    actor Client
    participant RankingV1Controller
    participant RankingFacade
    participant RedisTemplate
    participant ProductQueryService
    participant RankingService

    Client->>RankingV1Controller: GET /api/v1/rankings?date=20251210&size=20&page=1
    RankingV1Controller->>RankingFacade: getProductRankings(userId, date, size, page)
    
    RankingFacade->>RedisTemplate: zrevrange(ranking:all:20251210, offset, limit)
    RedisTemplate-->>RankingFacade: List<productId>
    
    alt ์ƒํ’ˆ ID ์กด์žฌ
        RankingFacade->>ProductQueryService: getProductListByProductIds(userId, productIds)
        ProductQueryService->>RankingService: getProductRank(productId)
        RankingService-->>ProductQueryService: rank
        ProductQueryService-->>RankingFacade: List<ProductListItem>
        
        RankingFacade->>RankingService: getTotalRankingCount(date)
        RankingService-->>RankingFacade: totalCount
        
        RankingFacade-->>RankingV1Controller: Page<ProductListItem>
    else ์ƒํ’ˆ ID ์—†์Œ
        RankingFacade-->>RankingV1Controller: ๋นˆ Page
    end
    
    RankingV1Controller-->>Client: ApiResponse<ProductListsResponse>
Loading
sequenceDiagram
    participant ViewEventProcessor
    participant EventHandledService
    participant RedisTemplate
    participant ProductRankingService
    participant MetricsBatchProcessor
    participant ProductMetricsService
    participant ScoreCarryOverScheduler

    ViewEventProcessor->>EventHandledService: saveEvents(records)
    EventHandledService->>RedisTemplate: ์ด๋ฒคํŠธ ์ €์žฅ
    
    ViewEventProcessor->>RedisTemplate: incrementViewCountByBucketTime(productId, bucketTimeKey)
    ViewEventProcessor->>ProductRankingService: addViewScore(productId)
    ProductRankingService->>RedisTemplate: zset ์ ์ˆ˜ ์ฆ๊ฐ€ (ranking:all:20251210)

    Note over ViewEventProcessor,ProductRankingService: 1๋ถ„๋งˆ๋‹ค ์‹คํ–‰
    
    MetricsBatchProcessor->>RedisTemplate: ํŒจํ„ด product_views:* ์Šค์บ”
    MetricsBatchProcessor->>ProductMetricsService: batchUpdateViewCounts(Map)
    ProductMetricsService->>RedisTemplate: ๋ฉ”ํŠธ๋ฆญ ์กฐํšŒ ๋ฐ ์ง‘๊ณ„

    Note over ScoreCarryOverScheduler: ๋งค์ผ 23:50 ์‹คํ–‰
    ScoreCarryOverScheduler->>RedisTemplate: zset ์ „์ฒด ์ฝ๊ธฐ (ranking:all:20251210)
    ScoreCarryOverScheduler->>RedisTemplate: DB์— DailyRanking ์ €์žฅ
    ScoreCarryOverScheduler->>RedisTemplate: 20% ์ ์ˆ˜๋ฅผ ๋‚ด์ผ zset์— ์ด์›”
Loading

์ฝ”๋“œ ๋ฆฌ๋ทฐ ์˜ˆ์ƒ ๋‚œ์ด๋„

๐ŸŽฏ 4 (๋ณต์žกํ•จ) | โฑ๏ธ ~45๋ถ„

๊ด€๋ จ PR

  • [volume - 8] Decoupling with Kafkaย #195: KafkaEventPublisher์™€ OrderCreatedEvent๋ฅผ ๋™์ผํ•˜๊ฒŒ ์ˆ˜์ •ํ•˜๋ฉฐ Kafka ์•„์›ƒ๋ฐ•์Šค ์ด๋ฒคํŠธ ๋ฐœํ–‰ ํ๋ฆ„์„ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.
  • [volume - 8] Decoupling with Kafka ย #193: Kafka ์•„์›ƒ๋ฐ•์Šค/์ธ๋ฐ•์Šค ํŒŒ์ดํ”„๋ผ์ธ ๋ฐ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์„œ๋น„์Šค๋ฅผ ๋ณ€๊ฒฝํ•˜์—ฌ ProductMetricsService ๊ต์ฒด ๋ฐ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๊ฐœ์„ ์„ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.
  • [volume - 8] Decoupling with Kafka ย #205: ์ด๋ฒคํŠธ/์•„์›ƒ๋ฐ•์Šค/๋ฉ”ํŠธ๋ฆญ Kafka ํ†ตํ•ฉ์„ ์ˆ˜์ •ํ•˜๊ณ  eventTime ์ฒ˜๋ฆฌ ๋ฐ ์ƒํ’ˆ ๋žญํ‚น/๋ฉ”ํŠธ๋ฆญ ์•„ํ‹ฐํŒฉํŠธ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.

์ œ์•ˆ ๋ ˆ์ด๋ธ”

enhancement

์‹œ

๐Ÿฐ Redis ์•ˆ์— ์ˆœ์œ„์˜ ๊ฟˆ์„ ์‹ฌ๊ณ 
๋ฒ„ํ‚ท์— ๋‹ด์•„ ์ง€ํ‘œ๋ฅผ ๋‚ ๋ฆฌ๋„ค
์ข‹์•„์š”์™€ ์กฐํšŒ๊ฐ€ ์ถค์„ ์ถ”๋ฉฐ
์ƒํ’ˆ๋“ค์˜ ๋ณ„์ด ํ•˜๋‚˜ ๋‘˜ ์˜ฌ๋ผ๊ฐ„๋‹ค๋„ค
๋žญํ‚น์˜ ๋งˆ๋ฒ•, ์˜ค๋Š˜๋„ ๊ณ„์†๋˜๊ณ ...

Pre-merge checks and finishing touches

โŒ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage โš ๏ธ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
โœ… Passed checks (2 passed)
Check name Status Explanation
Title check โœ… Passed PR ์ œ๋ชฉ์ด ์ฃผ์š” ๋ณ€๊ฒฝ์‚ฌํ•ญ์ธ ์ƒํ’ˆ ๋žญํ‚น ์‹œ์Šคํ…œ ๊ตฌํ˜„์„ ๋ช…ํ™•ํ•˜๊ฒŒ ์„ค๋ช…ํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ์ „์ฒด ๋ณ€๊ฒฝ์‚ฌํ•ญ๊ณผ ๊ด€๋ จ์„ฑ์ด ์žˆ์Šต๋‹ˆ๋‹ค.
Description check โœ… Passed PR description follows the template with complete Summary, Review Points (4 detailed sections), Checklist (2 main sections), and References sections.

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.

@sieun0322 sieun0322 changed the title [Volumn9-] [volume-9] Product Ranking with Redis Dec 26, 2025
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: 11

Caution

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

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

51-56: ์บ์‹œ ๋ฏธ์Šค๋œ ID๋งŒ์œผ๋กœ ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” ๋ฒ„๊ทธ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

missIds(์บ์‹œ ๋ฏธ์Šค๋œ ID๋“ค)๋งŒ์œผ๋กœ ProductListItem ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ตฌ์„ฑํ•˜๊ณ  ์žˆ์–ด, ์บ์‹œ์— ์กด์žฌํ•˜๋Š” ์ƒํ’ˆ๋“ค์ด ๊ฒฐ๊ณผ์—์„œ ๋ˆ„๋ฝ๋ฉ๋‹ˆ๋‹ค. ์ „์ฒด productIds๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ฒฐ๊ณผ๋ฅผ ๊ตฌ์„ฑํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
     // 2) ์ƒ์„ธ ์บ์‹œ ํ™•์ธ
     List<Long> missIds = productIds.stream()
         .filter(id -> cacheRepository.get(id) == null)
         .toList();
-    List<ProductListItem> list = getProductListByProductIds(userId, missIds);
+    // ์บ์‹œ ๋ฏธ์Šค๋œ ํ•ญ๋ชฉ๋“ค์„ ๋จผ์ € ๋กœ๋“œํ•˜์—ฌ ์บ์‹œ์— ์ €์žฅ
+    if (!missIds.isEmpty()) {
+      getProductListByProductIds(userId, missIds);
+    }
+    // ์ „์ฒด productIds๋กœ ๊ฒฐ๊ณผ ๊ตฌ์„ฑ
+    List<ProductListItem> list = getProductListByProductIds(userId, productIds);
     return new PageImpl<>(list, PageRequest.of(page, size), productIds.size());
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (1)

146-158: eventId ์ค‘๋ณต ์ œ๊ฑฐ ์‹œ eventTime ๋Œ€์‹  createdAt์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

OrderEventProcessor๋Š” createdAt์œผ๋กœ eventId ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜์ง€๋งŒ, ์—ฌ๊ธฐ์„œ๋Š” eventTime์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. eventId๊ฐ€ ๊ฐ™์€ ์ด๋ฒคํŠธ๋Š” ๋„คํŠธ์›Œํฌ ์žฌ์ „์†ก์œผ๋กœ ์ธํ•œ ์ค‘๋ณต์ด๋ฏ€๋กœ, DB ์ €์žฅ ์‹œ์ (createdAt)์ด ๋” ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
     Map<String, EventHandled> latestByEventId = events.stream()
         .collect(Collectors.groupingBy(
             EventHandled::getEventId,
-            Collectors.maxBy((e1, e2) -> e1.getEventTime().compareTo(e2.getEventTime()))
+            Collectors.maxBy((e1, e2) -> e1.getCreatedAt().compareTo(e2.getCreatedAt()))
         ))
๐Ÿงน Nitpick comments (30)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRanking.java (1)

18-22: (ranking_date, product_id)์— ์œ ๋‹ˆํฌ ์ œ์•ฝ ์กฐ๊ฑด ์ถ”๊ฐ€ ๊ถŒ์žฅ

๊ฐ™์€ ๋‚ ์งœ์— ๋™์ผ ์ƒํ’ˆ์ด ์ค‘๋ณต ์‚ฝ์ž…๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ์œ ๋‹ˆํฌ ์ œ์•ฝ ์กฐ๊ฑด์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์Šค์ผ€์ค„๋Ÿฌ๊ฐ€ ์žฌ์‹œ๋„๋˜๊ฑฐ๋‚˜ ์ค‘๋ณต ์‹คํ–‰๋  ๊ฒฝ์šฐ ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์œ ๋‹ˆํฌ ์ œ์•ฝ ์กฐ๊ฑด ์ถ”๊ฐ€ ์ œ์•ˆ
 @Entity
 @Table(name = "daily_ranking", indexes = {
     @Index(name = "idx_daily_ranking_date_rank", columnList = "ranking_date, ranking_position"),
     @Index(name = "idx_daily_ranking_date_product", columnList = "ranking_date, product_id")
-})
+}, uniqueConstraints = {
+    @UniqueConstraint(name = "uk_daily_ranking_date_product", columnNames = {"ranking_date", "product_id"})
+})
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java (3)

7-7: ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” import ์ œ๊ฑฐ

RedisCallback, HashMap, Map์ด import๋˜์–ด ์žˆ์ง€๋งŒ ์ฝ”๋“œ์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” import ์ œ๊ฑฐ
-import org.springframework.data.redis.core.RedisCallback;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;

Also applies to: 15-17


88-114: Redis N+1 ํ˜ธ์ถœ ๊ฐœ์„  ๋ฐ ํŒŒ์ดํ”„๋ผ์ด๋‹ ๊ณ ๋ ค

saveDailyRankingToDB์™€ ๋™์ผํ•˜๊ฒŒ N+1 ํ˜ธ์ถœ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. rangeWithScores๋ฅผ ์‚ฌ์šฉํ•˜๊ณ , ์—ฌ๋Ÿฌ ๊ฐœ์˜ incrementScore ํ˜ธ์ถœ์€ Redis ํŒŒ์ดํ”„๋ผ์ด๋‹์œผ๋กœ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌํ•˜๋ฉด ์„ฑ๋Šฅ์„ ํฌ๊ฒŒ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž rangeWithScores ๋ฐ ํŒŒ์ดํ”„๋ผ์ด๋‹ ์ ์šฉ
 private void carryOverToTomorrow(String todayKey, String tomorrowKey) {
-    Set<String> productIds = redisTemplate.opsForZSet().range(todayKey, 0, -1);
+    Set<org.springframework.data.redis.core.ZSetOperations.TypedTuple<String>> todayScores =
+        redisTemplate.opsForZSet().rangeWithScores(todayKey, 0, -1);
 
-    if (productIds == null || productIds.isEmpty()) {
+    if (todayScores == null || todayScores.isEmpty()) {
       log.info("No data to carry over to tomorrow");
       return;
     }
 
-    int carriedOverCount = 0;
-    double totalCarriedScore = 0;
-
-    for (String productId : productIds) {
-      Double todayScore = redisTemplate.opsForZSet().score(todayKey, productId);
-
-      if (todayScore != null && todayScore > 0) {
-        // ์˜ค๋Š˜ ์ ์ˆ˜์˜ 20%๋งŒ ๋‚ด์ผ๋กœ carry over
-        double carriedScore = todayScore * CARRY_OVER_RATIO;
-        redisTemplate.opsForZSet().incrementScore(tomorrowKey, productId, carriedScore);
-
-        carriedOverCount++;
-        totalCarriedScore += carriedScore;
+    final int[] carriedOverCount = {0};
+    final double[] totalCarriedScore = {0};
+
+    redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
+      for (var tuple : todayScores) {
+        Double todayScore = tuple.getScore();
+        if (todayScore != null && todayScore > 0) {
+          double carriedScore = todayScore * CARRY_OVER_RATIO;
+          connection.zSetCommands().zIncrBy(
+              tomorrowKey.getBytes(), carriedScore, tuple.getValue().getBytes());
+          carriedOverCount[0]++;
+          totalCarriedScore[0] += carriedScore;
+        }
       }
-    }
+      return null;
+    });
 
-    log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)",
-        carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100);
+    log.info("Carried over {} products to tomorrow. Total score: {} ({}% of today)",
+        carriedOverCount[0], String.format("%.2f", totalCarriedScore[0]), (int)(CARRY_OVER_RATIO * 100));
   }

38-39: LocalDate.now() ๋‹จ์ผ ํ˜ธ์ถœ๋กœ ๋ณ€๊ฒฝ ๊ถŒ์žฅ

LocalDate.now()๊ฐ€ ๋‘ ๋ฒˆ ํ˜ธ์ถœ๋˜์–ด ์ด๋ก ์ ์œผ๋กœ ์ž์ •์„ ๋„˜์–ด๊ฐ€๋Š” ๊ฒฝ์šฐ ๋‹ค๋ฅธ ๋‚ ์งœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. 23:50์— ์‹คํ–‰๋˜๋ฏ€๋กœ ๊ฐ€๋Šฅ์„ฑ์€ ๋‚ฎ์ง€๋งŒ, ๋‹จ์ผ ํ˜ธ์ถœ๋กœ ๋ณ€๊ฒฝํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋‹จ์ผ now() ํ˜ธ์ถœ๋กœ ๋ณ€๊ฒฝ
-      LocalDate today = LocalDate.now();
-      LocalDate tomorrow = LocalDate.now().plusDays(1);
+      LocalDate today = LocalDate.now();
+      LocalDate tomorrow = today.plusDays(1);
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)

28-29: bucketTimeKey ํ•„๋“œ์— ๋Œ€ํ•œ ๊ฒ€์ฆ ์ถ”๊ฐ€ ๊ถŒ์žฅ

bucketTimeKey๊ฐ€ nullable = false๋กœ ์„ค์ •๋˜์–ด ์žˆ์ง€๋งŒ, ์ƒ์„ฑ์ž์—์„œ null ์ฒดํฌ๋‚˜ ํ˜•์‹ ๊ฒ€์ฆ์ด ์—†์Šต๋‹ˆ๋‹ค. PR ๋ชฉํ‘œ์— ๋”ฐ๋ฅด๋ฉด yyyyMMddHHmm ํ˜•์‹์˜ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋Š”๋ฐ, ์ž˜๋ชป๋œ ํ˜•์‹์˜ ํ‚ค๊ฐ€ ์ „๋‹ฌ๋˜๋ฉด ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆํ•˜๋Š” ๊ฒ€์ฆ ๋กœ์ง
 public ProductMetrics(Long productId, String bucketTime) {
+  if (productId == null || bucketTime == null || bucketTime.isBlank()) {
+    throw new IllegalArgumentException("productId and bucketTime must not be null or empty");
+  }
+  if (!bucketTime.matches("\\d{12}")) {
+    throw new IllegalArgumentException("bucketTime must be in yyyyMMddHHmm format");
+  }
   this.productId = productId;
   this.bucketTimeKey = bucketTime;
 }
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductListIntegrationTest.java (1)

27-38: ๋žญํ‚น ๊ด€๋ จ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ ๊ณ ๋ ค

PR์—์„œ ์ œํ’ˆ ๋ชฉ๋ก์— ๋žญํ‚น ์ •๋ณด๊ฐ€ ํฌํ•จ๋œ๋‹ค๊ณ  ํ–ˆ๋Š”๋ฐ, ํ˜„์žฌ ํ…Œ์ŠคํŠธ๋Š” ํŽ˜์ด์ง€ ์‚ฌ์ด์ฆˆ๋งŒ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. ProductListItem์— ํฌํ•จ๋œ rank ํ•„๋“œ์— ๋Œ€ํ•œ ๊ฒ€์ฆ ๋กœ์ง์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๋žญํ‚น ๊ธฐ๋Šฅ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ํ†ตํ•ฉ๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
   @Test
   void testQueryPerformance() {
     long start = System.currentTimeMillis();
     Page<ProductListItem> page =
         productCacheService.getProductList(null, null, "likes_desc", 0, 20);
     long end = System.currentTimeMillis();
 
     System.out.println("Elapsed: " + (end - start) + " ms");
     System.out.println("Fetched: " + page.getContent().size());
 
     assertThat(page.getContent().size()).isEqualTo(20);
+    
+    // ๋žญํ‚น ์ •๋ณด ๊ฒ€์ฆ (์ธ๊ธฐ์ˆœ ์ •๋ ฌ ์‹œ)
+    List<ProductListItem> items = page.getContent();
+    if (!items.isEmpty() && items.get(0).rank() != null) {
+      // ๋žญํ‚น์ด ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ, ์ˆœ์œ„๊ฐ€ ์ œ๋Œ€๋กœ ํ• ๋‹น๋˜์—ˆ๋Š”์ง€ ํ™•์ธ
+      assertThat(items.stream().filter(item -> item.rank() != null).count())
+          .isGreaterThan(0);
+    }
   }
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java (1)

5-5: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

RankInfo๊ฐ€ import๋˜์–ด ์žˆ์ง€๋งŒ ์ด ํŒŒ์ผ์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-import com.loopers.application.ranking.RankInfo;
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (2)

5-5: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

ProductV1Dto๊ฐ€ import๋˜์–ด ์žˆ์ง€๋งŒ ์ด ํด๋ž˜์Šค์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-import com.loopers.interfaces.api.product.ProductV1Dto;

14-14: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

ArrayList๊ฐ€ import๋˜์–ด ์žˆ์ง€๋งŒ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-import java.util.ArrayList;
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java (1)

91-100: ํ—ค๋” ๊ฐ’ ๋ณ€ํ™˜ ์‹œ ๋ฌธ์ž์…‹์„ ๋ช…์‹œํ•˜์„ธ์š”.

new String(eventIdHeader.value())์—์„œ ์‹œ์Šคํ…œ ๊ธฐ๋ณธ ๋ฌธ์ž์…‹์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด StandardCharsets.UTF_8์„ ๋ช…์‹œ์ ์œผ๋กœ ์ง€์ •ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
+import java.nio.charset.StandardCharsets;
       if (eventIdHeader != null && eventIdHeader.value() != null) {
-        return new String(eventIdHeader.value());
+        return new String(eventIdHeader.value(), StandardCharsets.UTF_8);
       }
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (3)

32-41: ๋ฐ˜๋ณต๋ฌธ ๋Œ€์‹  delta ๊ฐ’์„ ์ง์ ‘ ์„ค์ •ํ•˜์„ธ์š”.

deltaCount๋งŒํผ incrementViewCount()๋ฅผ ๋ฐ˜๋ณต ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์€ ๋น„ํšจ์œจ์ ์ž…๋‹ˆ๋‹ค. ProductMetrics์— delta ๊ฐ’์„ ์ง์ ‘ ์„ค์ •ํ•˜๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜, ์ดˆ๊ธฐ ์ƒ์„ฑ ์‹œ delta ๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด deltaCount๊ฐ€ 1000์ด๋ฉด 1000๋ฒˆ์˜ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ

ProductMetrics์— delta ๊ฐ’์„ ์ง์ ‘ ์„ค์ •ํ•˜๋Š” ์ƒ์„ฑ์ž๋‚˜ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”:

         if (updatedRows == 0) {
           // ๋ฉ”ํŠธ๋ฆญ์Šค๊ฐ€ ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ
-          ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey);
-          for (int i = 0; i < deltaCount; i++) {
-            metrics.incrementViewCount();
-          }
+          ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey, deltaCount, 0L, 0L);
           productMetricsRepository.save(metrics);

66-78: ์ข‹์•„์š” ์ˆ˜ ์—…๋ฐ์ดํŠธ๋„ ๋ฐ˜๋ณต๋ฌธ ๋Œ€์‹  delta ๊ฐ’์„ ์ง์ ‘ ์„ค์ •ํ•˜์„ธ์š”.

์กฐํšŒ์ˆ˜์™€ ๋™์ผํ•œ ๋ฌธ์ œ์ž…๋‹ˆ๋‹ค. deltaCount๋งŒํผ ๋ฐ˜๋ณต ํ˜ธ์ถœํ•˜๋Š” ๋Œ€์‹  delta ๊ฐ’์„ ์ง์ ‘ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ด ํšจ์œจ์ ์ž…๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
         if (updatedRows == 0) {
           // ๋ฉ”ํŠธ๋ฆญ์Šค๊ฐ€ ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ
-          ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey);
-          if (deltaCount > 0) {
-            for (int i = 0; i < deltaCount; i++) {
-              metrics.incrementLikeCount();
-            }
-          } else {
-            // ์Œ์ˆ˜์ธ ๊ฒฝ์šฐ (์ข‹์•„์š” ์ทจ์†Œ๊ฐ€ ๋” ๋งŽ์€ ๊ฒฝ์šฐ)
-            for (int i = 0; i < Math.abs(deltaCount); i++) {
-              metrics.decrementLikeCount();
-            }
-          }
+          ProductMetrics metrics = new ProductMetrics(productId, bucketTimeKey, 0L, deltaCount, 0L);
           productMetricsRepository.save(metrics);

126-159: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” private ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

getBucketTime()๊ณผ parseBucketTime() ๋ฉ”์„œ๋“œ๊ฐ€ ์ด ํด๋ž˜์Šค ๋‚ด์—์„œ ํ˜ธ์ถœ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ํ”„๋กœ์„ธ์„œ๋“ค(ViewEventProcessor, OrderEventProcessor ๋“ฑ)์ด ์ž์ฒด์ ์œผ๋กœ bucket time ๋กœ์ง์„ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

์ด ๋ฉ”์„œ๋“œ๋“ค์ด ํ–ฅํ›„ ์‚ฌ์šฉ ์˜ˆ์ •์ด ์•„๋‹ˆ๋ผ๋ฉด ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

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

90-99: ๋ฐ”์ดํŠธ ๋ฐฐ์—ด์—์„œ ๋ฌธ์ž์—ด ๋ณ€ํ™˜ ์‹œ charset ๋ช…์‹œ๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

new String(eventIdHeader.value())๋Š” ์‹œ์Šคํ…œ ๊ธฐ๋ณธ charset์„ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ํ™˜๊ฒฝ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ฒฐ๊ณผ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
-        return new String(eventIdHeader.value());
+        return new String(eventIdHeader.value(), java.nio.charset.StandardCharsets.UTF_8);
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)

17-18: ๋žญํ‚น ํ‚ค ์ƒ์„ฑ ๋กœ์ง์˜ ์ƒ์ˆ˜ํ™”๋ฅผ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”.

๋‚ ์งœ ํฌ๋งท ํŒจํ„ด "yyyyMMdd"์™€ ํ‚ค ํ”„๋ฆฌํ”ฝ์Šค "ranking:all:"์ด ์—ฌ๋Ÿฌ ๊ณณ์—์„œ ๋ฐ˜๋ณต๋ฉ๋‹ˆ๋‹ค. ์ƒ์ˆ˜๋กœ ์ถ”์ถœํ•˜๋ฉด ์œ ์ง€๋ณด์ˆ˜์„ฑ์ด ํ–ฅ์ƒ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
+  private static final String RANKING_KEY_PREFIX = "ranking:all:";
+  private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+
   public Integer getProductRank(Long productId) {
-    String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
-    String rankingKey = "ranking:all:" + date;
+    String date = LocalDate.now().format(DATE_FORMATTER);
+    String rankingKey = RANKING_KEY_PREFIX + date;

Also applies to: 33-34, 48-49, 59-59

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

67-79: ์ƒํ’ˆ๋ณ„ ๊ฐœ๋ณ„ ๋žญํ‚น ์กฐํšŒ๋กœ ์ธํ•œ N+1 Redis ํ˜ธ์ถœ ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด rankingService.getProductRank(id)๋ฅผ ๊ฐœ๋ณ„ ํ˜ธ์ถœํ•˜๋ฉด ์ƒํ’ˆ ์ˆ˜๋งŒํผ Redis ํ˜ธ์ถœ์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ๋Œ€๋Ÿ‰์˜ ์ƒํ’ˆ ์กฐํšŒ ์‹œ ์„ฑ๋Šฅ ์ €ํ•˜๊ฐ€ ์šฐ๋ ค๋ฉ๋‹ˆ๋‹ค.

๋ฐฐ์น˜๋กœ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์˜ ๋žญํ‚น์„ ํ•œ ๋ฒˆ์— ์กฐํšŒํ•˜๋Š” ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€๋ฅผ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”. Redis์˜ ZMSCORE ๋˜๋Š” ํŒŒ์ดํ”„๋ผ์ธ์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (1)

199-227: ๋ฒ„ํ‚ท ์‹œ๊ฐ„ ๊ณ„์‚ฐ ์œ ํ‹ธ๋ฆฌํ‹ฐ๊ฐ€ ์—ฌ๋Ÿฌ ํ”„๋กœ์„ธ์„œ์— ์ค‘๋ณต๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

getBucketTime(), getBucketTimeKey() ๋ฉ”์„œ๋“œ๊ฐ€ ViewEventProcessor, LikeEventProcessor์—๋„ ๋™์ผํ•˜๊ฒŒ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ๊ณตํ†ต ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค๋กœ ์ถ”์ถœํ•˜๋ฉด ์ฝ”๋“œ ์ค‘๋ณต์„ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (1)

169-175: ๋น„๊ต ์‹œ๋งˆ๋‹ค ZonedDateTime ๊ฐ์ฒด๊ฐ€ ์ƒ์„ฑ๋˜์–ด ๋น„ํšจ์œจ์ ์ž…๋‹ˆ๋‹ค.

ZonedDateTime.parse("1970-01-01T00:00:00Z")๊ฐ€ ๋งค ๋น„๊ต๋งˆ๋‹ค ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค. ์ƒ์ˆ˜๋กœ ์ถ”์ถœํ•˜๋ฉด ์„ฑ๋Šฅ์ด ๊ฐœ์„ ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
+  private static final ZonedDateTime EPOCH = ZonedDateTime.parse("1970-01-01T00:00:00Z");
+
   // ...
   
       EventHandled finalEvent = businessKeyEvents.stream()
           .max((e1, e2) -> {
-            ZonedDateTime eventTime1 = e1.getEventTime() != null ? e1.getEventTime() : ZonedDateTime.parse("1970-01-01T00:00:00Z");
-            ZonedDateTime eventTime2 = e2.getEventTime() != null ? e2.getEventTime() : ZonedDateTime.parse("1970-01-01T00:00:00Z");
+            ZonedDateTime eventTime1 = e1.getEventTime() != null ? e1.getEventTime() : EPOCH;
+            ZonedDateTime eventTime2 = e2.getEventTime() != null ? e2.getEventTime() : EPOCH;
             return eventTime1.compareTo(eventTime2);
           })
apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java (1)

60-62: ํ‚ค ํŒŒ์‹ฑ ์‹œ ๋ฐฐ์—ด ์ธ๋ฑ์Šค ๊ฒ€์ฆ์ด ์—†์Šต๋‹ˆ๋‹ค.

key.split(":")์˜ ๊ฒฐ๊ณผ๋ฅผ ์ธ๋ฑ์Šค๋กœ ์ง์ ‘ ์ ‘๊ทผํ•˜๊ณ  ์žˆ์–ด, ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ํ‚ค ํ˜•์‹์ผ ๊ฒฝ์šฐ ArrayIndexOutOfBoundsException์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
             // product_views:{productId}:{bucketTime} ํŒŒ์‹ฑ
             String[] parts = key.split(":");
+            if (parts.length < 3) {
+              log.warn("Invalid key format, skipping: {}", key);
+              continue;
+            }
             Long productId = Long.valueOf(parts[1]);
             String bucketTime = parts[2];

Also applies to: 108-110, 156-158

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

36-36: ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” RedisTemplate ์˜์กด์„ฑ์„ ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜ ํ–ฅํ›„ ์‚ฌ์šฉ ๊ณ„ํš์„ ๋ช…์‹œํ•˜์„ธ์š”.

redisTemplate ํ•„๋“œ๊ฐ€ ์ฃผ์ž…๋˜์—ˆ์ง€๋งŒ ํ˜„์žฌ ํด๋ž˜์Šค์˜ ์–ด๋–ค ๋ฉ”์„œ๋“œ์—์„œ๋„ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ–ฅํ›„ ์‚ฌ์šฉ ๊ณ„ํš์ด ์žˆ๋‹ค๋ฉด TODO ์ฃผ์„์„ ์ถ”๊ฐ€ํ•˜๊ณ , ๋ถˆํ•„์š”ํ•˜๋‹ค๋ฉด ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

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

6-6: ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” JdbcTemplate import๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

JdbcTemplate์ด import๋˜์–ด ์žˆ์ง€๋งŒ ํด๋ž˜์Šค ๋‚ด์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-import org.springframework.jdbc.core.JdbcTemplate;
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)

58-107: ๋ฐฐ์น˜ ์ €์žฅ ๋กœ์ง์ด ์ž˜ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฐœ๋ณ„ ๋ ˆ์ฝ”๋“œ ์‹คํŒจ ์‹œ ํ•ด๋‹น ๋ ˆ์ฝ”๋“œ๋งŒ ์Šคํ‚ตํ•˜๊ณ  ๋‚˜๋จธ์ง€๋ฅผ ๊ณ„์† ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐฉ์‹์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋งŒ, saveAll ์‹คํŒจ ์‹œ ์–ด๋–ค ๋ ˆ์ฝ”๋“œ๋“ค์ด ์ค€๋น„ ์™„๋ฃŒ๋˜์—ˆ๋Š”์ง€์— ๋Œ€ํ•œ ์ •๋ณด๊ฐ€ ์†์‹ค๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฐฐ์น˜ ์ €์žฅ์ด ์‹คํŒจํ•  ๊ฒฝ์šฐ ๋””๋ฒ„๊น…์„ ์œ„ํ•ด ์ค€๋น„๋œ ์ด๋ฒคํŠธ ์ˆ˜๋ฅผ ๋กœ๊ทธ์— ํฌํ•จํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”:

๐Ÿ”Ž ๊ฐœ์„  ์ œ์•ˆ
    } catch (Exception e) {
-     log.error("Failed to batch save events to inbox", e);
+     log.error("Failed to batch save events to inbox: preparedCount={}", events.size(), e);
      throw new RuntimeException("Failed to batch save events to inbox", e);
    }
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)

20-38: ์ƒˆ๋กœ์šด DTO ๊ตฌ์กฐ๋กœ์˜ ๋ฆฌํŒฉํ† ๋ง์ด ์ž˜ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ProductListItem๊ณผ ProductListsResponse๋กœ์˜ ์ „ํ™˜์ด ๊น”๋”ํ•ฉ๋‹ˆ๋‹ค. getTotalElements()์˜ ๋ถˆํ•„์š”ํ•œ ์บ์ŠคํŒ… ์ œ๊ฑฐ๋„ ์ข‹์Šต๋‹ˆ๋‹ค.

brandId null ์ฒดํฌ ๋กœ์ง์„ ๋‹จ์ˆœํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

๐Ÿ”Ž ๊ฐ„์†Œํ™” ์ œ์•ˆ
-   Page<ProductListItem> productPage;
-   if (brandId != null) {
-     productPage = productFacade.getProductList(userId, brandId, sortType, page, size);
-   } else {
-     productPage = productFacade.getProductList(userId, null, sortType, page, size);
-   }
+   Page<ProductListItem> productPage = productFacade.getProductList(userId, brandId, sortType, page, size);

brandId๊ฐ€ null์ด๋“  ์•„๋‹ˆ๋“  ๋™์ผํ•œ ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๋ฏ€๋กœ ์กฐ๊ฑด๋ฌธ์ด ๋ถˆํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java (2)

119-129: TTL ์„ค์ • ๋กœ์ง์˜ ๊ฒฝ์Ÿ ์กฐ๊ฑด (๊ฒฝ๋ฏธํ•œ ์ด์Šˆ)

getExpire์™€ expire ํ˜ธ์ถœ ์‚ฌ์ด์— ๊ฒฝ์Ÿ ์กฐ๊ฑด์ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋™์‹œ ํ˜ธ์ถœ ์‹œ ๋‘˜ ๋‹ค TTL=-1์„ ํ™•์ธํ•˜๊ณ  TTL์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ๋Š” ๋™์ผํ•˜๋ฏ€๋กœ ๊ธฐ๋Šฅ์ƒ ๋ฌธ์ œ๋Š” ์—†์ง€๋งŒ, ๋ถˆํ•„์š”ํ•œ Redis ํ˜ธ์ถœ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์›์ž์  ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋ฉด Lua ์Šคํฌ๋ฆฝํŠธ๋ฅผ ๊ณ ๋ คํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ํ˜„์žฌ ๊ตฌํ˜„๋„ ์‹ค์šฉ์ ์œผ๋กœ ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.


109-112: ํƒ€์ž„์กด ์ผ๊ด€์„ฑ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

LocalDate.now()๋Š” ์„œ๋ฒ„์˜ ๊ธฐ๋ณธ ํƒ€์ž„์กด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ๋ถ„์‚ฐ ํ™˜๊ฒฝ์—์„œ ์„œ๋น„์Šค๋“ค์ด ๋‹ค๋ฅธ ํƒ€์ž„์กด์—์„œ ์‹คํ–‰๋  ๊ฒฝ์šฐ ๋žญํ‚น ํ‚ค๊ฐ€ ๋ถˆ์ผ์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ช…์‹œ์ ์ธ ํƒ€์ž„์กด ์ง€์ •์„ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”:

๐Ÿ”Ž ๊ฐœ์„  ์ œ์•ˆ
+ private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul");
+
  private String getTodayRankingKey() {
-   String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+   String today = LocalDate.now(KOREA_ZONE).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
    return String.format("ranking:all:%s", today);
  }
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsJpaRepository.java (1)

8-9: ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” import๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

LocalDateTime๊ณผ List๊ฐ€ import๋˜์–ด ์žˆ์ง€๋งŒ ์ธํ„ฐํŽ˜์ด์Šค ๋‚ด์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-import java.time.LocalDateTime;
-import java.util.List;
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (2)

5-5: ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” RankInfo import๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

RankInfo๊ฐ€ import๋˜์–ด ์žˆ์ง€๋งŒ ์ด ํŒŒ์ผ์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-import com.loopers.application.ranking.RankInfo;

89-90: ์ค‘๋ณต๋œ null ์ฒดํฌ๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

info.rank() != null ? info.rank() : null์€ info.rank()์™€ ๋™์ผํ•œ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
          info.likeInfo().likeCount(),
-         info.rank() != null ? info.rank() : null
+         info.rank()
      );
apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (2)

6-6: ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import๋ฅผ ์ œ๊ฑฐํ•˜์„ธ์š”.

RankInfo์™€ ArrayList๋Š” ์ด ํŒŒ์ผ์—์„œ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ฝ”๋“œ ๊ฐ€๋…์„ฑ์„ ์œ„ํ•ด ๋ถˆํ•„์š”ํ•œ import๋ฅผ ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •์‚ฌํ•ญ
-import com.loopers.application.ranking.RankInfo;
 import com.loopers.application.ranking.RankingService;
-import java.util.ArrayList;
 import java.util.List;

Also applies to: 12-12


32-35: Pass-through ๋ฉ”์„œ๋“œ ํŒจํ„ด์„ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”.

์ด ๋ฉ”์„œ๋“œ๋Š” productCacheService.getProductListByProductIds๋ฅผ ๋‹จ์ˆœํžˆ ํ˜ธ์ถœํ•˜๋Š” pass-through ์—ญํ• ๋งŒ ์ˆ˜ํ–‰ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

ํ˜„์žฌ ๊ตฌํ˜„์€ ์„œ๋น„์Šค ๋ ˆ์ด์–ด์˜ ์บก์Аํ™”๋ฅผ ์œ ์ง€ํ•˜๋Š” ํŒจํ„ด์œผ๋กœ ๋ฐ›์•„๋“ค์ผ ์ˆ˜ ์žˆ์ง€๋งŒ, ํ˜ธ์ถœ์ž(์˜ˆ: RankingFacade)๊ฐ€ ProductCacheService๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•˜๋„๋ก ๋ฆฌํŒฉํ† ๋งํ•˜๋Š” ๊ฒƒ๋„ ๊ณ ๋ คํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ ์ด๋Š” ์„ค๊ณ„ ์„ ํƒ์˜ ๋ฌธ์ œ์ด๋ฉฐ ํ˜„์žฌ ๊ตฌ์กฐ๋„ ์œ ํšจํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“œ Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

๐Ÿ“ฅ Commits

Reviewing files that changed from the base of the PR and between c0dfcd8 and a5c564f.

๐Ÿ“’ Files selected for processing (39)
  • apps/commerce-api/src/main/java/com/loopers/application/event/OrderCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductListItem.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithLikeCount.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductListIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRanking.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRankingRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/inbox/EventHandledRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java
๐Ÿ’ค Files with no reviewable changes (1)
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithLikeCount.java
๐Ÿงฐ Additional context used
๐Ÿง  Learnings (11)
๐Ÿ“š 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/test/java/com/loopers/domain/product/ProductListIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductListItem.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/productMetrics/ProductMetricsRepositoryImpl.java
๐Ÿ“š Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/inbox/EventHandledRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledRepository.java
๐Ÿ“š Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.

Applied to files:

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

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventsConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.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/ranking/DailyRankingJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRankingRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingRepositoryImpl.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-streamer/src/main/java/com/loopers/domain/event/EventHandledService.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-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java
๐Ÿ“š Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.

Applied to files:

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

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java
๐Ÿ“š Learning: 2025-12-21T15:12:57.845Z
Learnt from: hyujikoh
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 200
File: apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java:29-36
Timestamp: 2025-12-21T15:12:57.845Z
Learning: In MetricsLockCleanupScheduler in apps/commerce-streamer (Loopers-dev-lab/loopers-spring-java-template), the scheduled cleanup methods (cleanupUnusedLocks, cleanupProcessedEvents, monitorLockStatus) operate on JVM-local memory structures (ConcurrentHashMap for locks and idempotency cache in MetricsService). Each application instance has its own separate memory space, so duplicate execution across multiple instances is the intended behaviorโ€”each instance must clean up its own memory. Distributed locking is not needed for JVM-internal memory cleanup tasks.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java
๐Ÿงฌ Code graph analysis (10)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
  • ProductV1Dto (10-96)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (4)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (1)
  • Slf4j (23-300)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java (1)
  • Slf4j (14-182)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (1)
  • Slf4j (24-229)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)
  • Slf4j (23-173)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (2)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)
  • Slf4j (23-173)
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java (1)
  • Slf4j (12-130)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (4)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
  • ProductV1Dto (10-96)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (1)
  • RequiredArgsConstructor (16-48)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
  • RequiredArgsConstructor (18-51)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)
  • RequiredArgsConstructor (10-62)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java (3)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/OrderEventProcessor.java (1)
  • Slf4j (24-229)
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)
  • Slf4j (23-173)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java (1)
  • Slf4j (16-190)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
  • ProductV1Dto (10-96)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
  • ProductV1Dto (10-96)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (4)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java (1)
  • RequiredArgsConstructor (23-110)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
  • RequiredArgsConstructor (18-51)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)
  • RequiredArgsConstructor (10-62)
apps/commerce-api/src/main/java/com/loopers/domain/view/ProductListViewService.java (1)
  • RequiredArgsConstructor (16-86)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)
  • ProductV1Dto (10-96)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (1)
  • RequiredArgsConstructor (16-48)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (3)
apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java (1)
  • Entity (12-88)
apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java (1)
  • Entity (11-73)
apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
  • name (14-87)
๐Ÿ”‡ Additional comments (37)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/DailyRankingJpaRepository.java (1)

9-14: LGTM!

Spring Data JPA ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ •์˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค. findByRankingDateAndProductId๊ฐ€ List๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š”๋ฐ, ๋งŒ์•ฝ (ranking_date, product_id) ์กฐํ•ฉ์ด ์œ ์ผํ•ด์•ผ ํ•œ๋‹ค๋ฉด Optional<DailyRanking>์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฒƒ๋„ ๊ณ ๋ คํ•ด๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRankingRepository.java (1)

6-13: LGTM!

๋„๋ฉ”์ธ ๋ ˆ์ด์–ด์— ์ ์ ˆํ•œ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค์ž…๋‹ˆ๋‹ค. ์ธํ”„๋ผ์ŠคํŠธ๋Ÿญ์ฒ˜ ๋ ˆ์ด์–ด์™€ ์ž˜ ๋ถ„๋ฆฌ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/DailyRanking.java (1)

44-46: LGTM!

ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ํŒจํ„ด์ด ์ ์ ˆํ•˜๊ฒŒ ์‚ฌ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. id๋ฅผ null๋กœ ์ „๋‹ฌํ•˜์—ฌ ์ž๋™ ์ƒ์„ฑ๋˜๋„๋ก ์ฒ˜๋ฆฌํ•œ ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

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

11-31: LGTM!

JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ๋กœ์˜ ์œ„์ž„์ด ๊น”๋”ํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

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

12-12: ๋ฒ„ํ‚ท ๊ธฐ๋ฐ˜ ์กฐํšŒ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ ํ™•์ธ

์ƒˆ๋กœ์šด findByProductIdAndBucketTimeKey ๋ฉ”์„œ๋“œ๋Š” ๋ฒ„ํ‚ท ๋‹จ์œ„ ๋ฉ”ํŠธ๋ฆญ ์กฐํšŒ๋ฅผ ์ง€์›ํ•˜์—ฌ ๋ฉฑ๋“ฑ์„ฑ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค. ์ž˜ ์„ค๊ณ„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.


16-20: ๋ธํƒ€ ๊ธฐ๋ฐ˜ ์—…๋ฐ์ดํŠธ ๋ฉ”์„œ๋“œ์˜ ์›์ž์„ฑ์ด ์ ์ ˆํžˆ ๋ณด์žฅ๋จ

๊ตฌํ˜„์ฒด ๊ฒ€์ฆ ๊ฒฐ๊ณผ, ProductMetricsJpaRepository์—์„œ ์„ธ ๋ฉ”์„œ๋“œ ๋ชจ๋‘ @Query ์• ๋„ˆํ…Œ์ด์…˜์œผ๋กœ UPDATE ProductMetrics pm SET pm.viewCount = pm.viewCount + :delta ... ํ˜•ํƒœ์˜ JPQL์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์–ด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ ˆ๋ฒจ์—์„œ ์›์ž์  ์—…๋ฐ์ดํŠธ๊ฐ€ ๋ณด์žฅ๋ฉ๋‹ˆ๋‹ค.

ํ˜ธ์ถœํ•˜๋Š” ProductMetricsService์˜ ๋ฐฐ์น˜ ๋ฉ”์„œ๋“œ๋“ค(batchUpdateViewCounts, batchUpdateLikeCounts, batchUpdateSalesRevenues)์—์„œ๋„ ๋ฐ˜ํ™˜ ๊ฐ’์„ ์ ์ ˆํžˆ ๊ฒ€์ฆํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฐ ๋ฉ”์„œ๋“œ๋Š” int updatedRows๋ฅผ ์บก์ฒ˜ํ•œ ํ›„ 0์ด ๋ฐ˜ํ™˜๋  ๊ฒฝ์šฐ(ํ•ด๋‹น ๋ฒ„ํ‚ท์ด ์—†๋Š” ๊ฒฝ์šฐ) ์ƒˆ๋กœ์šด ProductMetrics ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํด๋ฐฑ ๋กœ์ง์„ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ๋ชจ๋“  ์—…๋ฐ์ดํŠธ ์ž‘์—…์— ์˜ˆ์™ธ ์ฒ˜๋ฆฌ์™€ ๋กœ๊น…๋„ ํ•จ๊ป˜ ์ ์šฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ถ”๊ฐ€์ ์ธ ๊ฐœ์„ ์ด ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

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

53-57: ๊ฐ์†Œ ๋กœ์ง์˜ ์Œ์ˆ˜ ๋ฐฉ์ง€๋Š” ์ ์ ˆํ•จ

decrementLikeCount()์—์„œ ์Œ์ˆ˜ ๋ฐฉ์ง€ ๋กœ์ง(if (this.likeCount > 0))์„ ์ถ”๊ฐ€ํ•œ ๊ฒƒ์€ ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ์ธก๋ฉด์—์„œ ์ข‹์€ ๋ฐฉ์–ด ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค.


45-61: ์‹ค์ œ ๊ตฌํ˜„์€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ ˆ๋ฒจ์˜ ์›์ž์  UPDATE ์—ฐ์‚ฐ์„ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ๋™์‹œ์„ฑ ๋ฌธ์ œ ์—†์Œ

๊ฒ€ํ†  ์ฝ”๋ฉ˜ํŠธ์˜ ์šฐ๋ ค์‚ฌํ•ญ์€ ์‹ค์ œ ๊ตฌํ˜„๊ณผ ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ProductMetricsService๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:

  1. ๊ธฐ์กด ๋ ˆ์ฝ”๋“œ์— ๋Œ€ํ•œ ์—…๋ฐ์ดํŠธ๋Š” @Query("UPDATE ProductMetrics pm SET pm.viewCount = pm.viewCount + :delta ...") ํ˜•ํƒœ์˜ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ ˆ๋ฒจ ์›์ž์  ์—ฐ์‚ฐ์œผ๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค
  2. ๋„๋ฉ”์ธ ๋ฉ”์„œ๋“œ(incrementViewCount(), incrementLikeCount() ๋“ฑ)๋Š” ์ƒˆ๋กœ์šด ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ์‹œ์—๋งŒ ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค (updatedRows == 0์ธ ๊ฒฝ์šฐ)
  3. Dirty checking์„ ํ†ตํ•œ ์—…๋ฐ์ดํŠธ๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ lost update ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค

์—ฌ๋Ÿฌ ๋ฐฐ์น˜ ํ”„๋กœ์„ธ์„œ ์ธ์Šคํ„ด์Šค๊ฐ€ ๋™์‹œ์— ์‹คํ–‰๋˜๋”๋ผ๋„ SQL UPDATE ์—ฐ์‚ฐ์€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์›์ž์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ, unique constraint (productId, bucketTimeKey)๊ฐ€ ์‹ ๊ทœ ๋ ˆ์ฝ”๋“œ ์ƒ์„ฑ ์‹œ ์ค‘๋ณต์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.

Likely an incorrect or invalid review comment.

apps/commerce-api/src/test/java/com/loopers/domain/product/ProductListIntegrationTest.java (1)

4-4: ํƒ€์ž… ๋ณ€๊ฒฝ ํ™•์ธ ์™„๋ฃŒ

ProductWithLikeCount์—์„œ ProductListItem์œผ๋กœ์˜ ํƒ€์ž… ๋ณ€๊ฒฝ์ด ์ ์ ˆํ•˜๊ฒŒ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ƒˆ๋กœ์šด ๋žญํ‚น ์‹œ์Šคํ…œ์„ ๋ฐ˜์˜ํ•˜๋Š” ๊น”๋”ํ•œ ๋ฆฌํŒฉํ† ๋ง์ž…๋‹ˆ๋‹ค.

Also applies to: 30-30

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

12-13: ๋ฐฐ์น˜ ์ €์žฅ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ ํ™•์ธ

saveAll ๋ฉ”์„œ๋“œ๋Š” ์ด๋ฒคํŠธ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ํ•„์ˆ˜์ ์ธ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค. ๋Œ€๋Ÿ‰์˜ ์ด๋ฒคํŠธ๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด ์„ฑ๋Šฅ ํ–ฅ์ƒ์— ๊ธฐ์—ฌํ•ฉ๋‹ˆ๋‹ค.

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

27-30: ๋ฐฐ์น˜ ์ €์žฅ ๊ตฌํ˜„ ํ™•์ธ ์™„๋ฃŒ

saveAll ๋ฉ”์„œ๋“œ๊ฐ€ ํ‘œ์ค€ JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์œ„์ž„ ํŒจํ„ด์„ ๋”ฐ๋ผ ๊น”๋”ํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๊ธฐ์กด ๋ฉ”์„œ๋“œ๋“ค๊ณผ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java (1)

14-14: ๋„ค์ด๋ฐ ํŒจํ„ด์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์„ค๊ณ„๋˜์—ˆ์Œ - ์ถ”๊ฐ€ ์ž‘์—… ๋ถˆํ•„์š”

ProductListResponse์™€ ProductListsResponse๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์šฉ๋„๋กœ ์„ค๊ณ„๋œ ๊ฒƒ์œผ๋กœ, ์ด๋Š” API ๊ณ„์•ฝ ๋ณ€๊ฒฝ์ด ์•„๋‹™๋‹ˆ๋‹ค:

  • ProductListsResponse: API ์‘๋‹ต ์ปจํ…Œ์ด๋„ˆ (๋ณต์ˆ˜ํ˜• - ๋‹ค์ค‘ ์•„์ดํ…œ + ํŽ˜์ด์ง€๋„ค์ด์…˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ)
  • ProductListResponse: ๋ฆฌ์ŠคํŠธ ๋‚ด ๊ฐœ๋ณ„ ์ƒํ’ˆ ํ•ญ๋ชฉ (๋‹จ์ˆ˜ํ˜• - ProductListsResponse ๋‚ด List ์š”์†Œ)

ProductV1Dto์˜ ๊ตฌ์กฐ(line 27-57)์—์„œ ์ด๋ฏธ ์ •ํ™•ํ•˜๊ฒŒ ์ •์˜๋˜์–ด ์žˆ์œผ๋ฉฐ, ProductV1Controller์™€ RankingV1ApiSpec ๋ชจ๋‘ ProductListsResponse๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ถ”๊ฐ€ ๋ฒ„์ „ ์—…๊ทธ๋ ˆ์ด๋“œ๋‚˜ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ณ„ํš์€ ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

Likely an incorrect or invalid review comment.

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

18-18: unitPrice ํ•„๋“œ๋ช… ๋ณ€๊ฒฝ ๊ฒ€์ฆ ์™„๋ฃŒ - ์ถ”๊ฐ€ ์กฐ์น˜ ๋ถˆํ•„์š”

price์—์„œ unitPrice๋กœ์˜ ํ•„๋“œ๋ช… ๋ณ€๊ฒฝ์ด ์ด๋ฏธ ์™„๋ฒฝํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. OrderFacade์—์„œ item.getUnitPrice().getAmount().longValue()๋กœ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ด๋ฒคํŠธ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, OrderEventHandler๋Š” orderItems๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ Order๋ฅผ ์กฐํšŒํ•˜์—ฌ order.getTotalPrice()๋ฅผ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ํ˜ธํ™˜์„ฑ ๋ฌธ์ œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. Java record์˜ ์ž๋™ ์ง๋ ฌํ™” ์ฒ˜๋ฆฌ๋กœ ์ธํ•ด ํ•„๋“œ๋ช… ๋งค์นญ ๋ฌธ์ œ๋„ ์—†์œผ๋ฉฐ, Outbox ํŒจํ„ด์„ ํ†ตํ•œ Kafka ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰๋„ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค. ๋ช…๋ช… ๊ฐœ์„ ์ด ์ž˜ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/kafka/KafkaEventPublisher.java (1)

37-37: getCreatedAt()์˜ ๋ฐ˜ํ™˜ ํƒ€์ž… ํ™•์ธ ํ•„์š”

outboxEvent.getCreatedAt()์€ LocalDateTime์ด ์•„๋‹ˆ๋ผ ZonedDateTime์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค (BaseEntity์—์„œ ์ƒ์†). ๋”ฐ๋ผ์„œ ์›๋ž˜ ์šฐ๋ ค์‚ฌํ•ญ๋“ค์€ ํ•ด๋‹นํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค:

  1. ํƒ€์ž„์กด ์ •๋ณด: ZonedDateTime์€ ํƒ€์ž„์กด ์ •๋ณด๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ ๋ถ„์‚ฐ ์‹œ์Šคํ…œ ํ™˜๊ฒฝ์—์„œ ์‹œ๊ฐ„ ํ•ด์„์— ๋ฌธ์ œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

  2. ํŒŒ์‹ฑ ํ˜•์‹: ZonedDateTime.toString()์€ ISO-8601 ํ˜•์‹(ํƒ€์ž„์กด ํฌํ•จ)์„ ์ƒ์„ฑํ•˜๋ฉฐ, ์ปจ์Šˆ๋จธ์˜ ZonedDateTime.parse(eventTime)์œผ๋กœ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ํŒŒ์‹ฑ๋ฉ๋‹ˆ๋‹ค (EventHandledService ํ™•์ธ๋จ).

  3. ๋ฒ„ํ‚ท ํ‚ค ์ƒ์„ฑ: ํ˜„์žฌ ํ˜•์‹์ด ์ •์ƒ์ ์œผ๋กœ ์ž‘๋™ํ•˜๋ฉฐ, ํ”„๋กœ์„ธ์„œ๋“ค(LikeEventProcessor ๋“ฑ)์ด ์ด๋ฅผ ํ†ตํ•ด LocalDateTime์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ yyyyMMddHHmm ํ˜•์‹์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

์‹œ์Šคํ…œ์€ ์ด๋ฏธ ํƒ€์ž… ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankInfo.java (1)

3-11: LGTM!

RankInfo ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋ช…ํ™•ํ•œ ์ •์  ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ(of, empty)์™€ ํ•จ๊ป˜ ์ž˜ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋žญํ‚น์ด ์—†๋Š” ๊ฒฝ์šฐ๋ฅผ ์œ„ํ•œ empty() ๋ฉ”์„œ๋“œ๊ฐ€ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java (2)

93-94: LGTM!

ProductWithLikeCount์—์„œ ProductListItem์œผ๋กœ์˜ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ํ…Œ์ŠคํŠธ์— ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ฐ˜์˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.


108-109: LGTM!

๋ธŒ๋žœ๋“œ ID ๊ฒ€์ƒ‰ ํ…Œ์ŠคํŠธ๋„ ์ƒˆ๋กœ์šด ProductListItem ํƒ€์ž…์œผ๋กœ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

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

11-23: LGTM!

rank ํ•„๋“œ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ถ”๊ฐ€๋˜์—ˆ๊ณ , ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ ์‹œ๊ทธ๋‹ˆ์ฒ˜๊ฐ€ ์ ์ ˆํžˆ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. Integer ํƒ€์ž…์œผ๋กœ nullable์„ ํ—ˆ์šฉํ•˜์—ฌ ๋žญํ‚น์— ์—†๋Š” ์ƒํ’ˆ๋„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventsConsumer.java (2)

33-44: LGTM!

๋ฐฐ์น˜ ๋‚ด ์ค‘๋ณต ์ œ๊ฑฐ ํ›„ inbox์— ์ €์žฅํ•˜๋Š” ํŒจํ„ด์ด ์ž˜ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. learnings์—์„œ ์–ธ๊ธ‰๋œ ์ค‘์•™ํ™”๋œ ์—๋Ÿฌ ํ•ธ๋“ค๋ง ํŒจํ„ด์„ ๋”ฐ๋ฅด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


56-86: LGTM!

LinkedHashMap์„ ์‚ฌ์šฉํ•˜์—ฌ ์‚ฝ์ž… ์ˆœ์„œ๋ฅผ ์œ ์ง€ํ•˜๋ฉด์„œ ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜๋Š” ๋กœ์ง์ด ์ž˜ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. offset ๊ธฐ์ค€์œผ๋กœ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ ์œ ์ง€ํ•˜๊ณ , ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ์—๋„ ๋ฉ”์‹œ์ง€ ์œ ์‹ค์„ ๋ฐฉ์ง€ํ•˜๋Š” ์•ˆ์ „ํ•œ ์ฒ˜๋ฆฌ๊ฐ€ ์ ์šฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

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

104-117: LGTM!

ํŒ๋งค ์ˆ˜์ต ์—…๋ฐ์ดํŠธ๋Š” incrementSalesRevenue(deltaCount)๋ฅผ ์ง์ ‘ ํ˜ธ์ถœํ•˜์—ฌ ํšจ์œจ์ ์œผ๋กœ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

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

31-48: ๋ฐฐ์น˜ ์ค‘๋ณต ์ œ๊ฑฐ ๋ฐ ์ €์žฅ ๋กœ์ง์ด ์ž˜ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ID ๊ธฐ๋ฐ˜ ์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ๋ฐฐ์น˜ ์ €์žฅํ•˜๋Š” ํ๋ฆ„์ด ๋ช…ํ™•ํ•ฉ๋‹ˆ๋‹ค. ์ฆ‰์‹œ ์ปค๋ฐ‹์œผ๋กœ ๋ฉ”์‹œ์ง€ ์œ ์‹ค์„ ๋ฐฉ์ง€ํ•˜๋Š” ํŒจํ„ด๋„ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

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

38-45: getProductList ๋ฐ˜ํ™˜ ํƒ€์ž… ๋ณ€๊ฒฝ์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

Page<ProductListItem> ๋ฐ˜ํ™˜์œผ๋กœ ๋žญํ‚น ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ํ†ตํ•ฉ๋œ ๋ฆฌ์ŠคํŠธ ์•„์ดํ…œ ๋ชจ๋ธ์„ ์‚ฌ์šฉํ•˜๋„๋ก ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ProductQueryService์™€ ์ผ๊ด€์„ฑ ์žˆ๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

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

25-26: eventId์— unique ์ œ์•ฝ ์กฐ๊ฑด ์ถ”๊ฐ€๊ฐ€ ์ข‹์Šต๋‹ˆ๋‹ค.

์ด๋ฒคํŠธ ID์˜ ๊ณ ์œ ์„ฑ์„ DB ๋ ˆ๋ฒจ์—์„œ ๋ณด์žฅํ•˜์—ฌ ์ค‘๋ณต ์ด๋ฒคํŠธ ์ €์žฅ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค. ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.


53-62: eventTime ํ•„๋“œ ๋ฐ ์ƒ์„ฑ์ž ํ™•์žฅ์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

10๋ถ„ ๋ฒ„ํ‚ท ๊ธฐ๋ฐ˜ ์ง‘๊ณ„๋ฅผ ์œ„ํ•œ eventTime ํ•„๋“œ๊ฐ€ ์ž˜ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. nullable๋กœ ์„ค์ •ํ•˜์—ฌ ๊ธฐ์กด ๋ฐ์ดํ„ฐ์™€์˜ ํ˜ธํ™˜์„ฑ๋„ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (1)

16-23: ๋žญํ‚น ์กฐํšŒ ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

reverseRank๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋†’์€ ์ ์ˆ˜ ์ˆœ์œผ๋กœ ์ˆœ์œ„๋ฅผ ๊ณ„์‚ฐํ•˜๊ณ , 0-based์—์„œ 1-based๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ๋กœ์ง์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java (1)

22-37: ์ง‘๊ณ„ ๋ฉ”์„œ๋“œ ์‹คํ–‰ ํ๋ฆ„์ด ์ž˜ ๊ตฌ์กฐํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฐ ๋ฉ”ํŠธ๋ฆญ ์œ ํ˜•๋ณ„๋กœ ๋…๋ฆฝ์ ์œผ๋กœ ์ง‘๊ณ„ํ•˜๊ณ , ๊ฐœ๋ณ„ ์‹คํŒจ๊ฐ€ ์ „์ฒด ๋ฐฐ์น˜์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋„๋ก ์—๋Ÿฌ ์ฒ˜๋ฆฌ๊ฐ€ ์ž˜ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)

17-29: API ์ŠคํŽ™์ด ์ž˜ ์ •์˜๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

๋žญํ‚น ์กฐํšŒ API์˜ ๊ตฌ์กฐ๊ฐ€ ๋ช…ํ™•ํ•˜๊ณ , Swagger ์–ด๋…ธํ…Œ์ด์…˜์ด ์ ์ ˆํ•˜๊ฒŒ ์ ์šฉ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋‚ ์งœ ํ˜•์‹(yyyyMMdd)๊ณผ ํŽ˜์ด์ง€๋„ค์ด์…˜ ๊ธฐ๋ณธ๊ฐ’(size=20, page=1)์ด ์ž˜ ๋ฌธ์„œํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

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

26-50: Delta ๊ธฐ๋ฐ˜ ์—…๋ฐ์ดํŠธ ๋ฉ”์„œ๋“œ ๊ตฌํ˜„์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

๋ฒ„ํ‚ท ๊ธฐ๋ฐ˜ ๋ฉ”ํŠธ๋ฆญ ์—…๋ฐ์ดํŠธ๋ฅผ ์œ„ํ•œ delta ๋ฉ”์„œ๋“œ๋“ค์ด JPA ๋ ˆํฌ์ง€ํ† ๋ฆฌ์— ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์œ„์ž„๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. findByProductIdAndBucketTimeKey ํŒŒ์ธ๋”์™€ ํ•จ๊ป˜ ๋ฒ„ํ‚ท ์‹œ๊ฐ„ ๊ธฐ๋ฐ˜ ์ง‘๊ณ„ ์ „๋žต์„ ์ž˜ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

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

43-44: eventTime ํŒŒ์‹ฑ ์‹œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”.

ZonedDateTime.parse(eventTime)๋Š” ์ž˜๋ชป๋œ ํ˜•์‹์˜ eventTime์— ๋Œ€ํ•ด DateTimeParseException์„ ๋ฐœ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ ์™ธ๋ถ€ try-catch์—์„œ ์ฒ˜๋ฆฌ๋˜์ง€๋งŒ, ๋ช…์‹œ์ ์ธ ํ˜•์‹ ๊ฒ€์ฆ์ด๋‚˜ ๋” ๊ตฌ์ฒด์ ์ธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ๋„์›€์ด ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java (1)

135-157: ๋ฒ„ํ‚ท ์‹œ๊ฐ„ ๊ณ„์‚ฐ ํ—ฌํผ ๋ฉ”์„œ๋“œ๊ฐ€ ์ž˜ ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

10๋ถ„ ๋‹จ์œ„ ๋ฒ„ํ‚ทํŒ… ๋กœ์ง์ด ๋ช…ํ™•ํ•˜๊ณ , null ์ฒ˜๋ฆฌ๊ฐ€ ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค. truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes) ํŒจํ„ด์œผ๋กœ ์ •ํ™•ํ•œ 10๋ถ„ ๊ฐ„๊ฒฉ ๋ฒ„ํ‚ท์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/ProductRankingService.java (1)

19-22: ๊ฐ€์ค‘์น˜ ์„ค๊ณ„์— ๋Œ€ํ•ด ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

VIEW(0.1), LIKE(0.2), ORDER(0.6)์˜ ํ•ฉ์ด 0.9์ž…๋‹ˆ๋‹ค. ์ด๋Š” ์˜๋„์ ์ธ ๊ฒƒ์œผ๋กœ ๋ณด์ด์ง€๋งŒ, addSalesScore์—์„œ price * quantity * 0.6์„ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ๊ณ ๊ฐ€ ์ƒํ’ˆ์ด ๋žญํ‚น์„ ์••๋„์ ์œผ๋กœ ์ง€๋ฐฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์˜ˆ: 100๋งŒ์› ์ƒํ’ˆ 1๊ฐœ ์ฃผ๋ฌธ = 600,000์  vs ์กฐํšŒ 1ํšŒ = 0.1์ 

์ด ์Šค์ฝ”์–ด๋ง ์ „๋žต์ด ๋น„์ฆˆ๋‹ˆ์Šค ์š”๊ตฌ์‚ฌํ•ญ๊ณผ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.

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

18-28: Delta ๊ธฐ๋ฐ˜ ์—…๋ฐ์ดํŠธ ์ฟผ๋ฆฌ ๊ตฌํ˜„์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

JPQL ์ฟผ๋ฆฌ๊ฐ€ ์ผ๊ด€๋œ ํŒจํ„ด์œผ๋กœ ์ž‘์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ฃผ์˜ํ•  ์ : ํ•ด๋‹น productId์™€ bucketTimeKey ์กฐํ•ฉ์ด ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด 0์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ํ˜ธ์ถœํ•˜๋Š” ์„œ๋น„์Šค ๋ ˆ์ด์–ด์—์„œ ์ด ๊ฒฝ์šฐ๋ฅผ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค (์˜ˆ: ๋ ˆ์ฝ”๋“œ๊ฐ€ ์—†์œผ๋ฉด ๋จผ์ € ์ƒ์„ฑ).

apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java (1)

27-57: ์ƒˆ๋กœ์šด DTO ๋ ˆ์ฝ”๋“œ ๊ตฌ์กฐ๊ฐ€ ์ž˜ ์„ค๊ณ„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ProductListsResponse์™€ ProductListResponse์˜ ๋ถ„๋ฆฌ๊ฐ€ ๋ช…ํ™•ํ•˜๊ณ , ๋žญํ‚น ์ •๋ณด(rank)๊ฐ€ ์ ์ ˆํ•˜๊ฒŒ ํ†ตํ•ฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. Schema ์–ด๋…ธํ…Œ์ด์…˜์œผ๋กœ API ๋ฌธ์„œํ™”๋„ ์ž˜ ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java (3)

22-22: LGTM! ๋žญํ‚น ์„œ๋น„์Šค ํ†ตํ•ฉ์ด ์˜ฌ๋ฐ”๋ฆ…๋‹ˆ๋‹ค.

RankingService ์˜์กด์„ฑ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ฃผ์ž…๋˜์—ˆ์œผ๋ฉฐ, ์ œํ’ˆ ์ˆœ์œ„ ์กฐํšŒ์— ์ ์ ˆํžˆ ํ™œ์šฉ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.


24-30: LGTM! ๋ฉ”์„œ๋“œ ์‹œ๊ทธ๋‹ˆ์ฒ˜ ๋ณ€๊ฒฝ์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

getProductList์˜ ๋ฐ˜ํ™˜ ํƒ€์ž…์ด Page<ProductListItem>์œผ๋กœ ๋ณ€๊ฒฝ๋˜์–ด ๋žญํ‚น ์ •๋ณด๋ฅผ ํฌํ•จํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์œผ๋ฉฐ, ProductCacheService๋กœ์˜ ์œ„์ž„์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.


37-42: LGTM! ์ œํ’ˆ ์ƒ์„ธ ์ •๋ณด์— ๋žญํ‚น ํ†ตํ•ฉ์ด ์˜ฌ๋ฐ”๋ฆ…๋‹ˆ๋‹ค.

rankingService.getProductRank(productId)๋ฅผ ํ†ตํ•ด ์ œํ’ˆ์˜ ํ˜„์žฌ ์ˆœ์œ„๋ฅผ ์กฐํšŒํ•˜๊ณ  ProductDetailInfo์— ์ „๋‹ฌํ•˜๋Š” ๋กœ์ง์ด ์ •ํ™•ํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. PR ๋ชฉํ‘œ์— ๋ช…์‹œ๋œ ๋Œ€๋กœ ๋žญํ‚น์ด ์—†๋Š” ๊ฒฝ์šฐ null์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋™์ž‘๋„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

Comment on lines 5 to 14
public record ProductListItem(Long id, String name, BigDecimal price, long likeCount, Integer rank) {
public static ProductListItem from(Long id, String name, BigDecimal price, long likeCount, int rank) {
return new ProductListItem(
id,
name,
price,
likeCount,
rank
);
}
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

ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ์˜ rank ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž…์„ Integer๋กœ ๋ณ€๊ฒฝํ•˜์„ธ์š”.

from() ๋ฉ”์„œ๋“œ์˜ rank ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ primitive int๋กœ ์„ ์–ธ๋˜์–ด ์žˆ์ง€๋งŒ, ๋ ˆ์ฝ”๋“œ ํ•„๋“œ๋Š” Integer์ž…๋‹ˆ๋‹ค. AI ์š”์•ฝ์— ๋”ฐ๋ฅด๋ฉด ๋žญํ‚น์— ์—†๋Š” ์ƒํ’ˆ์€ rank๊ฐ€ null์ผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ๋„ null์„ ํ—ˆ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-  public static ProductListItem from(Long id, String name, BigDecimal price, long likeCount, int rank) {
+  public static ProductListItem from(Long id, String name, BigDecimal price, long likeCount, Integer rank) {
๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/product/ProductListItem.java
around lines 5 to 14, the factory method from(...) currently declares the rank
parameter as primitive int while the record field type is Integer; change the
from method signature to accept Integer rank (allowing null), and pass that
Integer rank through to the record constructor so null ranks are preserved.

Comment on lines +40 to +49
// productIds ๋ฆฌ์ŠคํŠธ ์ƒ์„ฑ
List<Long> productIds = rankedProductIds.stream()
.map(Long::parseLong)
.toList();

// ์ƒํ’ˆ ์ •๋ณด ์ผ๊ด„ ์กฐํšŒ (์ข‹์•„์š” ์ •๋ณด ํฌํ•จ)
List<ProductListItem> productsWithLike = productQueryService.getProductListByProductIds(userId, productIds);
Long totalCount = rankingService.getTotalRankingCount(date);

return new PageImpl<>(productsWithLike, PageRequest.of(page - 1, size), totalCount);
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 -E "(ProductQueryService|RankingFacade)" | head -20

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

Length of output: 263


๐Ÿ Script executed:

rg -n "getProductListByProductIds" --type java -A 20

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

Length of output: 6530


๐Ÿ Script executed:

rg -n "reverseRange" --type java -A 10 -B 5

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

Length of output: 4054


๐Ÿ Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java | head -50

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

Length of output: 2287


๐Ÿ Script executed:

rg -n "class RankingFacade" --type java -A 50

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

Length of output: 4009


๐Ÿ Script executed:

rg -n "LinkedHashSet" --type java apps/commerce-api

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

Length of output: 70


๐Ÿ Script executed:

rg -n "rankedProductIds" --type java -B 2 -A 5

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

Length of output: 1933


Redis reverseRange์—์„œ ๋ฐ˜ํ™˜๋œ ์ˆœ์„œ๊ฐ€ ๋ณด์กด๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

Set<String> rankedProductIds = redisTemplate.opsForZSet().reverseRange(...)์—์„œ ๋ฐ˜ํ™˜๋œ Set์€ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜์ง€ ์•Š๋Š” ์ปฌ๋ ‰์…˜์ด๋ฏ€๋กœ, ์ด๋ฅผ List๋กœ ๋ณ€ํ™˜ํ•˜๋ฉด Redis์˜ ์ ์ˆ˜ ๋‚ด๋ฆผ์ฐจ์ˆœ์ด ์†์‹ค๋ฉ๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ API ์‘๋‹ต์˜ ์ˆœ์„œ๊ฐ€ ๋žญํ‚น ์ˆœ์„œ์™€ ์ผ์น˜ํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋Œ€์‹  reverseRangeWithScores() ๋˜๋Š” ๊ฒฐ๊ณผ๋ฅผ LinkedHashSet์œผ๋กœ ๋ฐ›์•„์„œ ์ˆœ์„œ๋ฅผ ์œ ์ง€ํ•˜๊ฑฐ๋‚˜, ๋‹ค์‹œ ์ •๋ ฌํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ˆ˜์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 40 to 49, the code converts the Set returned by
redisTemplate.opsForZSet().reverseRange(...) to a List which can lose Redis
ordering; replace that call with reverseRangeWithScores(...) (or otherwise
collect into an ordered collection like LinkedHashSet) and map the returned
ordered tuples to a List<String>/List<Long> in the same order before parsing to
Long, so the ranking order from Redis is preserved when calling
productQueryService and building the PageImpl.

Comment on lines +19 to +31
@Override
public ApiResponse<ProductV1Dto.ProductListsResponse> getRankings(Long userId, String date, int size, int page) {
Page<ProductListItem> productPage = rankingFacade.getProductRankings(userId, date, size, page);
List<ProductV1Dto.ProductListResponse> products = productPage.getContent().stream()
.map(ProductV1Dto.ProductListResponse::from)
.toList();

ProductV1Dto.ProductListsResponse response = new ProductV1Dto.ProductListsResponse(
products,
productPage.getTotalElements()
);
return ApiResponse.success(response);
}
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

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

find . -type f -name "*RankingV1ApiSpec*" | head -20

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

Length of output: 178


๐Ÿ Script executed:

rg "interface RankingV1ApiSpec" --type java -A 30

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

Length of output: 2105


๐Ÿ Script executed:

rg "class RankingV1Controller" --type java -B 5 -A 50

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

Length of output: 2968


๐Ÿ Script executed:

rg "class RankingFacade" --type java -A 100 | head -150

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

Length of output: 3913


๐Ÿ Script executed:

rg "getProductRankings" --type java -B 5 -A 25

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

Length of output: 5920


๐Ÿ Script executed:

# Check for global validation configuration or custom validators
rg "@Pattern|@Valid|@NotBlank|@DateTimeFormat" --type java -B 2 -A 2 | grep -A 5 -B 5 "date"

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

Length of output: 70


๐Ÿ Script executed:

# Check if there's a global validator configuration or custom validator class
rg "class.*Validator" --type java | grep -i "date\|ranking"

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

Length of output: 70


๐Ÿ Script executed:

# Check RankingService.getTotalRankingCount to see if date validation happens there
rg "getTotalRankingCount" --type java -A 10

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

Length of output: 1246


date ํŒŒ๋ผ๋ฏธํ„ฐ์— ํ˜•์‹ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

RankingV1ApiSpec ์ธํ„ฐํŽ˜์ด์Šค์—์„œ date ํŒŒ๋ผ๋ฏธํ„ฐ์— ๊ฒ€์ฆ ์–ด๋…ธํ…Œ์ด์…˜์ด ์—†์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ RankingFacade์™€ RankingService์—์„œ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์—†์ด ์ง์ ‘ Redis ํ‚ค๋ฅผ ์ƒ์„ฑํ•˜๋ฏ€๋กœ ("ranking:all:" + date), ์ž˜๋ชป๋œ ํ˜•์‹์˜ ๋‚ ์งœ๊ฐ€ ์ „๋‹ฌ๋˜๋ฉด ๋นˆ ๊ฒฐ๊ณผ๊ฐ€ ๋ฐ˜ํ™˜๋  ๋ฟ ์ ์ ˆํ•œ ์—๋Ÿฌ ์‘๋‹ต์ด ์ œ๊ณต๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

RankingV1ApiSpec์˜ date ํŒŒ๋ผ๋ฏธํ„ฐ์— @Pattern(regexp = "^\\d{8}$", message = "๋‚ ์งœ๋Š” yyyyMMdd ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค") ์–ด๋…ธํ…Œ์ด์…˜์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

Comment on lines 237 to 253
private LocalDateTime getBucketTime(ZonedDateTime eventTime) {
try {
if (eventTime == null) {
LocalDateTime now = LocalDateTime.now();
int bucketMinutes = (now.getMinute() / 10) * 10;
return now.truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes);
}
LocalDateTime eventDateTime = eventTime.toLocalDateTime();
int bucketMinutes = (eventDateTime.getMinute() / 10) * 10;
return eventDateTime.truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes);
} catch (Exception e) {
log.warn("Failed to parse eventTime, using current time: eventTime={}", eventTime, e);
LocalDateTime now = LocalDateTime.now();
int bucketMinutes = (now.getMinute() / 10) * 10;
return now.truncatedTo(ChronoUnit.HOURS).plusMinutes(bucketMinutes);
}
}
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

getBucketTime์˜ null ์ฒ˜๋ฆฌ๊ฐ€ ๋‹ค๋ฅธ ํ”„๋กœ์„ธ์„œ์™€ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.

OrderEventProcessor์™€ ViewEventProcessor๋Š” eventTime์ด null์ด๋ฉด null์„ ๋ฐ˜ํ™˜ํ•˜์ง€๋งŒ, ์—ฌ๊ธฐ์„œ๋Š” ํ˜„์žฌ ์‹œ๊ฐ„์œผ๋กœ ํด๋ฐฑํ•ฉ๋‹ˆ๋‹ค. ์ด๋กœ ์ธํ•ด ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ผ๊ด€๋œ ๋™์ž‘์„ ์œ„ํ•ด ๋‹ค๋ฅธ ํ”„๋กœ์„ธ์„œ์™€ ๋™์ผํ•˜๊ฒŒ null์„ ๋ฐ˜ํ™˜ํ•˜๊ฑฐ๋‚˜, ๋ชจ๋“  ํ”„๋กœ์„ธ์„œ์—์„œ ๋™์ผํ•œ ํด๋ฐฑ ์ „๋žต์„ ์ ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/LikeEventProcessor.java
around lines 237 to 253, the method currently falls back to the current time
when eventTime is null or parsing fails, which is inconsistent with
OrderEventProcessor and ViewEventProcessor; change the logic so that if
eventTime is null the method returns null (matching the other processors), and
update the exception handler to also return null instead of computing/returning
the current time; ensure any callers handle a possible null result accordingly.

*/
private void aggregateViewCounts() {
try {
Set<String> keys = redisTemplate.keys("product_views:*");
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

Redis KEYS ๋ช…๋ น์–ด ์‚ฌ์šฉ์€ ํ”„๋กœ๋•์…˜์—์„œ ์œ„ํ—˜ํ•ฉ๋‹ˆ๋‹ค.

redisTemplate.keys()๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ Redis KEYS ๋ช…๋ น์–ด๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ, ์ด๋Š” O(N) ๋ธ”๋กœํ‚น ์—ฐ์‚ฐ์ž…๋‹ˆ๋‹ค. ํ‚ค๊ฐ€ ๋งŽ์•„์ง€๋ฉด Redis ์„œ๋ฒ„๊ฐ€ ๋ธ”๋กœํ‚น๋˜์–ด ์ „์ฒด ์„œ๋น„์Šค์— ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

SCAN ๋ช…๋ น์–ด๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ฐ•๋ ฅํžˆ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค:

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ • (aggregateViewCounts ์˜ˆ์‹œ)
   private void aggregateViewCounts() {
     try {
-      Set<String> keys = redisTemplate.keys("product_views:*");
-      
-      if (keys == null || keys.isEmpty()) {
-        return;
-      }
+      ScanOptions options = ScanOptions.scanOptions()
+          .match("product_views:*")
+          .count(100)
+          .build();
       
       Map<Long, Map<String, Long>> productBucketCounts = new HashMap<>();
       
-      for (String key : keys) {
+      try (Cursor<String> cursor = redisTemplate.scan(options)) {
+        while (cursor.hasNext()) {
+          String key = cursor.next();
+          // ... ๊ธฐ์กด ๋กœ์ง
+        }
+      }

Also applies to: 92-92, 140-140

๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/MetricsBatchProcessor.java
around lines 44, 92 and 140, the code uses redisTemplate.keys("product_views:*")
which calls Redis KEYS (O(N)) and is unsafe in production; change these usages
to use Redis SCAN via a non-blocking cursor (e.g., redisTemplate.execute with a
ScanOptions.match("product_views:*") and a Cursor<byte[]/String> or
StringRedisTemplate.scan helper), iterate the cursor to collect/process matching
keys in pages, aggregate view counts as before, and ensure the cursor is closed
and the scan is performed with a reasonable count option to avoid blocking
Redis; replace each keys() call with this scanning pattern and adjust
aggregation logic to work incrementally while scanning.

Comment on lines +49 to +50
// 2. ๋‚ด์ผ๋กœ 20% carry over
carryOverToTomorrow(todayKey, tomorrowKey);
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

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
# First, let's find the file and understand the context
find . -type f -name "ScoreCarryOverScheduler.java" | head -5

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

Length of output: 185


๐Ÿ Script executed:

#!/bin/bash
# Search for ranking key patterns and TTL/expire settings
rg -n "ranking" --type java -A 2 -B 2 | head -100

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

Length of output: 11811


๐Ÿ Script executed:

#!/bin/bash
# Search for expire, setex, ttl patterns in Java files
rg -n "setex|expire|ttl|EXPIRE" --type java -i | head -50

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

Length of output: 5052


๐Ÿ Script executed:

#!/bin/bash
# Look for carryOverToTomorrow method implementation
rg -n "carryOverToTomorrow" --type java -A 10 -B 2

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

Length of output: 3442


๐Ÿ Script executed:

#!/bin/bash
# Let's see the complete carryOverToTomorrow method
cat -n apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java | sed -n '88,130p'

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

Length of output: 1461


๐Ÿ Script executed:

#!/bin/bash
# Check if productRankingService is used in the scheduler
rg -n "productRankingService|incrementScore" apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java

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

Length of output: 180


๐Ÿ Script executed:

#!/bin/bash
# Look at the full ScoreCarryOverScheduler class
wc -l apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java

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

Length of output: 187


๐Ÿ Script executed:

#!/bin/bash
# Let's see the full file to understand the context
head -80 apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java

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

Length of output: 2672


tomorrowKey์— TTL ์„ค์ • ํ•„์š”

carryOverToTomorrow ๋ฉ”์„œ๋“œ์—์„œ incrementScore(tomorrowKey, productId, carriedScore) ํ˜ธ์ถœ ํ›„ TTL์ด ์„ค์ •๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ProductRankingService์˜ ํŒจํ„ด์ฒ˜๋Ÿผ setTtlIfNeeded() ๋˜๋Š” ์ง์ ‘ expire(tomorrowKey, 2, TimeUnit.DAYS) ํ˜ธ์ถœ์„ ์ถ”๊ฐ€ํ•˜์—ฌ 2์ผ TTL์„ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ ์ƒํƒœ์—์„œ๋Š” ๋‚ด์ผ ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์œผ๋ฉด ํ‚ค๊ฐ€ ๋ฌดํ•œ์ • ์œ ์ง€๋ฉ๋‹ˆ๋‹ค.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java
around lines 49-50, after calling carryOverToTomorrow(todayKey, tomorrowKey) the
code path that performs incrementScore(tomorrowKey, productId, carriedScore)
does not set a TTL on tomorrowKey; update carryOverToTomorrow so that
immediately after incrementing the score you set a 2-day TTL on tomorrowKey
(either by calling the existing
ProductRankingService.setTtlIfNeeded(tomorrowKey) helper or by invoking
redis.expire(tomorrowKey, 2, TimeUnit.DAYS)) to ensure the key expires if no
event occurs the next day.

Comment on lines 59 to 86
private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) {
Set<String> productIds = redisTemplate.opsForZSet().reverseRange(rankingKey, 0, -1);

if (productIds == null || productIds.isEmpty()) {
log.info("No ranking data to save for date: {}", rankingDate);
return;
}

List<DailyRanking> dailyRankings = new ArrayList<>();
int position = 1;

for (String productId : productIds) {
Double score = redisTemplate.opsForZSet().score(rankingKey, productId);
if (score != null) {
DailyRanking dailyRanking = DailyRanking.of(
rankingDate,
Long.parseLong(productId),
position,
score
);
dailyRankings.add(dailyRanking);
position++;
}
}

dailyRankingRepository.saveAll(dailyRankings);
log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate);
}
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

Redis N+1 ํ˜ธ์ถœ ๋ฌธ์ œ - reverseRangeWithScores ์‚ฌ์šฉ ๊ถŒ์žฅ

reverseRange ํ˜ธ์ถœ ํ›„ ๊ฐ productId์— ๋Œ€ํ•ด score()๋ฅผ ๊ฐœ๋ณ„ ํ˜ธ์ถœํ•˜๊ณ  ์žˆ์–ด N+1 ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. reverseRangeWithScores๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํ•œ ๋ฒˆ์˜ ํ˜ธ์ถœ๋กœ ์ ์ˆ˜์™€ ๋ฉค๋ฒ„๋ฅผ ํ•จ๊ป˜ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž reverseRangeWithScores ์‚ฌ์šฉ์œผ๋กœ ๊ฐœ์„ 
 private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) {
-    Set<String> productIds = redisTemplate.opsForZSet().reverseRange(rankingKey, 0, -1);
+    Set<org.springframework.data.redis.core.ZSetOperations.TypedTuple<String>> rankedProducts =
+        redisTemplate.opsForZSet().reverseRangeWithScores(rankingKey, 0, -1);
 
-    if (productIds == null || productIds.isEmpty()) {
+    if (rankedProducts == null || rankedProducts.isEmpty()) {
       log.info("No ranking data to save for date: {}", rankingDate);
       return;
     }
 
     List<DailyRanking> dailyRankings = new ArrayList<>();
     int position = 1;
 
-    for (String productId : productIds) {
-      Double score = redisTemplate.opsForZSet().score(rankingKey, productId);
+    for (var tuple : rankedProducts) {
+      String productId = tuple.getValue();
+      Double score = tuple.getScore();
       if (score != null) {
         DailyRanking dailyRanking = DailyRanking.of(
             rankingDate,
             Long.parseLong(productId),
             position,
             score
         );
         dailyRankings.add(dailyRanking);
         position++;
       }
     }
 
     dailyRankingRepository.saveAll(dailyRankings);
     log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate);
   }
๐Ÿ“ 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
private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) {
Set<String> productIds = redisTemplate.opsForZSet().reverseRange(rankingKey, 0, -1);
if (productIds == null || productIds.isEmpty()) {
log.info("No ranking data to save for date: {}", rankingDate);
return;
}
List<DailyRanking> dailyRankings = new ArrayList<>();
int position = 1;
for (String productId : productIds) {
Double score = redisTemplate.opsForZSet().score(rankingKey, productId);
if (score != null) {
DailyRanking dailyRanking = DailyRanking.of(
rankingDate,
Long.parseLong(productId),
position,
score
);
dailyRankings.add(dailyRanking);
position++;
}
}
dailyRankingRepository.saveAll(dailyRankings);
log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate);
}
private void saveDailyRankingToDB(String rankingKey, LocalDate rankingDate) {
Set<org.springframework.data.redis.core.ZSetOperations.TypedTuple<String>> rankedProducts =
redisTemplate.opsForZSet().reverseRangeWithScores(rankingKey, 0, -1);
if (rankedProducts == null || rankedProducts.isEmpty()) {
log.info("No ranking data to save for date: {}", rankingDate);
return;
}
List<DailyRanking> dailyRankings = new ArrayList<>();
int position = 1;
for (var tuple : rankedProducts) {
String productId = tuple.getValue();
Double score = tuple.getScore();
if (score != null) {
DailyRanking dailyRanking = DailyRanking.of(
rankingDate,
Long.parseLong(productId),
position,
score
);
dailyRankings.add(dailyRanking);
position++;
}
}
dailyRankingRepository.saveAll(dailyRankings);
log.info("Saved {} daily rankings to DB for date: {}", dailyRankings.size(), rankingDate);
}
๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java
around lines 59 to 86, the method fetches members with
redisTemplate.opsForZSet().reverseRange and then calls score(...) per productId
causing an N+1 Redis calls issue; replace the two-step approach with a single
call to redisTemplate.opsForZSet().reverseRangeWithScores(rankingKey, 0, -1) to
retrieve both members and scores at once, iterate the returned TypedTuple
collection to build DailyRanking objects (parsing member to Long, using
tuple.getScore() for score) and then saveAll as before to eliminate the extra
network roundtrips.

Comment on lines 112 to 113
log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)",
carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100);
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

SLF4J ๋กœ๊ทธ ํฌ๋งท ์˜ค๋ฅ˜ ์ˆ˜์ • ํ•„์š”

{:.2f}๋Š” Python ์Šคํƒ€์ผ ํฌ๋งท์ด๋ฉฐ SLF4J์—์„œ๋Š” ๋™์ž‘ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. SLF4J๋Š” ๋‹จ์ˆœํžˆ {}๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์†Œ์ˆ˜์  ์ž๋ฆฟ์ˆ˜ ํฌ๋งท์ด ํ•„์š”ํ•˜๋‹ค๋ฉด String.format()์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋กœ๊ทธ ํฌ๋งท ์ˆ˜์ •
-    log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)",
-        carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100);
+    log.info("Carried over {} products to tomorrow. Total score: {} ({}% of today)",
+        carriedOverCount, String.format("%.2f", totalCarriedScore), (int)(CARRY_OVER_RATIO * 100));
๐Ÿ“ 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
log.info("Carried over {} products to tomorrow. Total score: {:.2f} ({}% of today)",
carriedOverCount, totalCarriedScore, CARRY_OVER_RATIO * 100);
log.info("Carried over {} products to tomorrow. Total score: {} ({}% of today)",
carriedOverCount, String.format("%.2f", totalCarriedScore), (int)(CARRY_OVER_RATIO * 100));
๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ScoreCarryOverScheduler.java
around lines 112-113, the log uses a Python-style "{:.2f}" placeholder which
SLF4J does not support; replace the format by either using plain "{}"
placeholders for SLF4J or pre-format the numeric value with String.format for
two decimal places. Concretely, format totalCarriedScore to two decimals (e.g.,
String.format(Locale.US, "%.2f", totalCarriedScore)) and then call log.info with
"{}" placeholders for carriedOverCount, the pre-formatted score string, and
(CARRY_OVER_RATIO * 100) or its formatted value.

Comment on lines 84 to 105
private void processProductViewed(JsonNode eventData, EventHandled event) {
Long productId = eventData.get("productId").asLong();
Long userId = eventData.get("userId").asLong();
ZonedDateTime eventTime = event.getEventTime();
LocalDateTime bucketTime = getBucketTime(eventTime);
String bucketTimeKey = getBucketTimeKey(bucketTime);

log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}",
productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey);

// eventTime ๊ธฐ์ค€ 10๋ถ„ ๊ฐ„๊ฒฉ ์กฐํšŒ์ˆ˜ ๋ˆ„์  (Redis๋งŒ ์ฆ‰์‹œ ๋ฐ˜์˜)
incrementViewCountByBucketTime(productId, bucketTimeKey);

log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}",
productId, userId, event.getEventId());
// ๋žญํ‚น ์ ์ˆ˜ ์ถ”๊ฐ€
productRankingService.addViewScore(productId);

// ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€
productMetricsService.incrementViewCount(productId);

// ์ƒํ’ˆ ์บ์‹œ ์‚ญ์ œ
evictProductCache(productId);

log.info("ProductViewed metrics updated: productId={}, eventId={}",
log.info("ProductViewed metrics and ranking updated: productId={}, eventId={}",
productId, event.getEventId());
}
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

bucketTimeKey๊ฐ€ null์ผ ๋•Œ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

eventTime์ด null์ด๋ฉด bucketTimeKey๋„ null์ด ๋˜์–ด Redis ํ‚ค๊ฐ€ product_views:123:null ํ˜•ํƒœ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. ์ด๋Š” ๋ฐ์ดํ„ฐ ์˜ค์—ผ์„ ์œ ๋ฐœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
  private void processProductViewed(JsonNode eventData, EventHandled event) {
    Long productId = eventData.get("productId").asLong();
    Long userId = eventData.get("userId").asLong();
    ZonedDateTime eventTime = event.getEventTime();
    LocalDateTime bucketTime = getBucketTime(eventTime);
    String bucketTimeKey = getBucketTimeKey(bucketTime);

    log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}",
        productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey);

-   // eventTime ๊ธฐ์ค€ 10๋ถ„ ๊ฐ„๊ฒฉ ์กฐํšŒ์ˆ˜ ๋ˆ„์  (Redis๋งŒ ์ฆ‰์‹œ ๋ฐ˜์˜)
-   incrementViewCountByBucketTime(productId, bucketTimeKey);
+   // eventTime ๊ธฐ์ค€ 10๋ถ„ ๊ฐ„๊ฒฉ ์กฐํšŒ์ˆ˜ ๋ˆ„์  (Redis๋งŒ ์ฆ‰์‹œ ๋ฐ˜์˜)
+   if (bucketTimeKey != null) {
+     incrementViewCountByBucketTime(productId, bucketTimeKey);
+   } else {
+     log.warn("Skipping bucket increment due to null bucketTimeKey: productId={}, eventId={}", 
+         productId, event.getEventId());
+   }

    // ๋žญํ‚น ์ ์ˆ˜ ์ถ”๊ฐ€
    productRankingService.addViewScore(productId);
๐Ÿ“ 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
private void processProductViewed(JsonNode eventData, EventHandled event) {
Long productId = eventData.get("productId").asLong();
Long userId = eventData.get("userId").asLong();
ZonedDateTime eventTime = event.getEventTime();
LocalDateTime bucketTime = getBucketTime(eventTime);
String bucketTimeKey = getBucketTimeKey(bucketTime);
log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}",
productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey);
// eventTime ๊ธฐ์ค€ 10๋ถ„ ๊ฐ„๊ฒฉ ์กฐํšŒ์ˆ˜ ๋ˆ„์  (Redis๋งŒ ์ฆ‰์‹œ ๋ฐ˜์˜)
incrementViewCountByBucketTime(productId, bucketTimeKey);
log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}",
productId, userId, event.getEventId());
// ๋žญํ‚น ์ ์ˆ˜ ์ถ”๊ฐ€
productRankingService.addViewScore(productId);
// ์กฐํšŒ์ˆ˜ ์ฆ๊ฐ€
productMetricsService.incrementViewCount(productId);
// ์ƒํ’ˆ ์บ์‹œ ์‚ญ์ œ
evictProductCache(productId);
log.info("ProductViewed metrics updated: productId={}, eventId={}",
log.info("ProductViewed metrics and ranking updated: productId={}, eventId={}",
productId, event.getEventId());
}
private void processProductViewed(JsonNode eventData, EventHandled event) {
Long productId = eventData.get("productId").asLong();
Long userId = eventData.get("userId").asLong();
ZonedDateTime eventTime = event.getEventTime();
LocalDateTime bucketTime = getBucketTime(eventTime);
String bucketTimeKey = getBucketTimeKey(bucketTime);
log.info("Processing ProductViewed event: productId={}, userId={}, eventId={}, eventTime={}, bucketTime={}, bucketTimeKey={}",
productId, userId, event.getEventId(), eventTime, bucketTime, bucketTimeKey);
// eventTime ๊ธฐ์ค€ 10๋ถ„ ๊ฐ„๊ฒฉ ์กฐํšŒ์ˆ˜ ๋ˆ„์  (Redis๋งŒ ์ฆ‰์‹œ ๋ฐ˜์˜)
if (bucketTimeKey != null) {
incrementViewCountByBucketTime(productId, bucketTimeKey);
} else {
log.warn("Skipping bucket increment due to null bucketTimeKey: productId={}, eventId={}",
productId, event.getEventId());
}
// ๋žญํ‚น ์ ์ˆ˜ ์ถ”๊ฐ€
productRankingService.addViewScore(productId);
// ์ƒํ’ˆ ์บ์‹œ ์‚ญ์ œ
evictProductCache(productId);
log.info("ProductViewed metrics and ranking updated: productId={}, eventId={}",
productId, event.getEventId());
}
๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/batch/ViewEventProcessor.java
around lines 84 to 105, bucketTimeKey can be null when event.getEventTime() is
null which produces Redis keys like product_views:123:null; add a null-check
after computing bucketTime/bucketTimeKey: if eventTime or bucketTimeKey is null,
log a warning including productId and eventId and return early (skip
incrementViewCountByBucketTime, productRankingService.addViewScore, and
evictProductCache) to avoid creating malformed Redis keys; alternatively, if
business rules allow, compute bucketTime from ZonedDateTime.now() before
creating the key โ€” ensure you choose one behavior and implement the guard and
log accordingly.

Comment on lines 13 to 20
@Table(name = "product_metrics",
indexes = {
@Index(name = "idx_product_bucket", columnList = "productId, bucketTime"),
@Index(name = "idx_bucket_time", columnList = "bucketTime")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTime"})
})
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

์‹ฌ๊ฐํ•œ ๋งคํ•‘ ์˜ค๋ฅ˜: ์ปฌ๋Ÿผ๋ช… ๋ถˆ์ผ์น˜

ํ…Œ์ด๋ธ” ์ •์˜์—์„œ ์ธ๋ฑ์Šค์™€ ์œ ๋‹ˆํฌ ์ œ์•ฝ์กฐ๊ฑด์ด bucketTime ์ปฌ๋Ÿผ์„ ์ฐธ์กฐํ•˜๊ณ  ์žˆ์ง€๋งŒ, ์‹ค์ œ ์—”ํ‹ฐํ‹ฐ ํ•„๋“œ๋Š” bucketTimeKey์ž…๋‹ˆ๋‹ค. ์ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฌธ์ œ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค:

  1. ์Šคํ‚ค๋งˆ ์ƒ์„ฑ ์‹คํŒจ: JPA๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” bucketTime ์ปฌ๋Ÿผ์— ๋Œ€ํ•œ ์ œ์•ฝ์กฐ๊ฑด์„ ์ƒ์„ฑํ•˜๋ ค๊ณ  ์‹œ๋„ํ•˜์—ฌ ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค.
  2. ๋Ÿฐํƒ€์ž„ ์˜ค๋ฅ˜: ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹œ์ž‘ ์‹œ ๋˜๋Š” ์ฟผ๋ฆฌ ์‹คํ–‰ ์‹œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Line 29์˜ ํ•„๋“œ๋ช…์ด bucketTimeKey์ด๋ฏ€๋กœ, ํ…Œ์ด๋ธ” ์ •์˜์˜ ๋ชจ๋“  bucketTime ์ฐธ์กฐ๋ฅผ bucketTimeKey๋กœ ์ˆ˜์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆํ•˜๋Š” ์ˆ˜์ •์‚ฌํ•ญ
 @Table(name = "product_metrics",
     indexes = {
-        @Index(name = "idx_product_bucket", columnList = "productId, bucketTime"),
-        @Index(name = "idx_bucket_time", columnList = "bucketTime")
+        @Index(name = "idx_product_bucket", columnList = "productId, bucketTimeKey"),
+        @Index(name = "idx_bucket_time", columnList = "bucketTimeKey")
     },
     uniqueConstraints = {
-        @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTime"})
+        @UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTimeKey"})
     })
๐Ÿ“ 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
@Table(name = "product_metrics",
indexes = {
@Index(name = "idx_product_bucket", columnList = "productId, bucketTime"),
@Index(name = "idx_bucket_time", columnList = "bucketTime")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTime"})
})
@Table(name = "product_metrics",
indexes = {
@Index(name = "idx_product_bucket", columnList = "productId, bucketTimeKey"),
@Index(name = "idx_bucket_time", columnList = "bucketTimeKey")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_product_bucket", columnNames = {"productId", "bucketTimeKey"})
})
๐Ÿค– Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
around lines 13 to 20, the @Table annotation references a non-existent column
name "bucketTime" in the indexes and uniqueConstraints while the entity field is
named "bucketTimeKey"; update all occurrences of "bucketTime" in the
@Index(columnList=...) and @UniqueConstraint(columnNames=...) to "bucketTimeKey"
so the JPA mappings match the actual field/column name and the
schema/constraints are generated correctly.

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