[Volume-10] Spring Batch 랭킹 집계 구현 - #424
Conversation
📝 Walkthrough변경 목적: Spring Batch로 주간·월간 TOP 100 랭킹을 사전 집계하고 기간별 조회 API를 제공한다. Walkthrough주간·월간 랭킹을 Spring Batch로 집계해 조회용 테이블에 저장하고, 랭킹 API가 Changes랭킹 데이터 계약과 점수 정책
랭킹 집계 Job과 원자적 재적재
기간별 랭킹 API 라우팅
배치 랭킹 설계 문서
Sequence Diagram(s)sequenceDiagram
participant Client
participant RankingV1Controller
participant RankingFacade
participant RedisZSET
participant RankingMV
Client->>RankingV1Controller: period와 페이지 조건으로 랭킹 요청
RankingV1Controller->>RankingFacade: RankPeriod와 날짜 전달
alt DAILY
RankingFacade->>RedisZSET: 기존 일간 랭킹 조회
else WEEKLY 또는 MONTHLY
RankingFacade->>RankingMV: 저장된 순위순 랭킹 조회
end
RankingFacade-->>Client: 상품 정보가 포함된 랭킹 응답
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 Checkov (3.3.8)apps/commerce-batch/src/main/resources/application.ymlTraceback (most recent call last): 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.docs/Note/10주차 정리.md:
- Around line 23-25: 문서의 모든 언어 미지정 코드 펜스를 찾아 적절한 언어 태그를 추가하세요. 일반 텍스트 블록은 text를
사용하고 다이어그램 블록은 mermaid를 사용하며, 언급된 모든 펜스에 일관되게 적용한 뒤 Markdown lint가 통과하도록 확인하세요.
In @.docs/Note/PRNote.md:
- Line 25: Update .docs/Note/PRNote.md:25 to define and enforce the chunk-size
invariant in code or deployment validation rather than relying on the documented
value, and retain the expected behavior of preserving the last valid dataset on
empty or failed writes. Update .docs/Note/TechNote.md:63-69 to document an
atomic clear-and-fill strategy that remains valid when chunk settings change,
including an intermediate-failure test confirming the previous dataset is
preserved.
- Line 42: Separate observability from data preservation in the documentation:
update .docs/Note/PRNote.md at lines 42-42 to describe the 오류 로그 as reporting
failure detection and that the previous 판 remains, rather than claiming it
blocks stale or empty API responses; update .docs/Note/TechNote.md at lines
85-85 similarly so logging is described as observation/alerting, while rollback
or other data-handling behavior is documented separately as the mechanism
responsible for preserving data and preventing invalid responses.
- Around line 43-44: 동시 실행 시 일반 SELECT가 동일한 product_metrics 스냅샷을 읽어 오래된 결과가 최종
판에 남을 수 있다는 점을 반영해야 한다. .docs/Note/PRNote.md 43-44와 .docs/Note/TechNote.md 86에서
테이블 잠금만으로 최종 결과가 정상이라고 단정하지 말고, 원본 변경 감지 또는 세대 검증을 포함한 신선도 보장 정책과 운영 책임을 명시하라.
또한 동시 배치 실행에서 최신 판이 커밋되는 동작을 검증하는 테스트를 추가하라.
In @.docs/requirements.md:
- Around line 27-29: 요구사항의 product_metrics 데이터 계약을 명확히 하세요. 날짜별 스냅샷과 주간·월간 기간
조건을 추가해 실제 기간별 집계가 가능하도록 정의하거나, 날짜 차원이 없는 누적 스냅샷 기반 범위 제한임을 명시하세요. 또한 서로 다른 날짜의
입력에 따라 주간·월간 결과가 달라지는 인수 테스트를 추가하세요.
In
`@apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingFacade.java`:
- Around line 35-43: Validate RankingV1Controller’s page and size request
parameters with `@Min`(1), and add equivalent validation at the
RankingFacade.getRankings entry point before constructing PageRequest values.
Ensure page=0, size=0, and negative inputs return 400 with the standard error
format, and add E2E coverage for each case.
In
`@apps/commerce-api/src/test/java/com/loopers/ranking/interfaces/RankingPeriodV1ApiE2ETest.java`:
- Around line 50-105: Extend the RankingPeriodV1ApiE2ETest coverage with a
Monthly nested test path using the monthly repository/model symbols, inserting
distinct monthly ranks and asserting the period=monthly response preserves rank
order and product details. Add a separate isolation case with different weekly
and monthly ranking data, verifying each period returns only its own MV records
and never mixes results across periods.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankAggregationJobConfig.java`:
- Around line 74-78: Update the SQL built in RankAggregationJobConfig so the
ranking ORDER BY uses product_id ASC as a deterministic secondary key after the
existing score descending order. Add an integration test that re-aggregates two
products with tied scores and verifies they are persisted in the same rank
order.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java`:
- Around line 55-60: Update the FAILED branch in JobListener to store
getAllFailureExceptions() once and log failures as Throwable arguments rather
than interpolating the list. Preserve the job name and failure message; handle
empty failures without an exception, log a single failure directly, and emit
separate logs for multiple failures. Add or update a test verifying that the
failure exception is attached to the logger call.
In
`@apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingWeightProperties.java`:
- Around line 10-12: Update the compact constructor of RankingWeightProperties
to validate view, like, and order at creation time, rejecting any negative, NaN,
or infinite value while accepting finite values greater than or equal to zero.
Add tests verifying invalid configuration values are rejected before application
startup.
In
`@apps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobRollbackTest.java`:
- Around line 72-89: Update keepsExistingMv_whenAggregationFails to inject
failure after RankMvItemWriter performs its DELETE, using a test-only writer
hook or EntityManager spy that throws during persist. Ensure the job reaches the
writer, fails after deletion within the transaction, and assert the original
weekly MV row remains unchanged after rollback.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: dd77b205-f024-48d9-8fc5-a703029963ff
📒 Files selected for processing (36)
.docs/Note/10주차 정리.md.docs/Note/PRNote.md.docs/Note/TechNote.md.docs/requirements.mdapps/commerce-api/src/main/java/com/loopers/ranking/application/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/ranking/domain/MonthlyProductRankModel.javaapps/commerce-api/src/main/java/com/loopers/ranking/domain/ProductRankModel.javaapps/commerce-api/src/main/java/com/loopers/ranking/domain/RankPeriod.javaapps/commerce-api/src/main/java/com/loopers/ranking/domain/WeeklyProductRankModel.javaapps/commerce-api/src/main/java/com/loopers/ranking/infrastructure/MonthlyProductRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/ranking/infrastructure/WeeklyProductRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/ranking/interfaces/RankingV1Controller.javaapps/commerce-api/src/test/java/com/loopers/ranking/application/RankingFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/ranking/interfaces/RankingPeriodV1ApiE2ETest.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricRow.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankAggregationJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankItemProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankMvItemWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.javaapps/commerce-batch/src/main/java/com/loopers/metrics/domain/ProductMetricsModel.javaapps/commerce-batch/src/main/java/com/loopers/metrics/infrastructure/ProductMetricsJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/ranking/domain/MonthlyProductRankModel.javaapps/commerce-batch/src/main/java/com/loopers/ranking/domain/ProductRankModel.javaapps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankPeriod.javaapps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingScorePolicy.javaapps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingWeightProperties.javaapps/commerce-batch/src/main/java/com/loopers/ranking/domain/WeeklyProductRankModel.javaapps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/MonthlyProductRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/WeeklyProductRankJpaRepository.javaapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/RankItemProcessorTest.javaapps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobIntegrationTest.javaapps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobRollbackTest.javaapps/commerce-batch/src/test/java/com/loopers/ranking/domain/ProductRankModelTest.javaapps/commerce-batch/src/test/java/com/loopers/ranking/domain/RankPeriodTest.javaapps/commerce-batch/src/test/java/com/loopers/ranking/domain/RankingScorePolicyTest.java
| ``` | ||
| 실시간(Redis) : 이벤트 1건 → 점수 조금 더함 (증분, 항상 최신) | ||
| 배치(Batch) : 하루치 집계를 한 번에 다시 계산 → 판을 새로 구움 (주기적, 무거움) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
코드 펜스에 언어 태그를 추가해야 한다.
현재 펜스에 언어가 없어 markdownlint-cli2의 MD040 경고가 발생하고, 문서 검증 단계가 실패할 수 있다. 일반 텍스트 블록에는 text, 다이어그램에는 mermaid 등 적절한 태그를 추가하고 CI에서 Markdown lint를 통과하는지 확인해야 한다.
Also applies to: 32-38, 66-69, 117-119, 124-130, 137-143, 177-189
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 23-23: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.docs/Note/10주차 정리.md around lines 23 - 25, 문서의 모든 언어 미지정 코드 펜스를 찾아 적절한 언어
태그를 추가하세요. 일반 텍스트 블록은 text를 사용하고 다이어그램 블록은 mermaid를 사용하며, 언급된 모든 펜스에 일관되게 적용한 뒤
Markdown lint가 통과하도록 확인하세요.
Source: Linters/SAST tools
| - B는 비우기가 **먼저 확정(커밋)**되므로, 채우기가 도중에 실패하면 테이블이 **다음 성공 때까지 계속 빈 채로** 남는다. 그 사이 조회가 오면 빈 랭킹을 받는다. "다시 만드는 순간 잠깐 빔"이 아니라 "실패하면 계속 빔"이 진짜 위험이다. | ||
| - A는 비우기와 채우기를 한 트랜잭션에 묶어 **도중에 실패하면 통째로 되돌려져 이전 판이 그대로 살아남는다.** 게다가 비우기는 커밋 전까지 다른 조회에 보이지 않아, 조회가 **비었거나 반쯤 채워진 판을 볼 틈 자체가 사라진다.** | ||
| - C는 조회를 아예 안 멈추지만 테이블 두 벌과 이름 바꾸기가 필요하다. 상위 100개 재적재는 워낙 짧아 조회를 막을 일이 없으니 **A로 충분하고 C는 과한 투자**다 — 데이터가 커져 다시 만드는 시간이 조회를 막을 만큼 길어지면 그때 C로 올리면 된다. | ||
| - 전제: 한 번에 끊어 처리하는 크기(100)가 상위 개수(100)와 같아 **한 묶음=한 트랜잭션**이 보장된다. 솔직한 대가: 원본이 비면 저장이 아예 호출되지 않아 비우기도 안 일어난다 → **새로 만들 게 없으면 마지막 정상 판을 유지**한다 — 의도한 동작이다. (`RankMvItemWriter`) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
chunk 크기 의존성을 원자성 보장으로 사용하지 않아야 한다.
두 문서 모두 clear+fill 원자성을 chunk 크기 설정에 의존한다. 설정 변경 시 부분 커밋이 발생할 수 있으므로, 실행 시점 검증이나 단일 트랜잭션 전략을 명시하고 중간 실패 테스트로 기존 판 보존을 확인해야 한다.
.docs/Note/PRNote.md#L25-L25: chunk size가 100 이상이라는 불변식을 코드 또는 배포 검증으로 강제한다..docs/Note/TechNote.md#L63-L69: 설정 변경 시에도 원자성이 유지되는 대안을 문서화한다.
📍 Affects 2 files
.docs/Note/PRNote.md#L25-L25(this comment).docs/Note/TechNote.md#L63-L69
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.docs/Note/PRNote.md at line 25, Update .docs/Note/PRNote.md:25 to define
and enforce the chunk-size invariant in code or deployment validation rather
than relying on the documented value, and retain the expected behavior of
preserving the last valid dataset on empty or failed writes. Update
.docs/Note/TechNote.md:63-69 to document an atomic clear-and-fill strategy that
remains valid when chunk settings change, including an intermediate-failure test
confirming the previous dataset is preserved.
|
|
||
| **[결정 3] 실패와 동시 실행 — 실패는 눈에 띄게, 동시 실행 방지는 넣지 않음** | ||
|
|
||
| - **실패 알림은 넣었다:** `JobListener`가 배치 실패를 감지하면 완료 로그와 별개로 **오류 로그로 "이전 판 유지됨 + 원인"** 을 남긴다. 실패가 완료 로그에 묻혀 낡거나 빈 판이 방치되고, 조회가 그걸 정상(200)처럼 반환하는 걸 막는다. |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
오류 로그와 데이터 보존의 책임을 분리해야 한다.
실패 로그는 관측을 제공하지만 이전 판 보존이나 낡은 판의 API 반환 차단을 보장하지 않는다. 롤백은 데이터 보존 수단으로, 로그·알람은 탐지 수단으로 기술하고 각각의 동작을 검증해야 한다.
.docs/Note/PRNote.md#L42-L42: “막는다” 대신 실패 탐지와 이전 판 유지 사실의 알림으로 표현한다..docs/Note/TechNote.md#L85-L85: 로그가 정상 응답을 차단하는 것처럼 보이는 문장을 관측 중심으로 수정한다.
📍 Affects 2 files
.docs/Note/PRNote.md#L42-L42(this comment).docs/Note/TechNote.md#L85-L85
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.docs/Note/PRNote.md at line 42, Separate observability from data
preservation in the documentation: update .docs/Note/PRNote.md at lines 42-42 to
describe the 오류 로그 as reporting failure detection and that the previous 판
remains, rather than claiming it blocks stale or empty API responses; update
.docs/Note/TechNote.md at lines 85-85 similarly so logging is described as
observation/alerting, while rollback or other data-handling behavior is
documented separately as the mechanism responsible for preserving data and
preventing invalid responses.
| - **동시 실행 방지는 뺐다:** 여러 인스턴스가 동시에 못 돌게 막는 잠금(ShedLock 등)을 넣지 않았다. 이 배치는 **정해진 시각에 스스로 도는 스케줄러가 없고, 밖에서 한 번씩 실행**되는 방식이라 잠금을 걸 지점이 없다. 동시 실행 통제는 **실행을 관리하는 쪽 몫**(예: 쿠버네티스가 "이미 돌면 새로 안 띄움"으로 설정)이고, 결정 1(한 트랜잭션 재적재) 덕에 설령 겹쳐 돌아도 테이블 잠금에 밀려 차례로 처리돼 최종 결과는 멀쩡하다. 코드로 막을 문제가 아니라고 봤다. | ||
| - 실행마다 새 이력이 남는 설정이라 배치의 "멈춘 지점부터 재시작" 기능은 포기하지만, 재적재가 **몇 번을 돌려도 결과가 같은 전체 재적재**라 늘 처음부터 다시 만들면 그만이다 — 의도한 선택이다. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## docs snippets"
for f in .docs/Note/PRNote.md .docs/Note/TechNote.md; do
if [ -f "$f" ]; then
echo "--- $f"
nl -ba "$f" | sed -n '30,50p;78,92p'
fi
done
echo
echo "## related search terms"
rg -n "동시 실톱|동시 실행|shedlock|lock|새로고침|재적재|트랜잭션|분리 수준|Isolation|select |for update|락|잠금|stale|세션|테이블" .docs/Note . --glob '!**/PRNote.md' --glob '!**/TechNote.md' | head -200Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 258
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## docs snippets"
for f in .docs/Note/PRNote.md .docs/Note/TechNote.md; do
if [ -f "$f" ]; then
echo "--- $f"
nl -ba "$f" | sed -n '30,50p;78,92p'
fi
done
echo
echo "## related search terms"
rg -n "동시 실행|shedor|shadlock|lock|새로고침|재적재|트랜잭션|분리 수준|Isolation|select |for update|락|잠금|stale|세션|테이블" .docs/Note . --glob '!**/PRNote.md' --glob '!**/TechNote.md' | head -200Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 258
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## docs snippets"
for f in .docs/Note/PRNote.md .docs/Note/TechNote.md; do
if [ -f "$f" ]; then
echo "--- $f"
awk '{printf "%d\t%s\n", NR, $0}' "$f" | sed -n '30,50{p};78,92{p}'
fi
done
echo
echo "## related search terms"
rg -n "동시 실행|shedor|shedlock|lock|새로고침|재적재|트랜잭션|분리 수준|Isolation|select |for update|락|잠금|stale|세션|테이블" .docs/Note . --glob '!**/PRNote.md' --glob '!**/TechNote.md' | head -200 || true
echo
echo "## implementation hints"
rg -n "Transactional|FOR UPDATE|SELECT .*FOR UPDATE|Isolation|`@Transactional`|retry|batch|배치|재적재|Stale|Stale|StaleObject|stale" -S --glob '!**/build/**' --glob '!**/target/**' --glob '!**/dist/**' . | head -300 || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 50397
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## tracked source files of interest"
git ls-files | rg -n "(RankItem|Reader|Mapper|Repository|Service|Job|Batch|Step|Entity).*Rank|.*Rank.*|.*Ranking.*|rank" | head -200 || true
echo
echo "## relevant annotations/types without running repo code"
rg -n "class RankItem|interface RankItem|record RankItem|enum RankItem|class .*Rank.*(Processor|Reader|Repository|Mapper|Service)|interface .*Rank.*(Repository|Mapper|Service)|rankNo|rank_no|product_metrics|mv_product_rank|FOR UPDATE|ForUpdate|`@Transactional`|Insertable|Isolation|StaleObject|Version|version" -S --glob '!**/*.html' --glob '!**/*.yml' --glob '!**/*.yaml' --glob '!**/build/**' --glob '!**/target/**' --glob '!**/dist/**' . | head -300 || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 24359
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## RankAggregationJobConfig"
awk '{printf "%d\t%s\n", NR, $0}' apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankAggregationJobConfig.java | sed -n '1,140p'
echo
echo "## RankMvItemWriter"
awk '{printf "%d\t%s\n", NR, $0}' apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankMvItemWriter.java | sed -n '1,180p'
echo
echo "## ProductRankModel JPA mapping"
awk '{printf "%d\t%s\n", NR, $0}' apps/commerce-batch/src/main/java/com/loopers/ranking/domain/ProductRankModel.java | sed -n '1,120p'
for f in \
apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/WeeklyProductRankJpaRepository.java \
apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/MonthlyProductRankJpaRepository.java \
apps/commerce-batch/src/main/java/com/loopers/ranking/domain/WeeklyProductRankModel.java \
apps/commerce-batch/src/main/java/com/loopers/ranking/domain/MonthlyProductRankModel.java; do
if [ -f "$f" ]; then
echo "--- $f"
awk '{printf "%d\t%s\n", NR, $0}' "$f" | sed -n '1,120p'
fi
done
echo
echo "## design doc relevant section"
awk '{printf "%d\t%s\n", NR, $0}' .docs/design/10-ranking-consistency.md | sed -n '1,180p'Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 15849
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## DB config references"
rg -n "auto-ddl|ddl-auto|hibernate|transaction.*isolation|default.*isolation|isolation.*level|read-committed|REPEATABLE|SERIALIZ" -S --glob '!**/*.html' --glob '!**/*.md' --glob '!**/build/**' --glob '!**/target/**' . | head -200 || true
echo
echo "## batch test relevant coverage"
awk '{printf "%d\t%s\n", NR, $0}' apps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobIntegrationTest.java | sed -n '1,180p'
awk '{printf "%d\t%s\n", NR, $0}' apps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobRollbackTest.java | sed -n '1,180p'
echo
echo "## ProductRankModel BaseEntity"
awk '{printf "%d\t%s\n", NR, $0}' apps/commerce-batch/src/main/java/com/loopers/domain/BaseEntity.java 2>/dev/null || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 11475
동시 실행 시 최종 판의 데이터 신선도를 보장해서는 안 된다.
두 문서의 설명은 product_metrics 읽기에서 잠금이 없다는 사실을 무시한다. Reader가 일반 SELECT라면 두 배치가 겹쳐도 같은 원본 스냅샷을 읽을 수 있으므로, DB 격리 수준에 의존한 뒤이어 커밋의 최종 상태와 같은 “테이블 잠금 → 차례 처리 → 결과 멀쩡” 단정은 운영 중 중복 배포 등으로 잘못된 판이 조회에 남을 수 있다. 실행 관리 주체만 강조하지 말고, 원본 변경 감지나 세대 검증과 같은 신선도 보장 정책을 문서에 보완하고, 동시 실행으로 최신 판이 커밋됨을 보장하는 테스트를 추가한다.
📍 Affects 2 files
.docs/Note/PRNote.md#L43-L44(this comment).docs/Note/TechNote.md#L86-L86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.docs/Note/PRNote.md around lines 43 - 44, 동시 실행 시 일반 SELECT가 동일한
product_metrics 스냅샷을 읽어 오래된 결과가 최종 판에 남을 수 있다는 점을 반영해야 한다. .docs/Note/PRNote.md
43-44와 .docs/Note/TechNote.md 86에서 테이블 잠금만으로 최종 결과가 정상이라고 단정하지 말고, 원본 변경 감지 또는
세대 검증을 포함한 신선도 보장 정책과 운영 책임을 명시하라. 또한 동시 배치 실행에서 최신 판이 커밋되는 동작을 검증하는 테스트를 추가하라.
| - 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다. | ||
| - 대상 테이블 : `product_metrics` | ||
| - Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
주간·월간 집계의 데이터 계약을 명확히 해야 한다.
이 요구사항은 product_metrics를 “하루치 메트릭”으로 설명하지만, .docs/Note/10주차 정리.md와 .docs/Note/TechNote.md에서는 상품별 누적 단일 행이며 날짜 차원이 없다고 명시한다. 현재 구조로는 실제 주간·월간 집계가 불가능해 두 기간이 현재 누적치의 재정렬로 굳을 수 있고, 잘못된 랭킹을 정상 결과로 승인할 위험이 있다. 날짜별 스냅샷과 기간 조건을 요구사항에 추가하거나, 현재 구현을 누적 스냅샷 기반의 범위 제한으로 명시해야 한다. 서로 다른 날짜의 입력을 넣었을 때 주간·월간 결과가 달라지는 인수 테스트도 추가해야 한다.
Also applies to: 37-39
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.docs/requirements.md around lines 27 - 29, 요구사항의 product_metrics 데이터 계약을
명확히 하세요. 날짜별 스냅샷과 주간·월간 기간 조건을 추가해 실제 기간별 집계가 가능하도록 정의하거나, 날짜 차원이 없는 누적 스냅샷 기반
범위 제한임을 명시하세요. 또한 서로 다른 날짜의 입력에 따라 주간·월간 결과가 달라지는 인수 테스트를 추가하세요.
| @DisplayName("GET /api/v1/rankings?period=weekly") | ||
| @Nested | ||
| class Weekly { | ||
|
|
||
| @DisplayName("주간 MV에 적재된 랭킹을 저장된 rank 순서로 상품정보와 함께 반환한다.") | ||
| @Test | ||
| void returnsWeeklyRanking_fromMv() { | ||
| // arrange | ||
| ProductModel a = productJpaRepository.save(new ProductModel("에어맥스", "나이키 운동화", 150000L, null)); | ||
| ProductModel b = productJpaRepository.save(new ProductModel("조던1", "나이키 농구화", 200000L, null)); | ||
| weeklyRepository.save(new WeeklyProductRankModel(b.getId(), 1, 9.0)); | ||
| weeklyRepository.save(new WeeklyProductRankModel(a.getId(), 2, 3.0)); | ||
|
|
||
| // act | ||
| ResponseEntity<ApiResponse<List<RankingV1Dto.RankingResponse>>> response = | ||
| testRestTemplate.exchange("/api/v1/rankings?period=weekly&page=1&size=20", | ||
| HttpMethod.GET, new HttpEntity<>(null), RESPONSE_TYPE); | ||
|
|
||
| // assert | ||
| List<RankingV1Dto.RankingResponse> data = response.getBody().data(); | ||
| assertAll( | ||
| () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), | ||
| () -> assertThat(data).hasSize(2), | ||
| () -> assertThat(data.get(0).rank()).isEqualTo(1L), | ||
| () -> assertThat(data.get(0).productId()).isEqualTo(b.getId()), | ||
| () -> assertThat(data.get(0).name()).isEqualTo("조던1"), | ||
| () -> assertThat(data.get(1).rank()).isEqualTo(2L), | ||
| () -> assertThat(data.get(1).productId()).isEqualTo(a.getId()) | ||
| ); | ||
| } | ||
|
|
||
| @DisplayName("page/size로 페이징하면 해당 구간(저장된 rank 순)만 반환된다.") | ||
| @Test | ||
| void paginates_byStoredRank() { | ||
| // arrange — rank 1,2,3 | ||
| ProductModel a = productJpaRepository.save(new ProductModel("A", "1위", 1000L, null)); | ||
| ProductModel b = productJpaRepository.save(new ProductModel("B", "2위", 1000L, null)); | ||
| ProductModel c = productJpaRepository.save(new ProductModel("C", "3위", 1000L, null)); | ||
| weeklyRepository.save(new WeeklyProductRankModel(a.getId(), 1, 9.0)); | ||
| weeklyRepository.save(new WeeklyProductRankModel(b.getId(), 2, 5.0)); | ||
| weeklyRepository.save(new WeeklyProductRankModel(c.getId(), 3, 1.0)); | ||
|
|
||
| // act — size=1, page=2 → rank 2 (b) | ||
| ResponseEntity<ApiResponse<List<RankingV1Dto.RankingResponse>>> response = | ||
| testRestTemplate.exchange("/api/v1/rankings?period=weekly&page=2&size=1", | ||
| HttpMethod.GET, new HttpEntity<>(null), RESPONSE_TYPE); | ||
|
|
||
| // assert | ||
| List<RankingV1Dto.RankingResponse> data = response.getBody().data(); | ||
| assertAll( | ||
| () -> assertThat(data).hasSize(1), | ||
| () -> assertThat(data.get(0).productId()).isEqualTo(b.getId()), | ||
| () -> assertThat(data.get(0).rank()).isEqualTo(2L) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
월간 조회 경로의 E2E 검증을 추가해야 한다.
현재는 weekly만 검증하므로 period=monthly가 잘못된 테이블을 조회하거나 빈 결과를 반환해도 회귀를 탐지하지 못한다. 월간 MV에만 서로 구분되는 랭킹을 적재한 뒤 period=monthly 응답을 검증하고, 주간·월간 데이터가 서로 섞이지 않는 추가 케이스도 포함해야 한다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-api/src/test/java/com/loopers/ranking/interfaces/RankingPeriodV1ApiE2ETest.java`
around lines 50 - 105, Extend the RankingPeriodV1ApiE2ETest coverage with a
Monthly nested test path using the monthly repository/model symbols, inserting
distinct monthly ranks and asserting the period=monthly response preserves rank
order and product details. Add a separate isolation case with different weekly
and monthly ranking data, verifying each period returns only its own MV records
and never mixes results across periods.
| String scoreExpr = "view_count * ? + like_count * ? + LOG10(1 + sales_count) * ?"; | ||
| String sql = "SELECT product_id, view_count, like_count, sales_count " | ||
| + "FROM product_metrics " | ||
| + "ORDER BY (" + scoreExpr + ") DESC " | ||
| + "LIMIT " + TOP_N; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
동점 점수의 정렬 기준을 고정해야 한다.
점수가 같은 행은 현재 순서가 보장되지 않아 재집계마다 저장된 rank가 바뀔 수 있다. ORDER BY ... DESC, product_id ASC 같은 2차 키를 추가하고, 동점 두 건을 재집계해 같은 순서로 적재되는 통합 테스트를 추가해야 한다.
수정안
- + "ORDER BY (" + scoreExpr + ") DESC "
+ + "ORDER BY (" + scoreExpr + ") DESC, product_id ASC "📝 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.
| String scoreExpr = "view_count * ? + like_count * ? + LOG10(1 + sales_count) * ?"; | |
| String sql = "SELECT product_id, view_count, like_count, sales_count " | |
| + "FROM product_metrics " | |
| + "ORDER BY (" + scoreExpr + ") DESC " | |
| + "LIMIT " + TOP_N; | |
| String scoreExpr = "view_count * ? + like_count * ? + LOG10(1 + sales_count) * ?"; | |
| String sql = "SELECT product_id, view_count, like_count, sales_count " | |
| "FROM product_metrics " | |
| "ORDER BY (" + scoreExpr + ") DESC, product_id ASC " | |
| "LIMIT " + TOP_N; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankAggregationJobConfig.java`
around lines 74 - 78, Update the SQL built in RankAggregationJobConfig so the
ranking ORDER BY uses product_id ASC as a deterministic secondary key after the
existing score descending order. Add an integration test that re-aggregates two
products with tied scores and verifies they are persisted in the same rank
order.
| if (jobExecution.getStatus() == BatchStatus.FAILED) { | ||
| log.error( | ||
| "Job '{}' 실패 — 이전 MV가 유지됩니다(재적재 미반영). 원인: {}", | ||
| jobExecution.getJobInstance().getJobName(), | ||
| jobExecution.getAllFailureExceptions() | ||
| ); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 프로젝트가 사용하는 SLF4J/Logback 구성을 확인한다.
fd -a 'build.gradle*' . -x rg -n -C2 'slf4j|logback|spring-boot' {}Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 3486
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate JobListener =="
fd -a 'JobListener\.java$' .
echo "== file and relevant section =="
file="$(fd 'JobListener\.java$' . | head -n1)"
wc -l "$file" || true
sed -n '1,120p' "$file" | cat -n
echo "== imports used in JobListener =="
rg -n "import (org\.slf4j|org\.springframework\.batch|^import [^\n]+Logger)" "$file" || true
echo "== logging patterns around listener and failures =="
rg -n "log\.error|getAllFailureExceptions|getAllFailureExceptions\(\)" .Repository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 4830
🌐 Web query:
SLF4J/Logback multiple exception placeholders list Throwable cause stack trace formatting
💡 Result:
In SLF4J, logging statements support a single Throwable object as the last argument, which is automatically treated as an exception rather than a standard message parameter [1][2]. It is not possible to pass multiple Throwable objects as distinct parameters in a single logging call; only the final argument is inspected for exception status [1][3]. If you need to log multiple exceptions, you must either: 1. Log them in separate statements. 2. Chain or wrap them into a single exception (using standard Java Throwable chaining, e.g., new Exception("Msg", cause)). 3. Convert the secondary exceptions to strings and pass them as standard arguments (though this loses stack trace formatting). For Logback configuration, stack trace formatting is controlled via the PatternLayout in your logback.xml file using specific conversion words [4]. Key configuration options include: - %ex{full} or %throwable{full}: Outputs the full stack trace (default) [4]. - %ex{short}: Prints only the first line of the stack trace [4]. - %ex{n}: Prints the first n lines of the stack trace [4]. - %xEx: Outputs extended stack trace information, including packaging data [5]. - %rEx: Outputs the stack trace with the root cause first [5]. If you do not explicitly include a throwable conversion word in your pattern, Logback will automatically append it to the end of the log message to ensure stack traces are not lost [4][6]. To disable this automatic output, you can use the %nopex conversion word in your pattern [4].
Citations:
- 1: https://www.baeldung.com/slf4j-log-exceptions
- 2: https://stackoverflow.com/questions/6371638/how-to-log-formatted-message-object-array-exception
- 3: https://www.slf4j.org/apidocs/org/slf4j/helpers/MessageFormatter.html
- 4: https://logback.qos.ch/manual/layouts.html
- 5: https://github.com/qos-ch/logback/blob/master/logback-classic/src/main/java/ch/qos/logback/classic/PatternLayout.java
- 6: http://dev.cs.ovgu.de/java/logback-0.9.8/apidocs/ch/qos/logback/classic/PatternLayout.html
배치 실패 원인을 Throwable로 첨부하도록 분리해 기록한다.
List<FailureException>을 두 번째 위치에서 {}로 치환하면 첫 예외의 스택 트레이스도 로그 패턴에 그대로 남지 않는다. 실패 예외가 하나뿐이면 log.error(..., failures.get(0))로Throwable로 첨부하고, 예외가 여러 개일 때는 각각 별도 로그로 분리해 원인을 보존할 수 있다. 운영에서 getAllFailureExceptions()로 확인해야 하는 장애 원인 추적을 가능하게 해야 한다.
추천 수정:
var failures = jobExecution.getAllFailureExceptions();
if (failures.isEmpty()) {
log.error("Job '{}' 실패 — 이전 MV가 유지됩니다(재적재 미반영).",
jobExecution.getJobInstance().getJobName());
} else if (failures.size() == 1) {
log.error("Job '{}' 실패 — 이전 MV가 유지됩니다(재적재 미반영).",
jobExecution.getJobInstance().getJobName(),
failures.get(0));
} else {
failures.forEach(failure -> log.error(
"Job '{}' 실패 — 이전 MV가 유지됩니다(재적재 미반영).",
jobExecution.getJobInstance().getJobName(),
failure
));
}이 경로에서 예외가 실제로 첨부되는지 확인하는 테스트도 추가해야 한다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java`
around lines 55 - 60, Update the FAILED branch in JobListener to store
getAllFailureExceptions() once and log failures as Throwable arguments rather
than interpolating the list. Preserve the job name and failure message; handle
empty failures without an exception, log a single failure directly, and emit
separate logs for multiple failures. Add or update a test verifying that the
failure exception is attached to the logger call.
| @ConfigurationProperties("ranking.weight") | ||
| public record RankingWeightProperties(double view, double like, double order) { | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Locate target file"
fd -a 'RankingWeightProperties.java|ranking' . | sed 's#^\./##' | head -100
echo
echo "Target file"
cat -n apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingWeightProperties.java
echo
echo "Usages of RankingWeightProperties"
rg -n "RankingWeightProperties|ranking\.weight|getOrder\(|getLike\(|getView\(|view|like|order" apps/commerce-batch/src/main/java/apps -g '*.java' | head -200 || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 1714
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Usages of RankingWeightProperties and ranking.weight in relevant module"
rg -n "RankingWeightProperties|ranking\.weight|\.view|\.like|\.order|order\(|like\(|view\(" apps/commerce-batch/src/main/java apps/commerce-batch/src/test/java apps/commerce-streamer/src/main/java apps/commerce-streamer/src/test/java -g '*.java' || true
echo
echo "YML properties examples"
rg -n "ranking:" apps -g 'application*.ya?ml' || true
echo
echo "Behavioral probe: Java record compact constructor availability for double fields"
python3 - <<'PY'
from pathlib import Path
p = Path("apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingWeightProperties.java")
text = p.read_text()
print("has compact_constructor=", "{ RankingWeightProperties" in text)
print("field_type=", "double view, double like, double order" in text)
PYRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 12009
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "target config file"
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankAggregationJobConfig.java
echo
echo "related test"
cat -n apps/commerce-batch/src/test/java/com/loopers/ranking/domain/RankingScorePolicyTest.java | sed -n '1,120p'
echo
echo "score policy"
cat -n apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingScorePolicy.java
echo
echo "check Java compiler version if available"
command -v javac || true; javac -version 2>&1 || true; command -v java || true; java -version 2>&1 || true
grep -R '^java\s*$\|sourceCompatibility\|targetCompatibility\|org.java-version\|java-toolchain' . -g 'build.gradle*' -g '*.gradle' -g 'gradle.properties' -g '*.properties' 2>/dev/null || trueRepository: loopers-labs/loop-pack-be-l2-vol4-java
Length of output: 9065
랭킹 가중치는 생성 시점에 유한한 0 이상 값으로 제한해야 한다.
현재는 설정 실수로 음수·NaN·Infinity 값이 바인딩되어 SQL 정렬 방향이 뒤집히거나 LOG10 항 곱이 비정상 점수로 확산될 수 있다. record compact constructor에서 view, like, order가 모두 유한한 0 이상 값인지 검증하도록 바꾸고, 음수/NaN/Infinity 설정이 애플리케이션 기동 전에 거부되는 테스트를 추가해야 한다. ``
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingWeightProperties.java`
around lines 10 - 12, Update the compact constructor of RankingWeightProperties
to validate view, like, and order at creation time, rejecting any negative, NaN,
or infinite value while accepting finite values greater than or equal to zero.
Add tests verifying invalid configuration values are rejected before application
startup.
Source: Path instructions
| @DisplayName("집계 중 예외가 발생하면 Job은 실패하고, 기존 MV는 롤백되어 그대로 유지된다.") | ||
| @Test | ||
| void keepsExistingMv_whenAggregationFails() throws Exception { | ||
| // arrange — 기존 MV에 마지막 정상 판 1행, 새로 읽을 원본 1행 | ||
| weeklyRepository.save(new WeeklyProductRankModel(999L, 1, 123.0)); | ||
| productMetricsJpaRepository.save(new ProductMetricsModel(1L, 0, 0, 100)); | ||
| given(rankItemProcessor.process(any())).willThrow(new RuntimeException("집계 중 강제 실패")); | ||
| jobLauncherTestUtils.setJob(job); | ||
|
|
||
| // act | ||
| var execution = jobLauncherTestUtils.launchJob(params("weekly")); | ||
|
|
||
| // assert — Job 실패 + 기존 판이 손상 없이 유지(비우기 미반영) | ||
| assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); | ||
| List<WeeklyProductRankModel> remaining = weeklyRepository.findAllByOrderByRankAsc(); | ||
| assertThat(remaining).hasSize(1); | ||
| assertThat(remaining.get(0).getProductId()).isEqualTo(999L); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
DELETE 이후 실패하는 롤백 경로를 검증해야 한다.
Line 78의 Processor 예외는 Writer 호출 전에 발생하므로 RankMvItemWriter의 DELETE가 실행되지 않는다. 따라서 이 테스트는 기존 MV가 트랜잭션으로 복구되는지를 검증하지 못한다. 테스트 전용 Writer 훅 또는 EntityManager spy로 DELETE 뒤 persist 실패를 주입하고, 기존 MV 행이 그대로 남는 통합 테스트를 추가해야 한다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobRollbackTest.java`
around lines 72 - 89, Update keepsExistingMv_whenAggregationFails to inject
failure after RankMvItemWriter performs its DELETE, using a test-only writer
hook or EntityManager spy that throws during persist. Ensure the job reaches the
writer, fails after deletion within the transaction, and assert the original
weekly MV row remains unchanged after rollback.
🧭 Context & Decision
문제 정의
product_metrics(MySQL)의 수십만 행을 그 자리에서 합산·정렬할 수 없다. 그래서 무거운 집계는 배치로 미리·한 번에 계산해 조회 전용 테이블에 저장해 두고, 조회는 그 순위표를 순위대로 가볍게 읽기만 한다.product_metrics이고, 조회 전용 테이블은 그것을 조회에 맞게 다시 만든 사본이라 언제든 새로 만들면 된다. 그래서 고민은 "완벽하게 정확한가"가 아니라 "순위표를 다시 만드는 동안 조회를 망가뜨리지 않고, 어긋난 값을 조용히 넘기지 않느냐" 가 된다.?period=daily|weekly|monthly로 조회 대상을 나누되, 기존 daily 조회는 그대로 동작한다.선택지와 결정
[결정 1] 순위표를 다시 만들 때 — 비우기와 채우기를 한 묶음으로 vs 따로 vs 새 테이블 통째 교체
RankMvItemWriter)[결정 2] 순위와 점수가 어긋나는 문제 — 저장 직전 검증 vs 주석만 vs 앱에서 한 번에 계산
RankingScorePolicy로 따로 계산된다. 같은 점수식이 SQL과 코드 두 곳에 살아, 둘이 조금이라도 어긋나면 "1등인데 점수는 2등보다 낮은" 모순이 아무 오류 없이 저장된다.0.000001)다. (RankItemProcessor)[결정 3] 실패와 동시 실행 — 실패는 눈에 띄게, 동시 실행 방지는 넣지 않음
JobListener가 배치 실패를 감지하면 완료 로그와 별개로 오류 로그로 "이전 판 유지됨 + 원인" 을 남긴다. 실패가 완료 로그에 묻혀 낡거나 빈 판이 방치되고, 조회가 그걸 정상(200)처럼 반환하는 걸 막는다.