[10주차] Spring Batch 기반 주간/월간 랭킹 시스템 구현 - 정인철#401
Conversation
📝 WalkthroughWalkthrough랭킹 조회 기능을 일일/주간/월간 세 가지 기간으로 확장하기 위해 새로운 읽기 모델(weekly/monthly MV 테이블), JPA 저장소, 배치 작업을 추가하고, API 계층에서 period 파라미터로 라우팅하는 변경을 수행했다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Controller as RankingV1Controller
participant Facade as RankingFacade
participant Service as RankingService
participant Repo as RankingRepository
participant RedisRepo as RankingRedisRepository
participant JpaRepo as MvProductRankWeeklyJpaRepository
participant DB as Database
Client->>Controller: GET /rankings?period=weekly&date=20260412&page=1&size=10
activate Controller
Controller->>Controller: validate period, date, page, size
alt period == "weekly"
Controller->>Facade: getRankingsWeekly("20260412", 1, 10)
else period == "monthly"
Controller->>Facade: getRankingsMonthly(...)
else default
Controller->>Facade: getRankings(...)
end
deactivate Controller
activate Facade
Facade->>Service: getTopRankingsWeekly("20260412", 1, 10)
deactivate Facade
activate Service
Service->>Service: computeYearWeek("20260412") → "2026W15"
Service->>Service: offset = (1-1)*10 = 0
Service->>Repo: getTopNWeekly("2026W15", 0, 10)
deactivate Service
activate Repo
Repo->>RedisRepo: getTopNWeekly("2026W15", 0, 10)
deactivate Repo
activate RedisRepo
RedisRepo->>RedisRepo: page = 0/10 = 0
RedisRepo->>JpaRepo: findByYearWeekOrderByRankingAsc("2026W15", PageRequest.of(0, 10))
deactivate RedisRepo
activate JpaRepo
JpaRepo->>DB: SELECT * FROM mv_product_rank_weekly WHERE year_week='2026W15' ORDER BY ranking ASC LIMIT 10
DB-->>JpaRepo: List<MvProductRankWeeklyReadModel>
deactivate JpaRepo
JpaRepo-->>RedisRepo: RankingEntry list
RedisRepo-->>Facade: List<RankingEntry>
Facade->>Facade: assembleResponse(entries, 1, 10) - 제품/브랜드 데이터 병합
Facade-->>Controller: RankingPageResponse
Controller-->>Client: ApiResponse<RankingPageResponse>
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~30 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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: 7
🧹 Nitpick comments (4)
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java (1)
40-48: 생성자에서 월 키/순위 도메인 검증을 즉시 수행해야 한다.운영 관점에서 잘못된
yearMonth(형식 불일치)나ranking(0 이하)이 저장되면 월간 랭킹 결과가 배치 재실행 전까지 오염될 수 있다.
수정안으로 생성자에서productIdnull 체크,ranking >= 1,yearMonth정규식(^\d{6}$) 검증을 fail-fast로 적용하는 것이 안전하다.
추가 테스트로 잘못된yearMonth,ranking=0,productId=null입력 시 예외 발생을 검증하는 단위 테스트를 추가해야 한다.제안 diff
+import java.util.Objects; +import java.util.regex.Pattern; ... public class MvProductRankMonthlyModel extends BaseEntity { + private static final Pattern YEAR_MONTH_PATTERN = Pattern.compile("^\\d{6}$"); ... public MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount, long salesCount, double score, int ranking, String yearMonth) { - this.productId = productId; + this.productId = Objects.requireNonNull(productId, "productId must not be null"); + if (ranking < 1) { + throw new IllegalArgumentException("ranking must be >= 1"); + } + if (yearMonth == null || !YEAR_MONTH_PATTERN.matcher(yearMonth).matches()) { + throw new IllegalArgumentException("yearMonth must match yyyyMM"); + } this.viewCount = viewCount; this.likeCount = likeCount; this.salesCount = salesCount; this.score = score; this.ranking = ranking; this.yearMonth = yearMonth; } }As per coding guidelines
**/domain/**/*.java: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java` around lines 40 - 48, In MvProductRankMonthlyModel's constructor (the public MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount, long salesCount, double score, int ranking, String yearMonth) method) add fail-fast validation: check productId for null and throw NullPointerException (or IllegalArgumentException) if null, enforce ranking >= 1 and throw IllegalArgumentException when <= 0, and validate yearMonth against the regex ^\d{6}$ throwing IllegalArgumentException on mismatch; update the constructor to perform these checks before assigning fields and add unit tests that assert exceptions for productId=null, ranking=0, and invalid yearMonth inputs.apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java (1)
45-53: 주간 키/순위 입력 검증이 없어 데이터 오염을 조기에 차단하지 못한다.운영 관점에서 잘못된
yearWeek형식이나 비정상ranking값이 적재되면 주간 랭킹 결과가 왜곡되고, 원인 파악이 배치 완료 후로 지연된다.
수정안으로 생성자에서productIdnull 체크,ranking >= 1,yearWeek정규식(^\d{4}W\d{2}$) 검증을 추가해야 한다.
추가 테스트로yearWeek="2026-15",ranking=0,productId=null케이스에서 예외가 발생하는지 검증해야 한다.As per coding guidelines
**/domain/**/*.java: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java` around lines 45 - 53, In MvProductRankWeeklyModel's constructor add validation to prevent bad weekly keys and rankings: check productId is not null, check ranking >= 1, and validate yearWeek against the regex ^\d{4}W\d{2}$; if any check fails throw an IllegalArgumentException with a clear message. Also make the model fields immutable (declare them final if not already) to follow domain immutability/encapsulation rules. Add unit tests for the failure cases yearWeek="2026-15", ranking=0, and productId=null to assert exceptions are thrown.apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java (1)
17-20: 인터페이스 계약 명확화로 유지보수성을 높여야 한다.Line 17~20의
getTopNWeekly,getTopNMonthly메서드는offset파라미터를 사용하지만 JavaDoc이 없어 의도가 불명확하다. 현재는RankingService에서offset = (page - 1) * size형태로만 호출되고, 구현에서도 이를 가정하고offset / size로 페이지를 계산하므로 실제 동작은 안전하다. 그러나 계약이 불명확하면 향후 다른 호출자 추가 시 또는 유지보수 과정에서 오프셋 계산 실수가 발생할 수 있다.다음 중 하나로 개선한다:
- JavaDoc에 "
offset은 항상page * size형태여야 함" 명시- 또는 메서드 시그니처를
(String yearWeek, int page, int size)로 리팩토링하여 page-based 의도를 명확히 함🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java` around lines 17 - 20, The methods getTopNWeekly and getTopNMonthly currently use an ambiguous int offset; change their signatures to use int page (e.g., getTopNWeekly(String yearWeek, int page, int size) and getTopNMonthly(String yearMonth, int page, int size)) to express page-based intent, then update all implementations of RankingRepository and all callers (notably RankingService) to compute offset = (page - 1) * size before performing data access, and adjust unit/integration tests and any documentation to reflect the new parameter semantics.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java (1)
61-75:offset기반 계약이라면 page 정수 나눗셈에 기대면 안 된다Line 62와 Line 71은
offset / size를 page로 바꾸므로offset이size의 배수가 아닐 때 계약과 다른 구간을 반환한다. 지금은RankingService가(page - 1) * size를 넘겨서 우연히 맞지만, 저장소 시그니처는 offset 기반이라 재사용 시 중복/누락 페이지가 생긴다. 시그니처를page/size로 바꾸거나, 최소한offset % size == 0을 검증해 잘못된 호출을 즉시 실패시키는 편이 낫다. 추가 테스트로는 정렬된 주간/월간 MV 데이터에서offset=10, size=20같은 입력이 잘못된 범위를 반환하지 않는지 확인해야 한다.최소 방어선 예시
`@Override` public List<RankingEntry> getTopNWeekly(String yearWeek, int offset, int size) { + if (offset % size != 0) { + throw new IllegalArgumentException("offset must be a multiple of size"); + } int page = offset / size; return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size)) .stream() .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore())) .toList(); } `@Override` public List<RankingEntry> getTopNMonthly(String yearMonth, int offset, int size) { + if (offset % size != 0) { + throw new IllegalArgumentException("offset must be a multiple of size"); + } int page = offset / size; return monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth, PageRequest.of(page, size)) .stream() .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore())) .toList(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java` around lines 61 - 75, The methods getTopNWeekly and getTopNMonthly in RankingRedisRepository treat the incoming offset as if it were a page by computing page = offset / size, which breaks the offset-based contract; either change the repository signature to accept page and size, or validate the offset is page-aligned and fail fast—add a guard at the start of getTopNWeekly and getTopNMonthly to check size > 0 and offset % size == 0 and throw an IllegalArgumentException (or similar) if not, then compute page = offset / size and proceed to call weeklyJpaRepository.findByYearWeekOrderByRankingAsc and monthlyJpaRepository.findByYearMonthOrderByRankingAsc respectively; ensure tests cover a non-aligned example like offset=10,size=20 to catch regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java`:
- Around line 58-63: getTopRankingsMonthly currently derives yearMonth via
date.substring(0,6) which can throw StringIndexOutOfBoundsException for short
inputs or produce invalid months for malformed dates; change
getTopRankingsMonthly to parse the incoming date string into a
java.time.LocalDate (using appropriate DateTimeFormatter patterns or trying
multiple patterns like yyyyMMdd and yyyyMM01), validate the parsed date, then
format it to "yyyyMM" (e.g., DateTimeFormatter.ofPattern("yyyyMM")) and pass
that to rankingRepository.getTopNMonthly; on parse/validation failure throw a
clear IllegalArgumentException with a descriptive message so callers get a
predictable error flow.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`:
- Around line 50-55: The LocalDate parsing in RankingV1Controller currently uses
a SMART resolver (LocalDate.parse(date, DATE_FORMAT)) which auto-corrects
invalid dates; change the formatter to enforce strict validation by replacing
DATE_FORMAT with DateTimeFormatter.BASIC_ISO_DATE (preferred) or update the
existing DATE_FORMAT to
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
so LocalDate.parse(date, DATE_FORMAT) throws DateTimeParseException for inputs
like 20260230/20250229/202401; update the controller to use the new formatter
and add unit tests asserting that those invalid inputs result in
DateTimeParseException (and therefore a 400 via the existing CoreException
path).
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java`:
- Around line 86-95: ORDER BY 절이 score만 있어 동점인 경우 순서가 비결정적이므로
MonthlyRankingJobConfig에서 사용된 SQL의 ORDER BY를 수정해 score DESC에 안정적인 보조
키(product_id 등)를 추가하고(예: ORDER BY score DESC, product_id ASC) 컷오프( LIMIT 100 )
결과가 재실행 시 일관되도록 만드세요; 또한 동일 score를 가진 여러 상품을 넣고 재실행해도 순서가 유지되는 통합/단위 테스트를 보강하여
변경을 검증하세요.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java`:
- Around line 29-38: The computeYearMonth(String dateStr) method currently uses
substring and can throw unclear NPE/SIOOBE; replace its logic to validate and
parse requestDate using LocalDate.parse(dateStr,
DateTimeFormatter.BASIC_ISO_DATE) (or equivalent validation), then format/return
yearMonth as yyyyMM; if parsing fails or dateStr is null/empty, throw a clear
IllegalArgumentException with a descriptive message. Update any callers (e.g.,
execute in ClearMonthlyRankingTasklet) to rely on the validated computeYearMonth
and add unit tests covering null/missing requestDate, too-short strings, and
invalid dates like "20260231" to assert explicit exception behavior.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java`:
- Around line 36-57: Validate and strictly parse requestDate before computing
the week: ensure the injected field requestDate is non-null/non-blank at step
start (fail fast with a clear error) and replace the loose formatter in
computeYearWeek with a constant DateTimeFormatter defined using
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
to prevent SMART parsing; update computeYearWeek to use that formatter and add
unit/integration tests for missing requestDate, an invalid date like "20240230",
and an ISO boundary case such as "20210101".
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java`:
- Around line 64-69: The current Job defined in weeklyRankingJob() uses two
separate steps clearWeeklyRankingStep() then aggregateWeeklyRankingStep(), which
allows an intermediate committed delete to leave the weekly ranking empty on
failure; change the flow so deletion and insertion are atomic: either combine
clearWeeklyRankingStep() and aggregateWeeklyRankingStep() into a single Step
that performs delete+bulk-insert within one transaction (or implement a snapshot
approach using a temp table and a single atomic swap/rename or a version column
update) and update the JobBuilder to start that single atomic step instead of
separate steps; also add an integration test that injects a failure into the
item writer for the aggregate phase to assert the previous week snapshot remains
served when the job fails.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`:
- Around line 87-94: The assertions rely on unstable ordering from
weeklyJpaRepository.findAll(); update the test in WeeklyRankingJobE2ETest to
fetch results with a deterministic order (e.g., call a repository method that
orders by ranking such as findAllByOrderByRankingAsc or pass a Sort to findAll)
or explicitly sort the returned List<MvProductRankWeeklyModel> by getRanking()
before making assertions on results.get(0), get(1), and getYearWeek(); also add
a separate integration test that covers tie scores to ensure deterministic
behavior when scores are equal.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java`:
- Around line 17-20: The methods getTopNWeekly and getTopNMonthly currently use
an ambiguous int offset; change their signatures to use int page (e.g.,
getTopNWeekly(String yearWeek, int page, int size) and getTopNMonthly(String
yearMonth, int page, int size)) to express page-based intent, then update all
implementations of RankingRepository and all callers (notably RankingService) to
compute offset = (page - 1) * size before performing data access, and adjust
unit/integration tests and any documentation to reflect the new parameter
semantics.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java`:
- Around line 61-75: The methods getTopNWeekly and getTopNMonthly in
RankingRedisRepository treat the incoming offset as if it were a page by
computing page = offset / size, which breaks the offset-based contract; either
change the repository signature to accept page and size, or validate the offset
is page-aligned and fail fast—add a guard at the start of getTopNWeekly and
getTopNMonthly to check size > 0 and offset % size == 0 and throw an
IllegalArgumentException (or similar) if not, then compute page = offset / size
and proceed to call weeklyJpaRepository.findByYearWeekOrderByRankingAsc and
monthlyJpaRepository.findByYearMonthOrderByRankingAsc respectively; ensure tests
cover a non-aligned example like offset=10,size=20 to catch regressions.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java`:
- Around line 40-48: In MvProductRankMonthlyModel's constructor (the public
MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount, long
salesCount, double score, int ranking, String yearMonth) method) add fail-fast
validation: check productId for null and throw NullPointerException (or
IllegalArgumentException) if null, enforce ranking >= 1 and throw
IllegalArgumentException when <= 0, and validate yearMonth against the regex
^\d{6}$ throwing IllegalArgumentException on mismatch; update the constructor to
perform these checks before assigning fields and add unit tests that assert
exceptions for productId=null, ranking=0, and invalid yearMonth inputs.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java`:
- Around line 45-53: In MvProductRankWeeklyModel's constructor add validation to
prevent bad weekly keys and rankings: check productId is not null, check ranking
>= 1, and validate yearWeek against the regex ^\d{4}W\d{2}$; if any check fails
throw an IllegalArgumentException with a clear message. Also make the model
fields immutable (declare them final if not already) to follow domain
immutability/encapsulation rules. Add unit tests for the failure cases
yearWeek="2026-15", ranking=0, and productId=null to assert exceptions are
thrown.
🪄 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
Run ID: 4330fb69-f597-4932-926e-9234d9f472ce
📒 Files selected for processing (23)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.javaapps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.javaapps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.javaapps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.javaapps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.javaapps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java
💤 Files with no reviewable changes (2)
- apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
- apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java
| /** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */ | ||
| public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) { | ||
| String yearMonth = date.substring(0, 6); // "yyyyMMdd" → "yyyyMM" | ||
| int offset = (page - 1) * size; | ||
| return rankingRepository.getTopNMonthly(yearMonth, offset, size); | ||
| } |
There was a problem hiding this comment.
월간 키 생성에서 substring에 의존하지 말아야 한다
Line 60은 입력 앞 6자리만 잘라 yearMonth를 만들기 때문에, 이 서비스가 다른 진입점에서 재사용되면 짧은 값은 StringIndexOutOfBoundsException, 잘못된 값은 존재하지 않는 월 키로 흘러갈 수 있다. 운영에서는 같은 날짜 입력인데 주간/월간 경로의 실패 방식이 달라져 원인 추적이 어려워진다. 주간 경로처럼 날짜를 먼저 해석한 뒤 yyyyMM으로 포맷하도록 맞추는 편이 안전하다. 추가 테스트로는 "202604", "20261301", "20260230" 입력에서 월간 경로가 예측 가능한 예외 흐름을 가지는지 확인해야 한다.
예시 수정안
/** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */
public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) {
- String yearMonth = date.substring(0, 6); // "yyyyMMdd" → "yyyyMM"
+ LocalDate parsedDate = LocalDate.parse(date, DATE_FORMAT);
+ String yearMonth = parsedDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
int offset = (page - 1) * size;
return rankingRepository.getTopNMonthly(yearMonth, offset, size);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java`
around lines 58 - 63, getTopRankingsMonthly currently derives yearMonth via
date.substring(0,6) which can throw StringIndexOutOfBoundsException for short
inputs or produce invalid months for malformed dates; change
getTopRankingsMonthly to parse the incoming date string into a
java.time.LocalDate (using appropriate DateTimeFormatter patterns or trying
multiple patterns like yyyyMMdd and yyyyMM01), validate the parsed date, then
format it to "yyyyMM" (e.g., DateTimeFormatter.ofPattern("yyyyMM")) and pass
that to rankingRepository.getTopNMonthly; on parse/validation failure throw a
clear IllegalArgumentException with a descriptive message so callers get a
predictable error flow.
| } else { | ||
| try { | ||
| LocalDate.parse(date, DATE_FORMAT); | ||
| } catch (DateTimeParseException e) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Java, does LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) reject invalid dates strictly by default? If not, what is the recommended formatter for strict validation of an 8-digit yyyyMMdd request parameter?
💡 Result:
No, LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) does not reject invalid dates strictly by default. It uses SMART resolver style, which adjusts invalid day-of-month values to the last valid day of the month (e.g., "20240230" parses to 2024-02-29). For strict validation of an 8-digit yyyyMMdd string, use DateTimeFormatter.BASIC_ISO_DATE, which matches the exact format and uses STRICT resolver style internally, rejecting invalid dates like "20240230" with DateTimeParseException. Alternatively, create a custom formatter: DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT), which also rejects invalid dates. DateTimeFormatter.BASIC_ISO_DATE is recommended as it is predefined, official for basic ISO date (yyyyMMdd), and ensures strict parsing without lenient adjustments.
Citations:
- 1: https://stackoverflow.com/questions/63315211/does-localdate-parse-silently-correct-day-number
- 2: https://stackoverflow.com/questions/32823368/java-8-localdatetime-is-parsing-invalid-datetime
- 3: https://blog.mfazio.dev/kotlin/localdate-parsing/
- 4: https://stackoverflow.com/questions/26393594/using-new-java-8-datetimeformatter-to-do-strict-date-parsing
- 5: https://stackoverflow.com/questions/30308122/datetimeformatter-auto-corrects-invalid-syntactically-possible-calendar-date
- 6: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html
- 7: https://docs.oracle.com/en/java/javase//20/docs/api/java.base/java/time/format/DateTimeFormatter.html
엄격한 날짜 검증으로 불가능한 입력을 400으로 거부해야 한다
현재 50번 줄의 LocalDate.parse(date, DATE_FORMAT)은 기본값인 SMART 리졸버 스타일을 사용하므로 존재하지 않는 날짜를 자동 보정한다. 예를 들어 20240230은 2024-02-29로 파싱되어 DateTimeParseException이 발생하지 않는다. 잘못된 요청이 400 에러로 거부되지 않고 다른 일자의 랭킹 데이터를 반환하므로 운영에서 입력값과 결과가 어긋나는 장애를 야기한다. 엄격한 검증이 필요하다.
포맷터를 두 가지 방법 중 선택하여 수정한다:
- 권장:
DateTimeFormatter.BASIC_ISO_DATE로 변경 (공식 ISO 8601 기본 형식, 기본 내장되어 엄격한 검증 제공) - 대안: 기존 방식 유지 시
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)적용
이후 20260230, 비윤년 20250229, 자리수 부족 입력(202401 등)이 모두 DateTimeParseException으로 거부되는지 단위 테스트로 검증한다.
방법 1: BASIC_ISO_DATE 적용 (권장)
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
...
private static final DateTimeFormatter DATE_FORMAT =
- DateTimeFormatter.ofPattern("yyyyMMdd");
+ DateTimeFormatter.BASIC_ISO_DATE;방법 2: STRICT 리졸버 스타일 명시
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
...
private static final DateTimeFormatter DATE_FORMAT =
- DateTimeFormatter.ofPattern("yyyyMMdd");
+ DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`
around lines 50 - 55, The LocalDate parsing in RankingV1Controller currently
uses a SMART resolver (LocalDate.parse(date, DATE_FORMAT)) which auto-corrects
invalid dates; change the formatter to enforce strict validation by replacing
DATE_FORMAT with DateTimeFormatter.BASIC_ISO_DATE (preferred) or update the
existing DATE_FORMAT to
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
so LocalDate.parse(date, DATE_FORMAT) throws DateTimeParseException for inputs
like 20260230/20250229/202401; update the controller to use the new formatter
and add unit tests asserting that those invalid inputs result in
DateTimeParseException (and therefore a 400 via the existing CoreException
path).
| .sql(""" | ||
| SELECT pm.product_id, | ||
| pm.view_count, | ||
| pm.like_count, | ||
| pm.sales_count, | ||
| (pm.view_count * 0.1 + pm.like_count * 0.2 + pm.sales_count * 0.7) AS score | ||
| FROM product_metrics pm | ||
| ORDER BY score DESC | ||
| LIMIT 100 | ||
| """) |
There was a problem hiding this comment.
동점 score에서 정렬 기준이 없어 월간 순위가 재실행마다 흔들릴 수 있다.
ORDER BY score DESC만 두면 동점 상품의 상대 순서와 100위 컷이 실행 계획에 따라 달라질 수 있어, 같은 데이터로 재실행해도 사용자에게 다른 순위가 노출될 수 있다. product_id 같은 안정적인 보조 정렬 키를 추가해 순위를 결정적으로 만들어야 한다. 동일 score 상품 여러 건을 넣고 반복 실행해도 순서가 유지되는 테스트를 보강하는 편이 좋다.
보조 정렬 키를 추가하는 예시다
- ORDER BY score DESC
+ ORDER BY score DESC, pm.product_id ASC
LIMIT 100🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java`
around lines 86 - 95, ORDER BY 절이 score만 있어 동점인 경우 순서가 비결정적이므로
MonthlyRankingJobConfig에서 사용된 SQL의 ORDER BY를 수정해 score DESC에 안정적인 보조
키(product_id 등)를 추가하고(예: ORDER BY score DESC, product_id ASC) 컷오프( LIMIT 100 )
결과가 재실행 시 일관되도록 만드세요; 또한 동일 score를 가진 여러 상품을 넣고 재실행해도 순서가 유지되는 통합/단위 테스트를 보강하여
변경을 검증하세요.
| public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { | ||
| String yearMonth = computeYearMonth(requestDate); | ||
| log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth); | ||
| monthlyJpaRepository.deleteByYearMonth(yearMonth); | ||
| return RepeatStatus.FINISHED; | ||
| } | ||
|
|
||
| /** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */ | ||
| public static String computeYearMonth(String dateStr) { | ||
| return dateStr.substring(0, 6); |
There was a problem hiding this comment.
requestDate를 substring으로 자르면 배치 실패 원인이 불명확해진다.
파라미터가 누락되거나 yyyyMMdd 형식이 아니면 여기서 NullPointerException 또는 StringIndexOutOfBoundsException이 발생해 운영 로그만으로 원인 파악이 어렵다. LocalDate.parse(..., DateTimeFormatter.BASIC_ISO_DATE)로 먼저 검증한 뒤 yyyyMM을 생성하고, 잘못된 값이면 명시적인 예외로 실패시키는 편이 낫다. requestDate 누락, 길이 부족, 존재하지 않는 날짜(20260231) 케이스를 테스트로 추가해야 한다.
명시적 검증으로 바꾸는 예시다
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
...
/** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */
public static String computeYearMonth(String dateStr) {
- return dateStr.substring(0, 6);
+ try {
+ return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE)
+ .format(DateTimeFormatter.ofPattern("yyyyMM"));
+ } catch (DateTimeParseException | NullPointerException ex) {
+ throw new IllegalArgumentException("requestDate must be yyyyMMdd", ex);
+ }
}As per coding guidelines **/*.java: 예외 흐름이 명확한지 점검한다.
📝 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.
| public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { | |
| String yearMonth = computeYearMonth(requestDate); | |
| log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth); | |
| monthlyJpaRepository.deleteByYearMonth(yearMonth); | |
| return RepeatStatus.FINISHED; | |
| } | |
| /** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */ | |
| public static String computeYearMonth(String dateStr) { | |
| return dateStr.substring(0, 6); | |
| public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { | |
| String yearMonth = computeYearMonth(requestDate); | |
| log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth); | |
| monthlyJpaRepository.deleteByYearMonth(yearMonth); | |
| return RepeatStatus.FINISHED; | |
| } | |
| /** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */ | |
| public static String computeYearMonth(String dateStr) { | |
| try { | |
| return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE) | |
| .format(DateTimeFormatter.ofPattern("yyyyMM")); | |
| } catch (DateTimeParseException | NullPointerException ex) { | |
| throw new IllegalArgumentException("requestDate must be yyyyMMdd", ex); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java`
around lines 29 - 38, The computeYearMonth(String dateStr) method currently uses
substring and can throw unclear NPE/SIOOBE; replace its logic to validate and
parse requestDate using LocalDate.parse(dateStr,
DateTimeFormatter.BASIC_ISO_DATE) (or equivalent validation), then format/return
yearMonth as yyyyMM; if parsing fails or dateStr is null/empty, throw a clear
IllegalArgumentException with a descriptive message. Update any callers (e.g.,
execute in ClearMonthlyRankingTasklet) to rely on the validated computeYearMonth
and add unit tests covering null/missing requestDate, too-short strings, and
invalid dates like "20260231" to assert explicit exception behavior.
| @Value("#{jobParameters['requestDate']}") | ||
| private String requestDate; | ||
|
|
||
| @Override | ||
| public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { | ||
| String yearWeek = computeYearWeek(requestDate); | ||
| log.info("주간 랭킹 MV 초기화: yearWeek={}", yearWeek); | ||
|
|
||
| weeklyJpaRepository.deleteByYearWeek(yearWeek); | ||
|
|
||
| return RepeatStatus.FINISHED; | ||
| } | ||
|
|
||
| /** | ||
| * "yyyyMMdd" 형식의 날짜에서 ISO 주차를 계산한다. | ||
| * 예: "20260412" → 2026년 15주차 → "2026W15" | ||
| */ | ||
| public static String computeYearWeek(String dateStr) { | ||
| LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); | ||
| int year = date.get(WeekFields.ISO.weekBasedYear()); | ||
| int week = date.get(WeekFields.ISO.weekOfWeekBasedYear()); | ||
| return String.format("%dW%02d", year, week); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Java, does LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) validate dates strictly by default, or can invalid calendar dates still be resolved because of the default ResolverStyle? What formatter is recommended for strict yyyyMMdd validation?
💡 Result:
No, LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) does not validate dates strictly by default. The default ResolverStyle is SMART, which intelligently resolves dates by adjusting invalid values (e.g., "20240230" would parse to 2024-02-29). Invalid calendar dates can still be resolved to the nearest valid date. For strict yyyyMMdd validation that rejects invalid dates like February 30, use DateTimeFormatter.BASIC_ISO_DATE (which uses STRICT ResolverStyle by design) or explicitly set ResolverStyle.STRICT: DateTimeFormatter.ofPattern("yyyyMMdd").withResolverStyle(ResolverStyle.STRICT). Note: prefer "uuuuMMdd" over "yyyyMMdd" to avoid year-of-era ambiguity, though it often works with defaults.
Citations:
- 1: https://stackoverflow.com/questions/63315211/does-localdate-parse-silently-correct-day-number
- 2: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html
- 3: https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html
- 4: https://stackoverflow.com/questions/26393594/using-new-java-8-datetimeformatter-to-do-strict-date-parsing
- 5: https://www.baeldung.com/java-string-valid-date
- 6: https://docs.oracle.com/javase/8/docs/api/java/time/format/ResolverStyle.html
- 7: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/time/format/DateTimeFormatter.html
requestDate를 엄격하게 검증해야 한다
requestDate가 누락되면 Line 54에서 스텝이 늦게 실패하고, 현재 DateTimeFormatter.ofPattern("yyyyMMdd")는 기본값으로 SMART 해석기를 사용하여 엄격한 검증을 보장하지 않는다. SMART 해석기는 잘못된 달력 날짜를 가장 가까운 유효한 날짜로 조정하므로(예: "20240230" → "2024-02-29"), 의도하지 않은 주차로 해석될 여지가 있다. 운영 배치에서는 잘못된 yearWeek에 대한 주간 MV를 삭제하거나 갱신이 실패하여 다음 조회까지 랭킹이 비는 문제가 발생한다. 스텝 진입 시 null/blank를 명시적으로 차단하고, DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)를 상수로 정의하여 주차 계산을 고정하는 것이 필요하다. 추가 테스트로는 requestDate 누락, "20240230" (존재하지 않는 날짜), ISO 주차 경계인 "20210101" 케이스를 검증해야 한다.
수정 예시
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
+import java.time.format.ResolverStyle;
import java.time.temporal.WeekFields;
...
public class ClearWeeklyRankingTasklet implements Tasklet {
+
+ private static final DateTimeFormatter REQUEST_DATE_FORMAT =
+ DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT);
private final MvProductRankWeeklyJpaRepository weeklyJpaRepository;
...
`@Override`
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ if (requestDate == null || requestDate.isBlank()) {
+ throw new IllegalArgumentException("requestDate 배치 파라미터는 필수입니다.");
+ }
String yearWeek = computeYearWeek(requestDate);
log.info("주간 랭킹 MV 초기화: yearWeek={}", yearWeek);
...
public static String computeYearWeek(String dateStr) {
- LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
+ LocalDate date = LocalDate.parse(dateStr, REQUEST_DATE_FORMAT);
int year = date.get(WeekFields.ISO.weekBasedYear());
int week = date.get(WeekFields.ISO.weekOfWeekBasedYear());
return String.format("%dW%02d", year, week);
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java`
around lines 36 - 57, Validate and strictly parse requestDate before computing
the week: ensure the injected field requestDate is non-null/non-blank at step
start (fail fast with a clear error) and replace the loose formatter in
computeYearWeek with a constant DateTimeFormatter defined using
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
to prevent SMART parsing; update computeYearWeek to use that formatter and add
unit/integration tests for missing requestDate, an invalid date like "20240230",
and an ISO boundary case such as "20210101".
| public Job weeklyRankingJob() { | ||
| return new JobBuilder(JOB_NAME, jobRepository) | ||
| .incrementer(new RunIdIncrementer()) | ||
| .start(clearWeeklyRankingStep()) // Step 1: 기존 데이터 삭제 | ||
| .next(aggregateWeeklyRankingStep()) // Step 2: 집계 + 적재 | ||
| .listener(jobListener) |
There was a problem hiding this comment.
삭제와 적재를 별도 Step로 커밋하면 실패 순간에 주간 랭킹이 비게 된다.
현재 구조는 clear step이 먼저 커밋되고 aggregate step이 뒤따르므로, 실행 중이거나 aggregate step이 실패하면 같은 yearWeek 조회가 빈 결과를 반환할 수 있다. 상위 100건만 다루므로 임시 스냅샷 테이블이나 버전 컬럼으로 원자적 교체를 하거나, 최소한 삭제와 삽입을 하나의 트랜잭션 경계에서 처리하는 구조로 바꾸는 편이 안전하다. writer 실패를 강제로 주입한 통합 테스트를 추가해도 이전 스냅샷이 계속 조회되는지 확인해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java`
around lines 64 - 69, The current Job defined in weeklyRankingJob() uses two
separate steps clearWeeklyRankingStep() then aggregateWeeklyRankingStep(), which
allows an intermediate committed delete to leave the weekly ranking empty on
failure; change the flow so deletion and insertion are atomic: either combine
clearWeeklyRankingStep() and aggregateWeeklyRankingStep() into a single Step
that performs delete+bulk-insert within one transaction (or implement a snapshot
approach using a temp table and a single atomic swap/rename or a version column
update) and update the JobBuilder to start that single atomic step instead of
separate steps; also add an integration test that injects a failure into the
item writer for the aggregate phase to assert the previous week snapshot remains
served when the job fails.
| List<MvProductRankWeeklyModel> results = weeklyJpaRepository.findAll(); | ||
| assertAll( | ||
| () -> assertThat(results).hasSize(3), | ||
| () -> assertThat(results.get(0).getRanking()).isEqualTo(1), | ||
| () -> assertThat(results.get(0).getScore()) | ||
| .isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수 | ||
| () -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15") | ||
| ); |
There was a problem hiding this comment.
findAll() 반환 순서에 의존한 단언은 플래키하다.
weeklyJpaRepository.findAll()은 정렬을 보장하지 않으므로 환경에 따라 results.get(0)이 1등이 아닐 수 있다. ranking 기준으로 조회하거나 테스트에서 명시적으로 정렬한 뒤 단언해야 한다. 동점 score 데이터가 있어도 동일한 순서로 검증되는지 통합 테스트를 하나 더 추가하는 편이 안전하다.
정렬을 고정하는 예시다
+import org.springframework.data.domain.Sort;
...
- List<MvProductRankWeeklyModel> results = weeklyJpaRepository.findAll();
+ List<MvProductRankWeeklyModel> results =
+ weeklyJpaRepository.findAll(Sort.by(Sort.Direction.ASC, "ranking"));As per coding guidelines **/*Test*.java: 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다.
📝 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.
| List<MvProductRankWeeklyModel> results = weeklyJpaRepository.findAll(); | |
| assertAll( | |
| () -> assertThat(results).hasSize(3), | |
| () -> assertThat(results.get(0).getRanking()).isEqualTo(1), | |
| () -> assertThat(results.get(0).getScore()) | |
| .isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수 | |
| () -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15") | |
| ); | |
| List<MvProductRankWeeklyModel> results = | |
| weeklyJpaRepository.findAll(Sort.by(Sort.Direction.ASC, "ranking")); | |
| assertAll( | |
| () -> assertThat(results).hasSize(3), | |
| () -> assertThat(results.get(0).getRanking()).isEqualTo(1), | |
| () -> assertThat(results.get(0).getScore()) | |
| .isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수 | |
| () -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15") | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`
around lines 87 - 94, The assertions rely on unstable ordering from
weeklyJpaRepository.findAll(); update the test in WeeklyRankingJobE2ETest to
fetch results with a deterministic order (e.g., call a repository method that
orders by ranking such as findAllByOrderByRankingAsc or pass a Sort to findAll)
or explicitly sort the returned List<MvProductRankWeeklyModel> by getRanking()
before making assertions on results.get(0), get(1), and getYearWeek(); also add
a separate integration test that covers tie scores to ensure deterministic
behavior when scores are equal.
📌 Summary
product_metrics일간 집계 파이프라인, R9에서 구현한 Redis ZSET 기반 실시간 일간 랭킹은 이미 머지된 상태입니다.ranking:all:{yyyyMMdd})으로 실시간 제공되지만, TTL이 2일이라 "이번 주"/"이번 달" 같은 장기 집계를 유지하기 어려웠습니다. 이벤트가 쌓일수록 ZSET 메모리가 선형 증가하기 때문에 기간이 길수록 운영 비용도 부담스러워집니다.product_metrics테이블을 Spring Batch로 집계하여 주간/월간 TOP 100 랭킹을 MV(Materialized View) 테이블에 적재하고, 기존GET /api/v1/rankingsAPI에period=daily|weekly|monthly분기를 추가했습니다.weeklyRankingJob,monthlyRankingJob두 개의 독립 배치 Job 구성. 일간(ZSET) + 주간·월간(MV) 하이브리드 랭킹 아키텍처 확립. 재실행 시 멱등성을 보장하고, API 입력값 검증 및 Facade 중복 로직 제거까지 완료했습니다.🧭 Context & Decision
문제 정의
ZINCRBY)실시간성이 필수인 일간과 달리, 주간/월간은 조회는 자주·갱신은 가끔 일어나는 패턴입니다. 이 차이를 인식하고 두 경로를 분리하는 것이 이번 라운드의 핵심 판단이었습니다.
저장 방식 선택지
product_metrics를 on-the-fly로ORDER BY score왜 Chunk-Oriented Processing인가
flowchart LR subgraph Tasklet["Tasklet 단일 트랜잭션"] T1["전체 데이터 메모리 적재"] --> T2["한 번에 처리/커밋"] end subgraph Chunk["Chunk-Oriented (채택)"] R["Reader\nCursor 스트리밍"] --> P["Processor\n1건씩 변환"] P --> W["Writer\nchunk=100 단위 커밋"] end style T1 fill:#e57373,color:#fff style T2 fill:#e57373,color:#fff style R fill:#81c784,color:#fff style P fill:#81c784,color:#fff style W fill:#81c784,color:#fff현재는
LIMIT 100이라 1회 커밋이면 끝나지만, 향후 TOP N을 확장해도 스트리밍 구조는 그대로 동작합니다. "지금의 단순함"과 "나중의 확장성"을 동시에 얻는 구조라고 판단했습니다.Clear + Aggregate 2-Step 분리 (멱등성)
flowchart LR Start(["Job 시작"]) --> S1 S1["Step 1\nClear Tasklet\nDELETE FROM mv WHERE yearWeek='2026W15'"] --> S2 S2["Step 2\nAggregate Chunk\nproduct_metrics → MV TOP 100"] --> End(["Job 완료"]) style S1 fill:#f5a742,color:#fff style S2 fill:#4287f5,color:#fff의도: 동일
yearWeek·yearMonth재실행 시 이전 배치 결과를 완전 대체합니다. 재계산 중에 기존 데이터가 남아있으면 응답에 중복이 섞이므로, 먼저 지우고 다시 채우는 순서로 분리했습니다. Step을 나누면 단계별 실패 복구가 쉬워지는 부수 효과도 있습니다 (Clear 실패 vs Aggregate 실패를 로그로 명확히 구분 가능).🏗️ Design Overview
변경 범위
WeeklyRankingJobE2ETest,CommerceBatchApplicationTest수정전체 아키텍처
sequenceDiagram participant Sched as 스케줄러/수동 participant Batch as commerce-batch participant PM as product_metrics (일간 집계) participant MV as mv_product_rank_weekly / monthly participant API as commerce-api participant Client as 클라이언트 Note over Sched, MV: 쓰기 경로 (Batch) Sched->>Batch: bootRun --spring.batch.job.name=weeklyRankingJob --requestDate=20260412 Batch->>MV: Step 1. DELETE FROM mv WHERE yearWeek='2026W15' Batch->>PM: Step 2. SELECT ... ORDER BY score DESC LIMIT 100 (cursor) PM-->>Batch: rows (스트리밍) Batch->>Batch: Processor: 순위 부여 + yearWeek 계산 Batch->>MV: Writer: JPA batch insert (chunk=100) Note over API, Client: 읽기 경로 (API) Client->>API: GET /api/v1/rankings?period=weekly&date=20260412 API->>MV: SELECT ... WHERE year_week='2026W15' ORDER BY ranking ASC LIMIT size OFFSET ... MV-->>API: TOP N rows API->>API: 상품/브랜드 IN 쿼리로 Aggregation API-->>Client: 랭킹 + 상품/브랜드 정보쓰기 경로: Spring Batch Job
WeeklyRankingJobConfig — Job/Step 구성
—
WeeklyRankingJobConfig.java설계 포인트
@ConditionalOnProperty로 실행 대상 Job만 Bean 등록 → 여러 Job이 동시에 로드되어 오동작하는 것 방지@JobScope·@StepScope를 통한 Job Parameter late binding (#{jobParameters['requestDate']})RunIdIncrementer로 Spring Batch 내부 JobInstance 중복 회피, 비즈니스 멱등성은 별도 Clear Step이 담당Reader — JdbcCursorItemReader
—
WeeklyRankingJobConfig.java L113-136왜 JdbcCursorItemReader인가
ProductMetricsModelJPA 엔티티가 없습니다 (해당 엔티티는 commerce-api 소유)JpaPagingItemReader를 쓰려면 batch 모듈에도 엔티티를 중복 선언해야 함 → JDBC 커서로 raw SQL 직접 실행view * 0.1 + like * 0.2 + sales * 0.7Processor — 순위 부여 + yearWeek 계산
—
WeeklyRankingJobConfig.java L147-164Reader가 이미
score DESC로 정렬해서 내려주므로 Processor는 읽은 순서대로AtomicInteger로 1-based 순위를 부여합니다.@StepScope가 있어 Step마다 새 인스턴스가 생성되고 rankCounter가 0으로 리셋됩니다.ProductMetricsRow — Reader/Processor 간 DTO
—
ProductMetricsRow.javaJava record로 불변 + 한 파일 20줄. Reader의 RowMapper에서 생성되어 Processor로 흘러갑니다.
Writer — JpaItemWriter
—
WeeklyRankingJobConfig.java L168-176chunk 단위(=100)로
entityManager.persist()+flush()를 자동 수행합니다.hibernate.jdbc.batch_size와 맞추면 하나의 JDBC batch로 적재됩니다.Clear Tasklet — 멱등성 보장
—
ClearWeeklyRankingTasklet.javaWeekFields.ISO를 사용하여 ISO 8601 주차(월요일 시작, 1월 첫 목요일이 포함된 주 = 1주차)를 계산합니다. 단순getYear()를 쓰면 연말/연초 주차 경계에서 오류가 발생합니다 (예: 12월 31일이 다음 해 1주차에 속하는 경우).월간도 구조는 동일하고
yearMonth = dateStr.substring(0, 6)로 더 단순합니다.—
ClearMonthlyRankingTasklet.javaMaterialized View 엔티티
—
MvProductRankWeeklyModel.javaranking예약어(year_week, ranking)year_month컬럼명EXTRACT(YEAR_MONTH FROM ...)함수와의 파싱 혼동을 피하기 위해 월간도 backtick escape월간 버전:
MvProductRankMonthlyModel.java읽기 경로: Ranking API
MV 읽기 전용 엔티티 (@immutable)
—
MvProductRankWeeklyReadModel.java왜 엔티티를 2개 만드는가
@Immutable을 붙이면 Hibernate가 dirty checking을 건너뛰어 읽기 성능 최적화 + 쓰기 시도 시 SnapshotDiff 없음RankingService — 기간별 조회 분기
—
RankingService.javaAPI가 전달한
date=yyyyMMdd를 도메인 개념(yearWeek,yearMonth)으로 변환하는 책임은 Service 레이어가 집니다. 컨트롤러는 format만 검증합니다.RankingRedisRepository — DB-level Pagination
—
RankingRedisRepository.java L60-76개선 전 → 개선 후
findByYearWeekOrderByRankingAsc(yearWeek)→ TOP 100 전체 로딩findByYearWeekOrderByRankingAsc(yearWeek, PageRequest)subList(offset, offset+size)LIMIT size OFFSET offset코드 리뷰에서 발견한 메모리 페이징 이슈를
PageRequest.of()기반 DB-level pagination으로 수정했습니다.RankingFacade — Aggregation 로직 공통화
—
RankingFacade.javaBefore / After
assembleResponse()단일 메서드RankingV1Controller — period 분기 + 입력 검증
—
RankingV1Controller.javaClean Architecture에서 경계 검증은 interfaces 레이어의 책임이라는 컨벤션(
CLAUDE.md)을 따랐습니다. Facade는 이미 유효한 입력만 받는 전제에서 동작합니다.API 엔드포인트
/api/v1/rankings?period=daily&date=yyyyMMdd&size=20&page=1/api/v1/rankings?period=weekly&date=yyyyMMdd&size=20&page=1yearWeek)/api/v1/rankings?period=monthly&date=yyyyMMdd&size=20&page=1yearMonth)/api/v1/rankings/db?size=20&page=1🧪 테스트
테스트 매트릭스
WeeklyRankingJobE2ETest L62(batch)WeeklyRankingJobE2ETest L97(batch)CommerceBatchApplicationTest멱등성 E2E 테스트 — rerun_replacesOldData
—
WeeklyRankingJobE2ETest.java L97-127Clear Step이 먼저 기존
yearWeek="2026W15"데이터를 DELETE한 뒤 Aggregate Step이 새로 INSERT하므로, 두 번 실행해도 최종 상태는 동일 파라미터에 대해 동일한 결과가 나옵니다.테스트 시 만난 이슈와 해결
contextLoads()실패 — "No job found with name 'NONE'"spring.batch.job.name=NONEdefault가 JobLauncherApplicationRunner를 기동시킴@TestPropertySource("spring.batch.job.enabled=false")로 자동실행 비활성화rerun테스트JobInstanceAlreadyCompleteExceptionrun.id파라미터를 1차=10L, 2차=11L 명시적 부여product_metrics테이블 없음@BeforeEach에서JdbcTemplate으로 수동 생성—
WeeklyRankingJobE2ETest.java L43-60✅ 과제 체크리스트
--requestDate=yyyyMMddJob Parameter 기반mv_product_rank_weekly,mv_product_rank_monthlyTOP 100?period=분기WeekFields.ISO사용 (12/31 → 다음 해 1주차 케이스 처리)📁 파일 변경 요약
커밋 이력 (18개)
🔄 트레이드오프 분석
ProductMetricsModel엔티티 없음. 커서 스트리밍으로 충분@Immutable최적화@ConditionalOnPropertyWeekFields.ISOgetYear()문제점 해결 사례: 코드 리뷰에서 발견한 MySQL 예약어 이슈
초기 구현에서는 MV 테이블의
ranking컬럼을 별도 처리 없이 선언했는데, MySQL 8.0에서RANK()Window Function 도입과 함께RANK·RANKING이 예약어가 되었습니다. Hibernate가 생성하는 DDL에서 컬럼명이 escape 없이 나가면 다음과 같은 에러가 발생합니다.flowchart LR subgraph before["수정 전"] B1["@Column(nullable = false)\nprivate int ranking;"] B2["DDL: ranking INT"] B3["❌ SQL syntax error"] B1 --> B2 --> B3 end subgraph after["수정 후"] A1["@Column(name = "`ranking`", nullable = false)\nprivate int ranking;"] A2["DDL: `ranking` INT"] A3["✅ OK"] A1 --> A2 --> A3 end style B3 fill:#e57373,color:#fff style A3 fill:#81c784,color:#fff동시에 복합 인덱스도 컬럼명을 맞추지 않으면 DDL은 생성되지만 인덱스가 엉뚱한 컬럼을 바라보는 정적 버그가 발생합니다.
코드 리뷰에서 이 이슈를 발견해
fix커밋으로 수정했습니다. 프로덕션 배포 전 발견해서 다행이지만, **"Hibernate가 생성한 DDL이 실제로 인덱스를 제대로 만들었는지 검증하는 테스트"**의 필요성을 느꼈습니다.💬 리뷰 포인트
1. Chunk size는 어떤 기준으로 정하는 게 맞는지
제가 내린 답은 "지금은 LIMIT 100이라 100으로 두되, 확장 대비 가이드가 필요하다"입니다.
flowchart TD subgraph factors["Chunk size에 영향을 주는 요인"] F1["메모리\n크면 OOM 위험"] F2["커밋 주기\n작으면 I/O 왕복↑"] F3["롤백 범위\n크면 실패 시 손실↑"] F4["JDBC batch_size\nHibernate 설정과 연동"] end subgraph current["현재 선택"] C["chunk=100\n(LIMIT 100이라 1회 커밋으로 종료)"] end factors --> C style C fill:#f5a742,color:#fff현재는 LIMIT 100이 Reader의 한계이므로 chunk=100이면 Step당 1회 커밋이 일어나고 끝납니다. 하지만 향후 TOP 1,000 또는 TOP 10,000으로 확장되면 chunk size 선택이 실제 성능에 영향을 미치게 됩니다.
질문: 실무에서 chunk size를 정할 때 JDBC
hibernate.jdbc.batch_size와 맞추는 게 정석이라는 얘기를 봤는데, 그게 항상 옳은지 궁금합니다. 둘이 불일치할 경우 어떤 증상(하나의 chunk가 여러 JDBC batch로 쪼개져서 flush가 많아짐)이 나타나는지, 실제로 이 두 값을 어떻게 맞추고 계신지 멘토님의 경험이 궁금합니다.2. MV 재계산 주기 — 매일 전체 재계산 vs 증분 계산
현재는 매일(또는 매주/매월 마지막 날) 전체 재계산 방식입니다. 집계 규모가 커질수록 불리한 구조라는 생각이 들었습니다.
flowchart LR subgraph full["현재: 매번 전체 재계산"] S1["Clear\nDELETE 전체"] --> S2["Aggregate\nproduct_metrics 풀스캔\n→ LIMIT 100"] end subgraph delta["대안: 증분 계산"] D1["last_run_at 이후\n변경분만 읽기"] --> D2["기존 MV와 MERGE"] end full -->|"상품 수 증가 시 한계"| delta style full fill:#ef9a9a,color:#fff style delta fill:#a5d6a7,color:#ffflast_run_at관리 복잡, MERGE 시 정합성 이슈 가능현재 규모(상품 수천~수만)에선 전체 재계산이 단순해서 낫지만, 언제 증분 계산으로 전환해야 하는지 그 전환 임계점을 경험으로 가지고 있지 않습니다.
질문: 실무에서 배치 집계를 설계할 때 증분 방식으로 전환하는 **heuristic(소요 시간 > N분, 데이터 > N건, DB 부하 > N% 등)**이 있는지 궁금합니다. 그리고 Spring Batch 생태계에서 이를 구현할 때 흔히 쓰는 패턴(CDC, Event Sourcing,
last_run_at테이블 등)이 있는지도 궁금합니다.3. ZSET + MV 하이브리드 운영 시 점수 정합성 문제
일간은 실시간 ZSET, 주간·월간은 배치 MV — 이 두 경로가 같은 "가중치 0.1/0.2/0.7"을 쓰긴 하지만, ZSET은 이벤트 발생 시점마다 누적하고 MV는
product_metrics누적값으로부터 계산한다는 차이가 있습니다.flowchart TD subgraph daily["일간 (ZSET)"] E1["이벤트 발생"] --> Z["ZINCRBY\n실시간 누적"] end subgraph weekly["주간 (MV)"] PM["product_metrics\n누적 집계"] --> B["Batch\nORDER BY score"] end subgraph risk["정합성 리스크"] R1["ZSET: 하루 이벤트 실시간 합산"] R2["MV: product_metrics 누적값 집계"] R1 -.->|"동일 가중치지만\n집계 단위·주기 다름"| R2 end daily --> risk weekly --> risk style risk fill:#fff3e0,color:#333사용자 관점에서 "왜 어제까지 일간 3위였던 상품이 주간에선 50위?" 같은 혼란이 생길 수 있습니다. 원인은 ZSET은 하루치 이벤트만 반영하고, MV는 전체 누적값에서 상위 100을 뽑기 때문입니다. 둘 다 "랭킹"이지만 의미가 다른 지표입니다.
product_metrics에서 일간 필터링질문: 실무에서 이처럼 저장 방식이 다른 여러 랭킹이 공존할 때, 사용자 혼란을 어떻게 관리하시는지 궁금합니다. 일간·주간·월간의 산정 방식을 일부러 통일하는지(그래서 실시간성을 일부 양보하는지), 아니면 UI/설명으로 각각의 의미를 명확히 하는지 멘토님의 경험이 궁금합니다.
Spring Batch 기반 주간/월간 랭킹 MV 시스템 구현
변경 목적: Redis ZSET의 TTL 유지 비용 절감을 위해 주간/월간 랭킹을 주기적으로 사전 계산하여 DB 물리화 뷰(MV) 테이블에 저장하고, 기존 daily 랭킹 API를 확장하여 period 파라미터로 daily(Redis)/weekly/monthly(MV) 데이터 소스를 분기 제공.
핵심 변경점:
weeklyRankingJob,monthlyRankingJob두 개 추가. 각각 Clear step(기존 데이터 삭제) + Chunk-oriented aggregation step(product_metrics에서 TOP 100 읽기 → 가중치 점수 계산 → 순위 부여 → MV 테이블 적재) 구성mv_product_rank_weekly,mv_product_rank_monthly엔티티/테이블 신규 추가 (배치 모듈 및 API 모듈에서 각각 쓰기/읽기 전용 엔티티 분리 설계)GET /api/v1/rankings?period={daily|weekly|monthly}&date=yyyyMMdd&page=size=- 컨트롤러에서 period 값에 따라 daily(Redis ZSET) 또는 weekly/monthly(MV 테이블) 조회로 분기assembleResponse메서드로 추출하여 daily/weekly/monthly에서 공통 재사용테스트 및 검증:
spring.batch.job.enabled=false적용으로 테스트 시 배치 자동 실행 비활성화주의/확인사항: