Skip to content

[Volume-10] Spring Batch 랭킹 집계 구현 - #424

Open
byuns wants to merge 6 commits into
loopers-labs:byunsfrom
byuns:volume-10
Open

[Volume-10] Spring Batch 랭킹 집계 구현#424
byuns wants to merge 6 commits into
loopers-labs:byunsfrom
byuns:volume-10

Conversation

@byuns

@byuns byuns commented Jul 23, 2026

Copy link
Copy Markdown

🧭 Context & Decision

문제 정의

  • 지금까지: 일간·시간 랭킹은 이벤트가 올 때마다 Redis 점수판을 조금씩 갱신하는 실시간 방식이었다. 하지만 주간·월간은 매 조회마다 product_metrics(MySQL)의 수십만 행을 그 자리에서 합산·정렬할 수 없다. 그래서 무거운 집계는 배치로 미리·한 번에 계산해 조회 전용 테이블에 저장해 두고, 조회는 그 순위표를 순위대로 가볍게 읽기만 한다.
  • 전제: 진짜 원본은 product_metrics이고, 조회 전용 테이블은 그것을 조회에 맞게 다시 만든 사본이라 언제든 새로 만들면 된다. 그래서 고민은 "완벽하게 정확한가"가 아니라 "순위표를 다시 만드는 동안 조회를 망가뜨리지 않고, 어긋난 값을 조용히 넘기지 않느냐" 가 된다.
  • 성공 기준: (1) 주간·월간 상위 100개를 미리 계산해, 조회 때 조인·집계 없이 순위대로 페이지만 읽는다 (2) 배치가 여러 번 돌거나 중간에 죽어도 조회가 비어 있거나 반쯤 채워졌거나 앞뒤가 안 맞는 판을 보지 않는다 (3) ?period=daily|weekly|monthly로 조회 대상을 나누되, 기존 daily 조회는 그대로 동작한다.

선택지와 결정

[결정 1] 순위표를 다시 만들 때 — 비우기와 채우기를 한 묶음으로 vs 따로 vs 새 테이블 통째 교체

  • 고려한 방법:
    • A (비우기+채우기를 한 트랜잭션에): 집계 단계 하나만 두고, 저장이 첫 저장 때 대상 테이블을 비운 뒤 새 순위를 넣는다 → 비우기와 채우기가 같은 트랜잭션
    • B (비우기 단계 따로 → 집계 단계): 비우기와 채우기를 서로 다른 트랜잭션으로 나눈다
    • C (새 테이블 통째 교체): 새 테이블에 다 채운 뒤 이름 바꾸기로 한 번에 갈아 끼운다
  • 결정: A
  • 트레이드오프:
    • B는 비우기가 **먼저 확정(커밋)**되므로, 채우기가 도중에 실패하면 테이블이 다음 성공 때까지 계속 빈 채로 남는다. 그 사이 조회가 오면 빈 랭킹을 받는다. "다시 만드는 순간 잠깐 빔"이 아니라 "실패하면 계속 빔"이 진짜 위험이다.
    • A는 비우기와 채우기를 한 트랜잭션에 묶어 도중에 실패하면 통째로 되돌려져 이전 판이 그대로 살아남는다. 게다가 비우기는 커밋 전까지 다른 조회에 보이지 않아, 조회가 비었거나 반쯤 채워진 판을 볼 틈 자체가 사라진다.
    • C는 조회를 아예 안 멈추지만 테이블 두 벌과 이름 바꾸기가 필요하다. 상위 100개 재적재는 워낙 짧아 조회를 막을 일이 없으니 A로 충분하고 C는 과한 투자다 — 데이터가 커져 다시 만드는 시간이 조회를 막을 만큼 길어지면 그때 C로 올리면 된다.
    • 전제: 한 번에 끊어 처리하는 크기(100)가 상위 개수(100)와 같아 한 묶음=한 트랜잭션이 보장된다. 솔직한 대가: 원본이 비면 저장이 아예 호출되지 않아 비우기도 안 일어난다 → 새로 만들 게 없으면 마지막 정상 판을 유지한다 — 의도한 동작이다. (RankMvItemWriter)

[결정 2] 순위와 점수가 어긋나는 문제 — 저장 직전 검증 vs 주석만 vs 앱에서 한 번에 계산

  • 배경: 뼈대상 순위는 SQL 정렬로, 점수는 RankingScorePolicy로 따로 계산된다. 같은 점수식이 SQL과 코드 두 곳에 살아, 둘이 조금이라도 어긋나면 "1등인데 점수는 2등보다 낮은" 모순이 아무 오류 없이 저장된다.
  • 고려한 방법:
    • A (저장 직전 검증): Processor가 흘러온 순서(점수 높은 순)를 점수로 다시 확인해, 순서가 뒤집히면 배치를 실패시킨다
    • B (주석만): 두 식을 똑같이 유지하라는 주석만 남긴다
    • C (앱에서 한 번에 계산): SQL 정렬을 버리고 앱에서 점수 계산과 정렬을 다 한다 → 계산을 한 곳으로
  • 결정: A
  • 트레이드오프:
    • B는 사람이 안 어기길 바랄 뿐, 돌아가는 중에 어긋남을 못 잡는다 — 조용히 틀어진 값을 그대로 둔다.
    • C는 계산이 한 곳이라 어긋날 일이 없지만, 상위 100개를 뽑으려 전체를 앱으로 들고 와 정렬해야 해 조금씩 끊어 처리하는 이점을 잃는다.
    • A는 끊어 처리하는 이점을 지키면서, 어긋나면 틀어진 값을 저장하는 대신 배치를 시끄럽게 실패시킨다(→ 결정 1 덕에 이전 판이 살아 있고, 결정 3의 오류 로그로 바로 알아챈다). 대가는 행마다 비교 한 번과, 소수점 오차로 인한 헛경보를 막을 아주 작은 허용치(0.000001)다. (RankItemProcessor)

[결정 3] 실패와 동시 실행 — 실패는 눈에 띄게, 동시 실행 방지는 넣지 않음

  • 실패 알림은 넣었다: JobListener가 배치 실패를 감지하면 완료 로그와 별개로 오류 로그로 "이전 판 유지됨 + 원인" 을 남긴다. 실패가 완료 로그에 묻혀 낡거나 빈 판이 방치되고, 조회가 그걸 정상(200)처럼 반환하는 걸 막는다.
  • 동시 실행 방지는 뺐다: 여러 인스턴스가 동시에 못 돌게 막는 잠금(ShedLock 등)을 넣지 않았다. 이 배치는 정해진 시각에 스스로 도는 스케줄러가 없고, 밖에서 한 번씩 실행되는 방식이라 잠금을 걸 지점이 없다. 동시 실행 통제는 실행을 관리하는 쪽 몫(예: 쿠버네티스가 "이미 돌면 새로 안 띄움"으로 설정)이고, 결정 1(한 트랜잭션 재적재) 덕에 설령 겹쳐 돌아도 테이블 잠금에 밀려 차례로 처리돼 최종 결과는 멀쩡하다. 코드로 막을 문제가 아니라고 봤다.
  • 실행마다 새 이력이 남는 설정이라 배치의 "멈춘 지점부터 재시작" 기능은 포기하지만, 재적재가 몇 번을 돌려도 결과가 같은 전체 재적재라 늘 처음부터 다시 만들면 그만이다 — 의도한 선택이다.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

변경 목적: Spring Batch로 주간·월간 TOP 100 랭킹을 사전 집계하고 기간별 조회 API를 제공한다.
핵심 변경점: product_metrics 기반 집계, 조회 전용 랭킹 테이블 저장, period=daily|weekly|monthly 라우팅을 추가했다.
핵심 변경점: 점수 정책과 SQL 정렬 결과를 저장 전에 검증하고, 재집계를 트랜잭션으로 처리해 실패 시 기존 랭킹을 유지한다.
리스크/주의사항: 동시 실행 잠금은 적용하지 않았으며, 주간·월간 데이터의 날짜 스냅샷 부재가 한계로 남아 있다.
테스트/검증: API 기간·페이징·잘못된 파라미터, 점수·순위 검증, 멱등 재집계와 배치 롤백을 테스트했다.
확인 필요: 실제 운영 환경에서 배치 동시 실행 방지와 날짜별 스냅샷이 필요한지 추가 확인이 필요하다.

Walkthrough

주간·월간 랭킹을 Spring Batch로 집계해 조회용 테이블에 저장하고, 랭킹 API가 period에 따라 Redis 또는 주간·월간 데이터를 조회하도록 확장했다. 카운트 기반 점수 정책과 재적재·검증·실패 로깅 테스트 및 설계 문서도 추가했다.

Changes

랭킹 데이터 계약과 점수 정책

Layer / File(s) Summary
랭킹 모델과 점수 계산
apps/commerce-batch/src/main/java/com/loopers/..., apps/commerce-batch/src/main/resources/application.yml, apps/commerce-batch/src/test/java/com/loopers/ranking/domain/*
product_metrics 입력 구조와 주간·월간 랭킹 엔티티를 추가하고, 조회수·좋아요·판매수 기반의 가중 점수 및 log10(1+sales_count) 계산을 정의하고 검증한다.

랭킹 집계 Job과 원자적 재적재

Layer / File(s) Summary
TOP 100 청크 집계와 MV 적재
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/*
SQL로 점수순 TOP 100을 읽고 Processor에서 순위와 점수를 검증·부여한 뒤, 기간별 테이블을 삭제하고 청크 단위로 적재한다. 실패 상태는 JobListener가 오류 로그로 기록한다.
배치 통합·롤백 검증
apps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJob*, apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/*
순위 부여, 재실행 시 중복 방지, 빈 입력 시 기존 데이터 유지, 주간·월간 분기와 실패 시 기존 판 유지를 검증한다.

기간별 랭킹 API 라우팅

Layer / File(s) Summary
기간별 조회 모델과 저장소
apps/commerce-api/src/main/java/com/loopers/ranking/domain/*, apps/commerce-api/src/main/java/com/loopers/ranking/infrastructure/*
주간·월간 조회 모델과 순위 오름차순 JPA 저장소를 추가하고 rankrank_no 컬럼으로 매핑한다.
컨트롤러·Facade 라우팅
apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingFacade.java, apps/commerce-api/src/main/java/com/loopers/ranking/interfaces/RankingV1Controller.java
period를 해석해 daily는 기존 Redis 조회를 사용하고, weekly·monthly는 각 저장소의 랭킹 행을 상품 정보로 변환한다.
API 검증
apps/commerce-api/src/test/java/com/loopers/ranking/*
weekly 순위·페이징 응답과 지원하지 않는 기간의 HTTP 400 응답을 검증하고 기존 Facade 테스트 호출을 갱신한다.

배치 랭킹 설계 문서

Layer / File(s) Summary
Round 10 설계와 요구사항
.docs/Note/10주차 정리.md, .docs/Note/PRNote.md, .docs/Note/TechNote.md, .docs/requirements.md
Redis 기반 실시간 랭킹 중심 문서를 Spring Batch·조회 전용 MV·기간별 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: 상품 정보가 포함된 랭킹 응답
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 Spring Batch 기반 랭킹 집계 구현이라는 핵심 변경을 정확히 요약한다.
Description check ✅ Passed 핵심 문제 정의와 선택지·결정·트레이드오프가 충분히 포함되어 템플릿 요구사항을 대부분 충족한다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.yml

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 673c8d1 and 6e5ff08.

📒 Files selected for processing (36)
  • .docs/Note/10주차 정리.md
  • .docs/Note/PRNote.md
  • .docs/Note/TechNote.md
  • .docs/requirements.md
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/domain/MonthlyProductRankModel.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/domain/ProductRankModel.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/domain/RankPeriod.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/domain/WeeklyProductRankModel.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/infrastructure/MonthlyProductRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/infrastructure/WeeklyProductRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/interfaces/RankingV1Controller.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/application/RankingFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/interfaces/RankingPeriodV1ApiE2ETest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankAggregationJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankItemProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankMvItemWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/metrics/domain/ProductMetricsModel.java
  • apps/commerce-batch/src/main/java/com/loopers/metrics/infrastructure/ProductMetricsJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/domain/MonthlyProductRankModel.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/domain/ProductRankModel.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankPeriod.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingScorePolicy.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/domain/RankingWeightProperties.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/domain/WeeklyProductRankModel.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/MonthlyProductRankJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/WeeklyProductRankJpaRepository.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/RankItemProcessorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/RankAggregationJobRollbackTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/domain/ProductRankModelTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/domain/RankPeriodTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/domain/RankingScorePolicyTest.java

Comment on lines +23 to +25
```
실시간(Redis) : 이벤트 1건 → 점수 조금 더함 (증분, 항상 최신)
배치(Batch) : 하루치 집계를 한 번에 다시 계산 → 판을 새로 구움 (주기적, 무거움)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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

Comment thread .docs/Note/PRNote.md
- B는 비우기가 **먼저 확정(커밋)**되므로, 채우기가 도중에 실패하면 테이블이 **다음 성공 때까지 계속 빈 채로** 남는다. 그 사이 조회가 오면 빈 랭킹을 받는다. "다시 만드는 순간 잠깐 빔"이 아니라 "실패하면 계속 빔"이 진짜 위험이다.
- A는 비우기와 채우기를 한 트랜잭션에 묶어 **도중에 실패하면 통째로 되돌려져 이전 판이 그대로 살아남는다.** 게다가 비우기는 커밋 전까지 다른 조회에 보이지 않아, 조회가 **비었거나 반쯤 채워진 판을 볼 틈 자체가 사라진다.**
- C는 조회를 아예 안 멈추지만 테이블 두 벌과 이름 바꾸기가 필요하다. 상위 100개 재적재는 워낙 짧아 조회를 막을 일이 없으니 **A로 충분하고 C는 과한 투자**다 — 데이터가 커져 다시 만드는 시간이 조회를 막을 만큼 길어지면 그때 C로 올리면 된다.
- 전제: 한 번에 끊어 처리하는 크기(100)가 상위 개수(100)와 같아 **한 묶음=한 트랜잭션**이 보장된다. 솔직한 대가: 원본이 비면 저장이 아예 호출되지 않아 비우기도 안 일어난다 → **새로 만들 게 없으면 마지막 정상 판을 유지**한다 — 의도한 동작이다. (`RankMvItemWriter`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.

Comment thread .docs/Note/PRNote.md

**[결정 3] 실패와 동시 실행 — 실패는 눈에 띄게, 동시 실행 방지는 넣지 않음**

- **실패 알림은 넣었다:** `JobListener`가 배치 실패를 감지하면 완료 로그와 별개로 **오류 로그로 "이전 판 유지됨 + 원인"** 을 남긴다. 실패가 완료 로그에 묻혀 낡거나 빈 판이 방치되고, 조회가 그걸 정상(200)처럼 반환하는 걸 막는다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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.

Comment thread .docs/Note/PRNote.md
Comment on lines +43 to +44
- **동시 실행 방지는 뺐다:** 여러 인스턴스가 동시에 못 돌게 막는 잠금(ShedLock 등)을 넣지 않았다. 이 배치는 **정해진 시각에 스스로 도는 스케줄러가 없고, 밖에서 한 번씩 실행**되는 방식이라 잠금을 걸 지점이 없다. 동시 실행 통제는 **실행을 관리하는 쪽 몫**(예: 쿠버네티스가 "이미 돌면 새로 안 띄움"으로 설정)이고, 결정 1(한 트랜잭션 재적재) 덕에 설령 겹쳐 돌아도 테이블 잠금에 밀려 차례로 처리돼 최종 결과는 멀쩡하다. 코드로 막을 문제가 아니라고 봤다.
- 실행마다 새 이력이 남는 설정이라 배치의 "멈춘 지점부터 재시작" 기능은 포기하지만, 재적재가 **몇 번을 돌려도 결과가 같은 전체 재적재**라 늘 처음부터 다시 만들면 그만이다 — 의도한 선택이다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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 -200

Repository: 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 -200

Repository: 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 || true

Repository: 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 || true

Repository: 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 || true

Repository: 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에서 테이블 잠금만으로 최종 결과가 정상이라고 단정하지 말고, 원본 변경 감지 또는
세대 검증을 포함한 신선도 보장 정책과 운영 책임을 명시하라. 또한 동시 배치 실행에서 최신 판이 커밋되는 동작을 검증하는 테스트를 추가하라.

Comment thread .docs/requirements.md
Comment on lines +27 to +29
- 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다.
- 대상 테이블 : `product_metrics`
- Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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 데이터 계약을
명확히 하세요. 날짜별 스냅샷과 주간·월간 기간 조건을 추가해 실제 기간별 집계가 가능하도록 정의하거나, 날짜 차원이 없는 누적 스냅샷 기반
범위 제한임을 명시하세요. 또한 서로 다른 날짜의 입력에 따라 주간·월간 결과가 달라지는 인수 테스트를 추가하세요.

Comment on lines +50 to +105
@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)
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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.

Comment on lines +74 to +78
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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.

Suggested change
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.

Comment on lines +55 to +60
if (jobExecution.getStatus() == BatchStatus.FAILED) {
log.error(
"Job '{}' 실패 — 이전 MV가 유지됩니다(재적재 미반영). 원인: {}",
jobExecution.getJobInstance().getJobName(),
jobExecution.getAllFailureExceptions()
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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


배치 실패 원인을 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.

Comment on lines +10 to +12
@ConfigurationProperties("ranking.weight")
public record RankingWeightProperties(double view, double like, double order) {
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 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 || true

Repository: 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)
PY

Repository: 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 || true

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

Comment on lines +72 to +89
@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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.

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