-
Notifications
You must be signed in to change notification settings - Fork 35
Spring Batch 기반 집계 서비스 구현 #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
회원 가입시 User 저장이 수행된다. ( spy 검증 ) 이미 가입된 ID 로 회원가입 시도 시, 실패한다.
회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.
회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다.
해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다.
해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다. 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.
0 이하의 정수로 포인트를 충전 시 실패한다. 존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다. 존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다. 존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.
- 상품별 일간 메트릭을 저장하는 ProductMetricsEntity 추가 - 복합키를 사용하여 메트릭을 구분하는 ProductMetricsId 추가 - 상품 ID와 날짜로 메트릭을 조회하는 메서드 구현 - 메트릭 저장 및 조회 기능을 제공하는 ProductMetricsRepository 추가 - 메트릭 관련 비즈니스 로직을 처리하는 ProductMetricsService 추가 - 메트릭 엔티티에 대한 단위 테스트 추가
- 상품별 일간 메트릭을 저장하는 ProductMetricsEntity 추가 - 복합키를 사용하여 메트릭을 구분하는 ProductMetricsId 추가 - 상품 ID와 날짜로 메트릭을 조회하는 메서드 구현 - 메트릭 저장 및 조회 기능을 제공하는 ProductMetricsRepository 추가 - 메트릭 관련 비즈니스 로직을 처리하는 ProductMetricsService 추가 - 메트릭 엔티티에 대한 단위 테스트 추가
- 주간 및 월간 랭킹 집계 Job 설정 - 메트릭 데이터 처리기 및 점수 계산기 구현 - 랭킹 집계 및 저장 로직 추가 - 날짜 범위 파싱 유틸리티 구현 - 관련 테스트 코드 추가
- 월간 및 주간 랭킹 엔티티(MonthlyRankEntity, WeeklyRankEntity) 생성 - 각 엔티티에 대한 JPA Repository 및 서비스 구현 - 랭킹 데이터 저장 및 조회 기능 추가 - 관련 테스트 코드 작성
- rankPosition 필드 타입을 int에서 long으로 변경 - createdAt 필드 타입을 ZonedDateTime에서 LocalDateTime으로 변경 - 불필요한 메서드 주석 제거
- 일간, 주간, 월간 랭킹 조회 API 명세 및 구현 - 랭킹 데이터 처리 로직 추가 - 기존 코드 리팩토링 및 불필요한 메서드 제거
- rank_position을 base_rank_position으로 변경 - year_month를 base_year_month로 변경 - 로그 메시지 개선 및 예외 처리 강화
- 코드 가독성을 높이기 위해 불필요한 임포트 문을 제거 - 주석 및 공백 정리로 코드 일관성 향상
배치 서비스 구현
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the 요약Walkthrough기간별(일/주/월) 상품 순위 조회를 추가하고, 순위 집계용 배치 모듈과 관련 도메인/저장소/리더·라이터·프로세서·유틸을 도입하며, 스트리머의 메트릭스 모델을 일별 기반으로 리팩토링하고 GitHub Actions 워크플로우를 추가했습니다. Changes
Sequence DiagramssequenceDiagram
participant Client
participant API as RankingV1Controller
participant Facade as ProductFacade
participant Service as WeeklyRankingService
participant Repo as WeeklyRankRepository
participant DB as Database
Client->>API: GET /api/v1/rankings/period?period=WEEKLY&yearWeek=2024-W52
API->>Facade: getRankingProductsByPeriod(period=WEEKLY, yearWeek, pageable)
Facade->>Service: getWeeklyRankingProducts(pageable, yearWeek)
Service->>Repo: findByYearWeek(yearWeek, pageable)
Repo->>DB: SELECT ... WHERE year_week = ? ORDER BY rank_position
DB-->>Repo: Page<WeeklyRankEntity>
Repo-->>Service: Page<WeeklyRankEntity>
Service-->>Facade: Page<ProductInfo>
Facade-->>API: Page<ProductListResponse DTO>
API-->>Client: ApiResponse<PageResponse<...>>
sequenceDiagram
participant Scheduler
participant Job as MonthlyRankingJob
participant Reader as MonthlyMetricsReader
participant Aggregator as RankingAggregator
participant Processor as RankingProcessor
participant Writer as MonthlyRankWriter
participant MetricsDB as Metrics DB
participant RankDB as Ranking DB
Scheduler->>Job: Start(yearMonth)
Job->>Reader: read() 반복
Reader->>MetricsDB: aggregateByDateRange(startDate,endDate)
MetricsDB-->>Reader: List<Object[]>
Reader->>Aggregator: processRankings(rows)
Aggregator-->>Reader: List<RankingAggregation top100>
Job->>Processor: process(item)
Processor-->>Job: item (pass-through)
Job->>Writer: write(chunk)
Writer->>RankDB: deleteByIdYearMonth(yearMonth)
Writer->>RankDB: saveAll(MonthlyRankEntity...)
RankDB-->>Writer: 저장완료
Job-->>Scheduler: 완료
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java (1)
122-156: 순위 검증 로직에 잠재적 오류가 있습니다.Line 152-154의 assertion 순서를 확인해보면:
- Line 152:
content().get(0)→product1(1위) ✓- Line 153:
content().get(2)→product2(3위) ✓- Line 154:
content().get(1)→product3(2위) ✓하지만 given 섹션에서 점수를 보면:
- product1: PAYMENT_SUCCESS, score=100.0 (가중치 0.6 → 60.0)
- product2: LIKE_ACTION, score=10.0 (가중치 0.2 → 2.0)
- product3: PRODUCT_VIEW, score=50.0 (가중치 0.1 → 5.0)
실제 순서는 product1(60.0) > product3(5.0) > product2(2.0)가 되어야 합니다.
Line 153의 예상 순위가 잘못되었습니다.
🔎 수정 제안
assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(3), () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1), - () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(2).productId()).isEqualTo(product2), - () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3) + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(2).productId()).isEqualTo(product2) );
🧹 Nitpick comments (30)
.github/workflows/main.yml (3)
3-4: 트리거 이벤트에reopened추가를 고려하세요.현재 워크플로우는
opened와synchronize이벤트에만 반응합니다. PR이 닫혔다가 다시 열리는 경우(reopened)에도 PR Agent가 실행되도록 하려면 트리거 목록에 추가하는 것이 좋습니다.🔎 제안하는 수정
pull_request: - types: [opened, synchronize] + types: [opened, synchronize, reopened]
13-13: 환경 변수와 시크릿 이름 매핑 명확히 하기환경 변수
GITHUB_TOKEN이secrets.G_TOKEN으로 매핑되고 있습니다. 이는pr-agent액션이GITHUB_TOKEN을 기대하기 때문에 의도적인 것으로 보이지만, 일관성을 위해 고려할 사항이 있습니다. 다른 시크릿인OPENAI_KEY는 환경 변수 이름과 일치하므로,G_TOKEN매핑이 필요한 이유를 문서화하거나, 시크릿 이름을GITHUB_TOKEN으로 변경하는 것을 권장합니다.
10-10: 액션 버전을 명시적으로 고정하는 것을 권장합니다.
@main브랜치를 사용하면 예기치 않은 변경 사항이나 호환성 문제가 발생할 수 있습니다. 안정성을 위해@0.31과 같은 특정 버전 태그 또는 커밋 SHA를 사용하는 것이 좋습니다. 현재 최신 안정 버전은 v0.31입니다.apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (1)
21-24: System.exit() 사용 패턴 개선을 권장합니다.현재 구현은 배치 애플리케이션에서 일반적인 패턴이지만,
System.exit()를 명시적으로 호출하면 다음과 같은 단점이 있습니다:
- 통합 테스트에서 JVM이 종료되어 테스트 실행이 중단될 수 있음
- Spring의 graceful shutdown 메커니즘이 우회될 수 있음
개선 제안
프로덕션 환경에서만 System.exit()를 호출하도록 조건부로 처리하거나, exit code를 반환만 하고 JVM 종료는 외부에서 처리하도록 변경할 수 있습니다:
public static void main(String[] args) { - int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); - System.exit(exitCode); + SpringApplication.run(CommerceBatchApplication.class, args); }또는 프로파일 기반 조건부 종료:
public static void main(String[] args) { - int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); - System.exit(exitCode); + ConfigurableApplicationContext context = SpringApplication.run(CommerceBatchApplication.class, args); + if (!Arrays.asList(context.getEnvironment().getActiveProfiles()).contains("test")) { + int exitCode = SpringApplication.exit(context); + System.exit(exitCode); + } }apps/commerce-batch/src/main/resources/application.yml (2)
34-50: 프로덕션 프로파일 섹션에 명시적 설정 고려dev, qa, prd 프로파일 섹션이 활성화만 되어 있고 추가 설정이 없습니다. 다음 중 하나를 고려하세요:
- 각 환경별로 필요한 명시적 설정(예: 배치 청크 크기, 스레드 풀 설정 등)을 추가
- 의도적으로 비어있다면 주석으로 이유 명시
- 불필요하다면 섹션 제거
현재는 모든 설정이 임포트된 파일들에 의존하고 있어, 환경별 차이를 파악하기 어려울 수 있습니다.
52-54: SpringDoc 설정 중복 (선택적 정리 가능)
web-application-type: none으로 설정되어 있어 웹 서버가 없으므로, SpringDoc API 문서 설정은 실제로 영향을 주지 않습니다. 이 설정은 제거해도 무방하며, 설정 파일을 더 간결하게 유지할 수 있습니다.다만 명시적으로 비활성화를 표시하는 것도 나름의 의도가 있을 수 있으므로 큰 문제는 아닙니다.
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java (1)
25-25: 컬럼명 일관성 검토 (선택사항)
WeeklyRankId는 컬럼명으로year_week을 사용하는 반면, 여기서는base_year_month를 사용합니다. 네이밍에 특별한 이유가 없다면year_month로 통일하는 것이 일관성 측면에서 더 나을 수 있습니다.🔎 선택적 수정 제안
- @Column(name = "base_year_month", nullable = false, length = 7) + @Column(name = "year_month", nullable = false, length = 7)apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1)
27-39: 타입 안전성을 위해 Projection 인터페이스 사용을 권장합니다.현재
List<Object[]>반환 타입은 타입 안전성이 부족하고 캐스팅 오류가 발생할 수 있습니다. Spring Data JPA Projection을 사용하면 컴파일 타임에 타입 체크가 가능하고 가독성도 향상됩니다.🔎 Projection 인터페이스를 사용한 개선안
먼저 Projection 인터페이스를 정의합니다:
public interface ProductMetricsAggregation { Long getProductId(); Long getViewCount(); Long getLikeCount(); Long getSalesCount(); Long getOrderCount(); }그 다음 쿼리를 수정합니다:
- @Query(""" - SELECT m.id.productId, - SUM(m.viewCount), - SUM(m.likeCount), - SUM(m.salesCount), - SUM(m.orderCount) - FROM ProductMetricsEntity m - WHERE m.id.metricDate BETWEEN :startDate AND :endDate - GROUP BY m.id.productId - """) - List<Object[]> aggregateByDateRange( - @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate); + @Query(""" + SELECT m.id.productId as productId, + SUM(m.viewCount) as viewCount, + SUM(m.likeCount) as likeCount, + SUM(m.salesCount) as salesCount, + SUM(m.orderCount) as orderCount + FROM ProductMetricsEntity m + WHERE m.id.metricDate BETWEEN :startDate AND :endDate + GROUP BY m.id.productId + """) + List<ProductMetricsAggregation> aggregateByDateRange( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate);apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
31-36: 명시적인 타임존 사용을 권장합니다.현재
ZoneId.systemDefault()를 사용하고 있어 실행 환경에 따라 시간대가 달라질 수 있습니다. 배치 작업의 일관성을 위해 명시적인 타임존(예:Asia/Seoul)을 사용하는 것이 좋습니다.🔎 명시적 타임존 적용
+ private static final ZoneId BATCH_ZONE_ID = ZoneId.of("Asia/Seoul"); + @AfterJob void afterJob(JobExecution jobExecution) { var startTime = jobExecution.getExecutionContext().getLong("startTime"); var endTime = System.currentTimeMillis(); var startDateTime = Instant.ofEpochMilli(startTime) - .atZone(ZoneId.systemDefault()) + .atZone(BATCH_ZONE_ID) .toLocalDateTime(); var endDateTime = Instant.ofEpochMilli(endTime) - .atZone(ZoneId.systemDefault()) + .atZone(BATCH_ZONE_ID) .toLocalDateTime();apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java (1)
219-225: 과거 이벤트 무시 검증 로직을 개선할 수 있습니다.Line 219-225의 과거 이벤트 무시 테스트에서,
await().atMost(Duration.ofSeconds(2))로 짧은 대기 시간을 사용하고 있습니다. 하지만 비동기 이벤트 처리 환경에서는 타이밍 이슈로 인한 flaky test가 발생할 수 있습니다.더 명확한 검증을 위해 다음을 고려해보세요:
- 대기 시간을 늘리거나
- 과거 이벤트가 처리되지 않았음을 명시적으로 확인하는 추가 assertion 추가
🔎 개선 제안
- // Then - 조회수는 여전히 1이어야 함 (과거 이벤트 무시) - await().atMost(Duration.ofSeconds(2)) + // Then - 과거 이벤트 처리를 충분히 기다린 후, 조회수는 여전히 1이어야 함 + await().atMost(Duration.ofSeconds(5)) .until(() -> { Optional<ProductMetricsEntity> finalMetrics = productMetricsRepository .findByProductIdAndMetricDate(productId, today); return finalMetrics.isPresent() && finalMetrics.get().getViewCount() == 1L; }); + + // 과거 이벤트가 멱등성 테이블에만 기록되고 메트릭에는 영향 없음을 확인 + assertThat(eventRepository.existsById(oldEventId)).isTrue();modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.java (1)
36-44: 생성 타임스탬프 필드 검증 누락AI 요약에 따르면
create메서드는 생성 타임스탬프 필드(createdAt,updatedAt)도 초기화하는 것으로 보입니다. 엔티티 생성 테스트에서 이러한 필드들이 null이 아닌지 검증하는 assertion을 추가하는 것을 고려해보세요.🔎 제안하는 개선사항
assertThat(entity.getViewCount()).isZero(); assertThat(entity.getLikeCount()).isZero(); assertThat(entity.getSalesCount()).isZero(); assertThat(entity.getOrderCount()).isZero(); + assertThat(entity.getCreatedAt()).isNotNull(); + assertThat(entity.getUpdatedAt()).isNotNull(); }apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)
34-41: 에러 로깅 레벨 검토 필요에러 발생 시
log.info()를 사용하고 있습니다. 일반적으로 에러는log.error()레벨로 로깅하는 것이 모니터링 및 알림 설정에 더 적합합니다. 특히 Line 42의 주석에서 언급된 것처럼 Slack 등 외부 채널로 에러를 전송할 계획이라면, ERROR 레벨 로그를 기반으로 알림을 설정하는 것이 표준적인 접근 방식입니다.🔎 제안하는 개선사항
- log.info( + log.error( """ [에러 발생] jobName: {} exceptions: {} """.trim(), jobName, exceptions );apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java (1)
69-72: 카운트 조회 최적화 권장전체 데이터를 로드한 후 크기를 반환하는 대신, 리포지토리에 전용 카운트 쿼리 메서드(
countByYearWeek)를 추가하는 것이 더 효율적입니다. 현재는 TOP 100 제약으로 영향이 제한적이지만, 이는 모범 사례가 아닙니다.🔎 제안하는 개선사항
리포지토리에 카운트 메서드를 추가하세요:
// WeeklyRankRepository 또는 WeeklyRankJpaRepository에 추가 long countByYearWeek(String yearWeek);그런 다음 서비스 메서드를 업데이트하세요:
public long getWeeklyRankingCount(String yearWeek) { - List<WeeklyRankEntity> rankings = weeklyRankRepository.findByYearWeek(yearWeek); - return rankings.size(); + return weeklyRankRepository.countByYearWeek(yearWeek); }apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (2)
36-39: 불필요한 빈 메서드 제거빈
beforeEach메서드는 사용되지 않으므로 제거하는 것이 좋습니다.🔎 제안: 빈 메서드 제거
- @BeforeEach - void beforeEach() { - - } -
41-55: 테스트 메서드명이 실제 검증 내용과 불일치메서드명
shouldNotSaveCategories_whenApiError는 카테고리 저장 및 API 에러를 암시하지만, 실제로는requestDate파라미터 누락 시 배치 실패를 검증하고 있습니다. 메서드명을shouldFailJob_whenRequestDateParameterIsMissing과 같이 변경하는 것이 명확합니다.추가로, 두 테스트 모두에서
jobLauncherTestUtils.setJob(job)를 호출하고 있어beforeEach로 추출할 수 있습니다.🔎 제안: 메서드명 변경 및 공통 설정 추출
+ @BeforeEach + void beforeEach() { + jobLauncherTestUtils.setJob(job); + } + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") @Test - void shouldNotSaveCategories_whenApiError() throws Exception { + void shouldFailJob_whenRequestDateParameterIsMissing() throws Exception { // arrange - jobLauncherTestUtils.setJob(job); // act var jobExecution = jobLauncherTestUtils.launchJob();apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
224-258: 주간 랭킹 조회 로직 구현 확인 및 코드 중복 제거 권장주간 랭킹 조회 로직이 정상적으로 구현되었습니다. 다만 상품 정보 조회 및 정렬 로직(lines 238-254)이 월간 랭킹 메서드(lines 282-298) 및 일간 랭킹 메서드(lines 177-184)와 거의 동일합니다.
이 중복 로직을 별도 private 메서드로 추출하면 유지보수성이 향상됩니다.
🔎 제안: 공통 정렬 로직 추출
공통 private 메서드 추가:
/** * 랭킹 순서에 따라 상품 정보를 정렬합니다. * * @param productIds 정렬 기준 상품 ID 목록 * @param products 조회된 상품 MV 엔티티 목록 * @return 정렬된 ProductInfo 목록 */ private List<ProductInfo> sortProductsByRankingOrder( List<Long> productIds, List<ProductMaterializedViewEntity> products) { return productIds.stream() .map(productId -> products.stream() .filter(p -> p.getProductId().equals(productId)) .findFirst() .map(ProductInfo::from) .orElse(null)) .filter(Objects::nonNull) .collect(Collectors.toList()); }주간 랭킹 메서드에 적용:
// 3. 상품 정보 조회 (MV 사용) List<ProductMaterializedViewEntity> products = mvService.getByIds(productIds); // 4. 랭킹 순서대로 정렬 - List<ProductInfo> sortedProducts = productIds.stream() - .map(productId -> products.stream() - .filter(p -> p.getProductId().equals(productId)) - .findFirst() - .map(ProductInfo::from) - .orElse(null)) - .filter(Objects::nonNull) - .collect(Collectors.toList()); + List<ProductInfo> sortedProducts = sortProductsByRankingOrder(productIds, products); // 5. Page 객체 생성 return new PageImpl<>(sortedProducts, pageable, weeklyRankings.getTotalElements());월간 랭킹 메서드에도 동일하게 적용할 수 있습니다.
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (2)
29-31: 로깅 프레임워크 사용 권장
System.out.println대신 로거를 사용하는 것이 좋습니다. 데모 코드이지만 일관된 로깅 방식을 유지하면 나중에 확장할 때 유리합니다.🔎 제안: 로거 사용
클래스에
@Slf4j추가 (이미 import되어 있음):+import lombok.extern.slf4j.Slf4j; + @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) @RequiredArgsConstructor +@Slf4j @Component public class DemoTasklet implements Tasklet {System.out을 로거로 변경:
if (requestDate == null) { throw new RuntimeException("requestDate is null"); } - System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + log.info("Demo Tasklet 실행 (실행 일자 : {})", requestDate); Thread.sleep(1000); - System.out.println("Demo Tasklet 작업 완료"); + log.info("Demo Tasklet 작업 완료"); return RepeatStatus.FINISHED;
14-20: 불필요한 @requiredargsconstructor 제거클래스에 생성자 주입이 필요한 final 필드가 없어
@RequiredArgsConstructor가 불필요합니다.🔎 제안: 불필요한 어노테이션 제거
-import lombok.RequiredArgsConstructor; - @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) -@RequiredArgsConstructor @Component public class DemoTasklet implements Tasklet {apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.java (1)
19-32: 추가 경계 사례 테스트를 고려하세요.테스트 커버리지가 전반적으로 우수하지만, 다음과 같은 추가 경계 사례를 고려할 수 있습니다:
- ISO 8601 주차 시스템에서 일부 연도에 존재하는 53주차 (예: "2020-W53")
- 주차 형식 변형 (예: "2024-W01" vs "2024-W1")
- 연도의 첫 주와 마지막 주가 전년도/다음년도에 걸쳐 있는 경우
현재 테스트는 기본 시나리오를 잘 다루고 있으나, 이러한 경계 사례를 추가하면 더 견고한 검증이 가능합니다.
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java (2)
20-23: 조회 메서드에 정렬 순서 명시를 고려하세요.
findByYearWeek메서드가 랭킹 목록을 반환하지만, JavaDoc이나 메서드 시그니처에서 정렬 순서를 명시하지 않고 있습니다. 랭킹 데이터의 경우 순위 순서가 중요하므로, 메서드명이나 JavaDoc에 정렬 기준(예: 순위 오름차순)을 명시하는 것이 좋습니다.예:
findByYearWeekOrderByRankPosition또는 JavaDoc에 "@return 순위 순으로 정렬된 랭킹 목록" 추가
25-28: 페이지네이션 추상화 일관성을 확인하세요.
findByYearWeekWithPagination메서드가 원시 타입int page, int size를 사용하고 있습니다. Spring Data의Pageable추상화를 사용하는 것이 더 일관되고 유연한 접근 방식일 수 있습니다 (정렬 옵션 포함 가능).학습 컨텍스트에 따르면 이 코드베이스에서 도메인 레포지토리에 Spring Data 타입 사용이 허용되므로,
Pageable을 고려해볼 수 있습니다.Based on learnings, Spring Data types like Pageable are acceptable in domain repositories.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
32-46: 기간별 파라미터 설계를 재검토하세요.
getRankingProductsByPeriod메서드가date,yearWeek,yearMonth세 개의 파라미터를 모두 받고 있습니다.RankingPeriod에 따라 실제로는 하나의 파라미터만 사용되므로, 이는 API 사용자에게 혼란을 줄 수 있습니다.더 명확한 설계 옵션:
- 기간별로 별도의 API 엔드포인트 분리 (
/rankings/weekly?yearWeek=...,/rankings/monthly?yearMonth=...)- 단일 파라미터로 통합 (예:
String period- "2024-W52" 또는 "2024-12" 형식)- Request DTO 객체로 캡슐화하여 유효성 검증 로직 명확화
현재 설계는 구현부에서 복잡한 조건부 로직과 파라미터 검증이 필요하며, API 문서만으로는 어떤 파라미터 조합이 유효한지 불명확합니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java (1)
22-29: 쿼리 중복 제거를 고려하세요 (선택 사항)두 메서드 모두 동일한 JPQL 쿼리를 사용하고 있습니다. Spring Data JPA의 메서드 이름 기반 쿼리 파생 기능을 활용하면
@Query어노테이션 없이도 동일한 기능을 구현할 수 있습니다.🔎 제안: 메서드 이름 기반 쿼리 파생 활용
- @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") - List<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek); + List<WeeklyRankEntity> findByIdYearWeekOrderByRankPositionAsc(String yearWeek); - @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") - List<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); + List<WeeklyRankEntity> findByIdYearWeekOrderByRankPositionAsc(String yearWeek, Pageable pageable);또는 명시적 쿼리를 유지하고 싶다면 상수로 추출하여 중복을 제거할 수 있습니다.
apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java (2)
35-61: 페이징 처리 방식 검토 (권장 사항)전체 랭킹 데이터를 조회한 후 메모리에서 페이징 처리하고 있습니다. 배치에서 TOP 100으로 제한되므로 현재는 성능 영향이 크지 않지만, 데이터베이스 레벨 페이징을 활용하면 더 효율적일 수 있습니다.
참고로
WeeklyRankingService도 동일한 패턴을 사용하고 있어 일관된 아키텍처 선택으로 보입니다.🔎 선택적 개선: 데이터베이스 레벨 페이징 활용
MonthlyRankJpaRepository에서 Pageable을 직접 처리하도록 수정하면 데이터베이스 레벨 페이징의 이점을 활용할 수 있습니다:
public Page<MonthlyRankEntity> getMonthlyRanking(String yearMonth, Pageable pageable) { log.debug("월간 랭킹 조회: yearMonth={}, page={}, size={}", yearMonth, pageable.getPageNumber(), pageable.getPageSize()); // Repository에서 직접 Page 반환 Page<MonthlyRankEntity> rankings = monthlyRankRepository.findByYearMonth(yearMonth, pageable); log.debug("월간 랭킹 조회 완료: yearMonth={}, 전체={}, 페이지={}", yearMonth, rankings.getTotalElements(), rankings.getNumberOfElements()); return rankings; }
69-72: 카운트 쿼리 최적화 권장전체 엔티티를 조회한 후 크기를 반환하는 것보다 카운트 쿼리를 사용하는 것이 더 효율적입니다.
🔎 제안: Repository에 카운트 메서드 추가
MonthlyRankRepository에 카운트 전용 메서드를 추가하세요:
// Repository 인터페이스에 추가 long countByYearMonth(String yearMonth);서비스 메서드 수정:
public long getMonthlyRankingCount(String yearMonth) { - List<MonthlyRankEntity> rankings = monthlyRankRepository.findByYearMonth(yearMonth); - return rankings.size(); + return monthlyRankRepository.countByYearMonth(yearMonth); }apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
17-47: 구현이 정확하며 표준 리포지토리 패턴을 잘 따르고 있습니다.위임 패턴이 올바르게 구현되어 있고, 페이지네이션 처리도 적절합니다.
다만
MonthlyRankRepositoryImpl과 구조가 거의 동일하여 코드 중복이 있습니다. 현재는 주간/월간 두 개만 있어서 수용 가능하지만, 향후 기간 유형이 추가된다면 제네릭 기반의 추상 클래스로 공통 로직을 추출하는 것을 고려해볼 수 있습니다.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (1)
46-78: Job 및 Step 구성이 적절합니다.
CHUNK_SIZE = 100을 TOP 100 집계에 맞춰 설정한 것은 합리적입니다. Reader에서 이미 TOP 100으로 필터링된 데이터를 한 번에 처리하는 구조로 이해됩니다.다만
WeeklyRankingJobConfig와 구조가 거의 동일합니다. 현재는 두 개의 Job만 있어 수용 가능하지만, 향후 유사한 Job이 추가될 경우 공통 설정을 추상화하는 것을 고려할 수 있습니다.향후 확장성을 위한 선택적 개선 사항
만약 TOP 100 외의 다른 규모의 데이터를 처리하게 될 경우를 대비해, CHUNK_SIZE를 application.yml 등에서 설정 가능하도록 외부화하는 것도 고려해볼 수 있습니다.
- private static final int CHUNK_SIZE = 100; + @Value("${batch.ranking.chunk-size:100}") + private int chunkSize;apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
48-65: 기간별 랭킹 엔드포인트의 파라미터 처리가 비효율적입니다.현재 구현에서는
period값에 관계없이processYearWeekParameter와processYearMonthParameter를 모두 호출합니다. 이는 불필요한 연산이며, 각 기간 유형에 따라 필요한 파라미터만 처리하는 것이 더 명확합니다.🔎 제안하는 개선
public ApiResponse<PageResponse<ProductV1Dtos.ProductListResponse>> getRankingProductsByPeriod( @RequestParam RankingPeriod period, @PageableDefault(size = 20) Pageable pageable, @RequestParam(required = false) LocalDate date, @RequestParam(required = false) String yearWeek, @RequestParam(required = false) String yearMonth ) { - // Java 8 Date API를 활용한 파라미터 검증 및 변환 - String processedYearWeek = processYearWeekParameter(yearWeek, date); - String processedYearMonth = processYearMonthParameter(yearMonth, date); + // 기간 유형에 따라 필요한 파라미터만 처리 + String processedYearWeek = period == RankingPeriod.WEEKLY + ? processYearWeekParameter(yearWeek, date) + : null; + String processedYearMonth = period == RankingPeriod.MONTHLY + ? processYearMonthParameter(yearMonth, date) + : null; Page<ProductInfo> products = productFacade.getRankingProductsByPeriod( period, pageable, date, processedYearWeek, processedYearMonth);apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1)
44-59: 집계 쿼리가 올바르게 구현되어 있습니다.GROUP BY 및 SUM 집계 로직이 정확하며, 복합 키 필드 접근도 올바릅니다.
다만
List<Object[]>반환 타입은 타입 안정성이 부족합니다. 향후 유지보수성을 높이기 위해 전용 DTO나 record 클래스를 사용하는 것을 고려할 수 있습니다.선택적 개선: 타입 안전한 프로젝션
// 예시: 집계 결과용 record public record ProductMetricsAggregation( Long productId, Long totalViews, Long totalLikes, Long totalSales, Long totalOrders ) {} // 쿼리 변경 @Query(""" SELECT new com.loopers.domain.metrics.ProductMetricsAggregation( m.id.productId, SUM(m.viewCount), SUM(m.likeCount), SUM(m.salesCount), SUM(m.orderCount)) FROM ProductMetricsEntity m WHERE m.id.metricDate BETWEEN :startDate AND :endDate GROUP BY m.id.productId """) List<ProductMetricsAggregation> aggregateByDateRange(...);apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (1)
49-74: 집계 로직이 올바르게 구현되었습니다.날짜 범위 파싱 → DB 집계 → 랭킹 처리 → Iterator 생성의 흐름이 명확하며, 예외 처리 시
yearWeek컨텍스트를 로그에 포함하는 것도 좋습니다.다만, 대규모 데이터셋에 대한 확장성을 고려하시기 바랍니다. 61번 라인에서
aggregateByDateRange()가 모든 집계 결과를 메모리에 로드한 후 TOP 100으로 필터링합니다. 현재 주간 집계(7일) 규모에서는 문제없지만, 향후 상품 수가 수십만 건 이상으로 증가할 경우 다음을 고려해보세요:
- Repository 레벨에서 TOP N 쿼리 최적화 (LIMIT 절 활용)
- 페이징 또는 스트리밍 방식의 집계 처리
- 메모리 사용량 모니터링 및 알림 설정
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (2)
modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankEntity.javais excluded by!**/generated/**modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankId.javais excluded by!**/generated/**
📒 Files selected for processing (74)
.github/workflows/main.ymlREADME.mdapps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.javaapps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.javaapps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.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-api/src/main/java/com/loopers/support/Uris.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.javaapps/commerce-batch/build.gradle.ktsapps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.javaapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsId.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.javamodules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.javamodules/jpa/src/test/java/com/loopers/domain/ranking/MonthlyRankEntityUnitTest.javamodules/jpa/src/test/java/com/loopers/domain/ranking/WeeklyRankEntityUnitTest.javasettings.gradle.kts
💤 Files with no reviewable changes (5)
- apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
- apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
- apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java
🧰 Additional context used
🧠 Learnings (11)
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-batch/src/main/resources/application.yml
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java
📚 Learning: 2026-01-01T14:12:04.385Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/ChunkListener.kt:4-5
Timestamp: 2026-01-01T14:12:04.385Z
Learning: In Spring Batch 6.x, ChunkListener API changed from using ChunkContext to Chunk<T> parameters. The correct method signatures are: beforeChunk(Chunk<T> chunk), afterChunk(Chunk<T> chunk), and onChunkError(Exception exception, Chunk<T> chunk). The import path is org.springframework.batch.core.listener.ChunkListener.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
📚 Learning: 2026-01-01T14:12:07.943Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/JobListener.kt:4-5
Timestamp: 2026-01-01T14:12:07.943Z
Learning: Spring Batch 6.x에서 JobExecution은 org.springframework.batch.core.job 패키지에, JobExecutionListener는 org.springframework.batch.core.listener 패키지에 위치합니다. Spring Batch 5.x와 달리 하위 패키지를 사용합니다.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
📚 Learning: 2025-12-22T16:33:50.678Z
Learnt from: jikimee64
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 71
File: apps/commerce-streamer/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt:167-173
Timestamp: 2025-12-22T16:33:50.678Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository, using system default timezone (ZonedDateTime.now() without explicit ZoneId) in date key calculations is an intentional design choice. The deployment ensures all instances share the same timezone configuration.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
📚 Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.
Applied to files:
modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.
Applied to files:
modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsId.javamodules/jpa/src/test/java/com/loopers/domain/ranking/MonthlyRankEntityUnitTest.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javamodules/jpa/src/test/java/com/loopers/domain/ranking/WeeklyRankEntityUnitTest.java
📚 Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java
📚 Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.
Applied to files:
modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javaapps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
📚 Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService에서 상품 목록 조회 시 Redis 캐시를 적용했으며, 캐시 키는 brandId, sortType, pageNumber, pageSize의 조합으로 구성되고 TTL은 5분으로 설정되어 있다.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
🧬 Code graph analysis (17)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
ProductV1Dtos(10-168)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
ConditionalOnProperty(21-50)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (1)
Configuration(29-79)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java (3)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
Component(14-73)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (1)
Slf4j(25-75)
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java (1)
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java (1)
Entity(19-96)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (2)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(17-48)apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
Repository(17-48)
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java (1)
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java (1)
Embeddable(16-36)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
ConditionalOnProperty(21-50)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (4)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(17-48)apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
Repository(17-48)apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(17-48)apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
Repository(17-48)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
Component(14-73)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java (1)
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java (1)
Embeddable(16-36)
apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java (1)
apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java (1)
Service(20-73)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankWriter.java (7)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.java (1)
Component(13-25)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
Component(14-73)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (1)
Slf4j(25-75)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.java (1)
Slf4j(23-79)apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
Slf4j(10-22)apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
Slf4j(15-54)
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java (2)
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java (4)
DisplayName(10-173)Nested(15-87)Nested(89-150)Nested(152-172)apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java (3)
DisplayName(8-102)Nested(13-83)Nested(85-101)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
ConditionalOnProperty(21-50)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (1)
Configuration(30-80)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java (2)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(13-85)apps/commerce-api/src/main/java/com/loopers/support/Uris.java (3)
Uris(9-116)Ranking(85-92)Product(73-80)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)
Slf4j(16-47)
apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java
Show resolved
Hide resolved
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
Show resolved
Hide resolved
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java
Show resolved
Hide resolved
.../commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java
Outdated
Show resolved
Hide resolved
| public LocalDate[] parseYearWeek(String yearWeek) { | ||
| if (yearWeek == null || !yearWeek.matches("\\d{4}-W\\d{1,2}")) { | ||
| throw new IllegalArgumentException( | ||
| String.format("잘못된 yearWeek 형식입니다. 예상: '2024-W52', 실제: '%s'", yearWeek)); | ||
| } | ||
|
|
||
| try { | ||
| String[] parts = yearWeek.split("-W"); | ||
| int year = Integer.parseInt(parts[0]); | ||
| int week = Integer.parseInt(parts[1]); | ||
|
|
||
| // ISO 주차 시스템 사용 (월요일 시작) | ||
| WeekFields weekFields = WeekFields.ISO; | ||
| LocalDate startOfWeek = LocalDate.of(year, 1, 1) | ||
| .with(weekFields.weekOfYear(), week) | ||
| .with(weekFields.dayOfWeek(), 1); | ||
| LocalDate endOfWeek = startOfWeek.plusDays(6); | ||
|
|
||
| return new LocalDate[]{startOfWeek, endOfWeek}; | ||
| } catch (Exception e) { | ||
| throw new IllegalArgumentException( | ||
| String.format("yearWeek 파싱 중 오류가 발생했습니다: %s", yearWeek), e); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
fd -t f "DateRangeParserUnitTest" --extension javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 188
🏁 Script executed:
git ls-files | grep -i "daterangeparer"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
cat -n apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 6895
🏁 Script executed:
# Verify if any tests check year boundary week calculations
rg -n "2023-W|2025-W|week.*boundary|span.*year" apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Check if there are any other test files for DateRangeParser
fd -t f ".*Test.*" apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ | grep -i "daterange"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 188
🏁 Script executed:
# Create a test to verify actual ISO week behavior for edge cases
python3 << 'EOF'
from datetime import datetime, timedelta
import sys
# Check actual ISO week behavior
test_cases = [
"2024-W01", # First week of 2024
"2023-W01", # First week of 2023
"2025-W01", # First week of 2025
"2024-W52", # Last week before potential W53
]
# For Java's LocalDate.of(year, 1, 1).with(weekFields.weekOfYear(), week)
# The behavior depends on ISO week fields
# ISO 8601: Week 1 is the week containing the first Thursday of the year
# OR equivalently: the week containing Jan 4
import calendar
for case in test_cases:
year_str, week_str = case.split('-W')
year = int(year_str)
week = int(week_str)
# In ISO 8601, week 1 contains Jan 4
jan4 = datetime(year, 1, 4)
iso_calendar = jan4.isocalendar()
# Get the Monday of that ISO week
# ISO calendar returns (ISO year, ISO week, ISO weekday 1-7 where 1=Mon)
days_since_monday = (jan4.weekday()) # 0=Mon, 6=Sun
monday_of_week_with_jan4 = jan4 - timedelta(days=days_since_monday)
print(f"\n{case}:")
print(f" Jan 4, {year}: {jan4.strftime('%A, %B %d')} (ISO: {iso_calendar})")
# Now calculate Monday of the requested week
# If week 1 contains Jan 4, then Monday of week 1 is up to 3 days before Jan 4
monday_week1 = jan4 - timedelta(days=jan4.weekday()) # Go to Monday
# Calculate Monday of requested week
monday_of_week = monday_week1 + timedelta(weeks=week-1)
friday_of_week = monday_of_week + timedelta(days=6)
print(f" Week {week} Monday: {monday_of_week.strftime('%Y-%m-%d')} ({monday_of_week.strftime('%A')})")
print(f" Week {week} Sunday: {friday_of_week.strftime('%Y-%m-%d')} ({friday_of_week.strftime('%A')})")
EOFRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 174
🏁 Script executed:
# Let me verify the ISO week calculation logic more directly
# Check what the test expects vs potential issues
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2036
🏁 Script executed:
# Search for any documentation or comments about edge cases in the implementation
rg -A 5 -B 5 "ISO|week.*edge|53" apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 577
ISO 주차 계산의 엣지 케이스에 대한 테스트 커버리지 부족
현재 단위 테스트는 2024-W1과 2024-W52만 검증하고 있으며, ISO 주차 시스템의 중요한 엣지 케이스가 누락되었습니다:
- 연도 경계 사례: ISO 주차는 연도를 가로질러 확장됩니다. 예를 들어 2023-W1은 2022년 12월 26일부터 시작하지만, 현재 테스트에는 다른 연도의 첫 주 또는 마지막 주에 대한 검증이 없습니다.
- 주차 53 검증 부재: 주차 53이 존재하는 연도와 존재하지 않는 연도를 구분하여 테스트하지 않습니다.
다음 케이스에 대한 테스트를 추가하세요:
2023-W1(2022년 12월 날짜 포함 확인)2025-W1(연도 경계 재검증)2024-W53(일부 연도에만 존재)- 주차 53이 존재하지 않는 연도의 W53 요청 시 예외 처리 검증
현재 구현이 이들 케이스를 올바르게 처리하는지 확인되지 않았습니다.
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java
around lines 24 to 47, add unit tests and validation to cover ISO week edge
cases: write tests for 2023-W1 (should return start 2022-12-26), 2025-W1 (verify
correct boundary behavior), 2024-W53 (verify it returns the correct week when
present), and a case where W53 is requested for a year without 53 weeks (expect
IllegalArgumentException). Update the parser to validate that the parsed week
number is within the actual range for the given year using
WeekFields/TemporalField or Year/Week-based checks and throw a clear
IllegalArgumentException for invalid week numbers before computing start/end
dates.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
Show resolved
Hide resolved
| /** | ||
| * 좋아요 수 변경 | ||
| * <p> | ||
| * 좋아요 수는 0 미만으로 내려가지 않도록 보장합니다. | ||
| * | ||
| * @param delta 변경량 (양수: 증가, 음수: 감소) | ||
| * @param eventTime 이벤트 발생 시간 | ||
| */ | ||
| public void applyLikeDelta(int delta, ZonedDateTime eventTime) { | ||
| long next = this.likeCount + delta; | ||
| this.likeCount = Math.max(0, next); | ||
| this.lastEventAt = eventTime; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋아요 증감 로직이 올바르게 구현되어 있습니다.
Math.max(0, next)를 사용하여 음수 방지 로직이 잘 적용되어 있습니다. 좋아요 증가/취소 모두를 하나의 메서드로 처리하는 것도 좋은 설계입니다.
다만 Line 105에서 eventTime이 null인 경우 NPE가 발생할 수 있습니다. incrementView 및 addSales에도 동일한 이슈가 있습니다. 파라미터에 @Nonnull 어노테이션을 추가하거나, 메서드 시작 부분에서 null 체크를 수행하는 것을 권장합니다.
🔎 제안하는 개선
생성자와 동일하게 null 검증 추가:
public void applyLikeDelta(int delta, ZonedDateTime eventTime) {
+ Objects.requireNonNull(eventTime, "이벤트 시간은 필수입니다.");
long next = this.likeCount + delta;
this.likeCount = Math.max(0, next);
this.lastEventAt = eventTime;
}동일한 검증을 incrementView와 addSales에도 적용하세요.
📝 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.
| /** | |
| * 좋아요 수 변경 | |
| * <p> | |
| * 좋아요 수는 0 미만으로 내려가지 않도록 보장합니다. | |
| * | |
| * @param delta 변경량 (양수: 증가, 음수: 감소) | |
| * @param eventTime 이벤트 발생 시간 | |
| */ | |
| public void applyLikeDelta(int delta, ZonedDateTime eventTime) { | |
| long next = this.likeCount + delta; | |
| this.likeCount = Math.max(0, next); | |
| this.lastEventAt = eventTime; | |
| } | |
| public void applyLikeDelta(int delta, ZonedDateTime eventTime) { | |
| Objects.requireNonNull(eventTime, "이벤트 시간은 필수입니다."); | |
| long next = this.likeCount + delta; | |
| this.likeCount = Math.max(0, next); | |
| this.lastEventAt = eventTime; | |
| } |
🤖 Prompt for AI Agents
In
modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
around lines 94 to 106, the method applyLikeDelta accepts eventTime which can be
null and cause an NPE; add input validation by either annotating eventTime with
@Nonnull and updating imports, or explicitly checking eventTime for null at the
start of the method and throw a clear IllegalArgumentException (or default to
ZonedDateTime.now() per project convention), and apply the same
null-check/annotation pattern to incrementView and addSales to ensure consistent
null-safety.
| /** | ||
| * 기간별 상품 집계 (배치용 - GROUP BY) | ||
| * | ||
| * @param startDate 시작 날짜 | ||
| * @param endDate 종료 날짜 | ||
| * @return 집계 결과 (productId, viewCount, likeCount, salesCount, orderCount) | ||
| */ | ||
| List<Object[]> aggregateByDateRange(LocalDate startDate, LocalDate endDate); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Domain 계층에서 Object[] 반환은 추상화 누수입니다.
aggregateByDateRange 메서드가 List<Object[]>를 반환하는 것은 구현 세부사항(JPQL/SQL raw result)을 Domain 계층에 노출시킵니다. 이는 Clean Architecture 원칙을 위배하며, 타입 안정성도 보장하지 못합니다.
대신 집계 결과를 표현하는 전용 DTO를 정의하여 반환하는 것을 권장합니다.
🔎 제안하는 리팩토링
먼저 집계 결과를 위한 DTO를 정의합니다:
package com.loopers.domain.metrics;
public class ProductMetricsAggregation {
private final Long productId;
private final long viewCount;
private final long likeCount;
private final long salesCount;
private final long orderCount;
public ProductMetricsAggregation(Long productId, long viewCount,
long likeCount, long salesCount, long orderCount) {
this.productId = productId;
this.viewCount = viewCount;
this.likeCount = likeCount;
this.salesCount = salesCount;
this.orderCount = orderCount;
}
// getters...
}그 다음 메서드 시그니처를 변경합니다:
/**
* 기간별 상품 집계 (배치용 - GROUP BY)
*
* @param startDate 시작 날짜
* @param endDate 종료 날짜
- * @return 집계 결과 (productId, viewCount, likeCount, salesCount, orderCount)
+ * @return 집계 결과
*/
- List<Object[]> aggregateByDateRange(LocalDate startDate, LocalDate endDate);
+ List<ProductMetricsAggregation> aggregateByDateRange(LocalDate startDate, LocalDate endDate);Infrastructure 계층에서는 JPQL 생성자 표현식을 사용하여 구현할 수 있습니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
around lines 52-59, the method currently returns List<Object[]> which leaks
persistence implementation details into the domain; define a domain DTO (e.g.,
com.loopers.domain.metrics.ProductMetricsAggregation with fields productId,
viewCount, likeCount, salesCount, orderCount and appropriate
constructor/getters) and change the repository method signature to return
List<ProductMetricsAggregation>; update the JPA/infrastructure implementation to
project results into that DTO (using JPQL constructor expression or mapping each
result row to the DTO) so the domain API is type-safe and free of raw Object[]
arrays.
| @Column(name = "base_rank_position", nullable = false) | ||
| private long rankPosition; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
rankPosition 타입 불일치를 정리해 주세요.
rankPosition 필드는 long 타입(Line 48)으로 선언되었지만, 생성자 및 팩토리 메서드의 매개변수는 int 타입(Lines 55, 72)을 사용하고 있습니다. 검증 로직(Lines 81-86)에서도 1-100 범위만 허용하므로 int로 충분합니다.
일관성을 위해 다음 중 하나로 통일하는 것을 권장합니다:
- 옵션 1: 필드를
int타입으로 변경 (추천) - 옵션 2: 매개변수와 검증 메서드를
long타입으로 변경
🔎 필드를 int로 통일하는 제안
- @Column(name = "base_rank_position", nullable = false)
- private long rankPosition;
+ @Column(name = "base_rank_position", nullable = false)
+ private int rankPosition;Also applies to: 54-55, 62-62, 81-86
🤖 Prompt for AI Agents
In modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java
around lines 48-86, the rankPosition field is declared as long while
constructors, factory methods, and validation use int and enforce 1-100; change
the rankPosition field type from long to int and update any related field
declarations/getters/setters/annotations in this class (including constructor
and factory method parameter types and any usages in lines 54-55, 62, and 81-86)
so all signatures and validations consistently use int.
| @Column(name = "rank_position", nullable = false) | ||
| private long rankPosition; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
rankPosition 타입 불일치를 수정하세요.
rankPosition 필드가 long으로 선언되어 있지만, 생성자와 create() 메서드에서는 int 파라미터를 받고 있으며, 검증 로직도 int 타입의 1-100 범위를 확인합니다. 또한 MonthlyRankEntity는 int rankPosition을 사용하여 일관성이 없습니다.
순위는 항상 1-100 범위의 정수이므로 int 타입이 더 적합합니다.
🔎 제안하는 수정
- @Column(name = "rank_position", nullable = false)
- private long rankPosition;
+ @Column(name = "rank_position", nullable = false)
+ private int rankPosition;📝 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.
| @Column(name = "rank_position", nullable = false) | |
| private long rankPosition; | |
| @Column(name = "rank_position", nullable = false) | |
| private int rankPosition; |
🤖 Prompt for AI Agents
In modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java
around lines 48-49, the rankPosition field is declared as long but constructors
and create() accept int and validation checks assume 1-100; change the field
type to int to match usage and domain constraints, update its getter/setter and
any constructors/factory methods to use int, ensure JPA mapping stays intact
(keep @Column) and update any references/usages (and MonthlyRankEntity if
necessary) to use int consistently across the codebase so validation and types
are aligned.
- 코드 가독성을 높이기 위해 불필요한 임포트 문을 제거 - 주석 및 공백 정리로 코드 일관성 향상
- 주간 랭킹 조회 시 페이징 기능을 추가하여 성능 개선 - 관련 메서드 및 리포지토리 수정
- ISO 주차 기준으로 변경하여 일관성 있는 주차 계산 보장
- 메트릭 Reader 공통 추상 클래스인 AbstractMetricsReader 구현 - 월간 및 주간 메트릭 Reader에서 상속하여 코드 중복 제거 - 랭킹 집계 로직을 통합하여 유지보수성 향상
- 점수 계산 방식 변경에 따른 테스트 값 업데이트 - 기존 테스트에서 사용된 점수 값 수정
- 판매량 증가 메서드에 총 판매 금액 매개변수 추가 - 월간 랭킹 조회 API에 페이징 처리 적용 - 관련된 리포지토리 및 서비스 메서드 수정
코드리뷰 기반 수정
|
@coderabbitai full review 재시도 pr 내용이랑 맞지 않는 내용있으면 피드백 주세요 |
|
@hyujikoh, 전체 리뷰를 다시 시작하겠습니다. PR 내용과 일치하지 않는 부분이 있다면 피드백 드리도록 하겠습니다. 이번 PR은 Spring Batch 기반의 주간/월간 TOP 100 랭킹 집계 시스템 구현으로, 다음 내용들을 포함하고 있습니다:
전체 변경사항을 꼼꼼히 검토하여 피드백 드리겠습니다. 🧠 Learnings used✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 9
♻️ Duplicate comments (11)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
17-20: Java에서 지원하지 않는 Kotlin 문자열 보간 문법 사용Java 코드에서
${...}문법을 사용하면 실제 값이 아닌 리터럴 문자열로 출력됩니다. SLF4J의 파라미터화된 로깅({}플레이스홀더)과 Java getter 메서드(getStepContext(),getStepExecution()등)를 사용해야 합니다.apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
20-24: Java에서 지원하지 않는 문자열 보간 구문을 수정해야 합니다.Line 22의
'${jobExecution.jobInstance.jobName}'구문은 Kotlin/Groovy 문법으로, Java에서는 실제 값이 아닌 리터럴 문자열로 출력됩니다.🔎 Java 문자열 포맷팅으로 수정
@BeforeJob void beforeJob(JobExecution jobExecution) { - log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); }apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
24-47: ISO 주차 유효성 검증 부재현재 정규식은
W1부터W99까지 허용하지만, 대부분의 연도는 52주만 있고 일부 연도만 53주가 있습니다. 유효하지 않은 주차(예:2024-W54)가 입력되면 예상치 못한 날짜가 반환될 수 있습니다.또한 이전 리뷰에서 언급된 대로, 연도 경계 케이스(예:
2023-W1은 2022년 12월 26일부터 시작)에 대한 테스트 커버리지가 필요합니다.🔎 제안하는 개선사항
주차 유효성 검증을 추가하세요:
public LocalDate[] parseYearWeek(String yearWeek) { if (yearWeek == null || !yearWeek.matches("\\d{4}-W\\d{1,2}")) { throw new IllegalArgumentException( String.format("잘못된 yearWeek 형식입니다. 예상: '2024-W52', 실제: '%s'", yearWeek)); } try { String[] parts = yearWeek.split("-W"); int year = Integer.parseInt(parts[0]); int week = Integer.parseInt(parts[1]); + // 해당 연도의 최대 주차 수 확인 + int maxWeeks = (int) LocalDate.of(year, 12, 28) + .get(WeekFields.ISO.weekOfYear()); + if (week < 1 || week > maxWeeks) { + throw new IllegalArgumentException( + String.format("주차 범위 초과: %d년은 최대 %d주까지 있습니다 (요청: %d주)", + year, maxWeeks, week)); + } + // ISO 주차 시스템 사용 (월요일 시작) WeekFields weekFields = WeekFields.ISO;apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java (1)
7-18: 문서화 불일치 - 이미 지적된 사항입니다.Lines 9-10의 클래스 주석이 실제 구현과 일치하지 않습니다. 주석은 "viewCount1 + likeCount3 + salesCount5 + orderCount2" 공식을 언급하지만, 실제 가중치는
VIEW_WEIGHT=0.1,LIKE_WEIGHT=0.2,SALES_WEIGHT=0.6이며ORDER_WEIGHT는 존재하지 않습니다.이 문제는 이전 리뷰에서 이미 지적되었습니다.
modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)
52-59:Object[]반환 타입에 대한 기존 리뷰 의견이 여전히 유효합니다.
aggregateByDateRange메서드가List<Object[]>를 반환하는 것은 구현 세부사항을 도메인 계층에 노출시킵니다. 이는 이전 리뷰에서 이미 지적된 사항입니다.전용 DTO(예:
ProductMetricsAggregation)를 생성하여 타입 안정성을 확보하는 것이 권장됩니다.modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java (1)
48-49:rankPosition타입 불일치를 수정하세요.
rankPosition필드가long으로 선언되어 있지만, 생성자(Line 55)와create()메서드(Line 72)에서는int파라미터를 받고 있으며, 검증 로직(Line 82)도int타입의 1-100 범위를 확인합니다.순위는 항상 1-100 범위의 정수이므로
int타입이 더 적합합니다.MonthlyRankEntity와의 일관성을 위해서도int로 변경하는 것을 권장합니다.🔎 제안하는 수정
- @Column(name = "rank_position", nullable = false) - private long rankPosition; + @Column(name = "rank_position", nullable = false) + private int rankPosition;modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java (1)
48-62: rankPosition 타입 불일치를 수정해 주세요.
rankPosition필드가long타입(Line 49)으로 선언되었지만, 생성자(Line 55)와 검증 로직(Line 81-86)에서는int타입을 사용하고 있습니다. 순위가 1~100 범위만 허용되므로int로 충분하며, 타입 일관성을 위해 필드를int로 변경하는 것을 권장합니다.참고: WeeklyRankEntity(Line 49)에서도 동일한 패턴이 발견되므로 함께 수정하는 것이 좋습니다.
🔎 필드를 int로 통일하는 제안
- @Column(name = "base_rank_position", nullable = false) - private long rankPosition; + @Column(name = "base_rank_position", nullable = false) + private int rankPosition;modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java (1)
93-110:eventTime파라미터에 null 검증이 필요합니다.
incrementView와applyLikeDelta메서드에서eventTime이 null인 경우lastEventAt에 null이 할당되며, 이는 의도하지 않은 동작일 수 있습니다. 생성자와 동일하게 null 검증을 추가하는 것을 권장합니다.🔎 제안하는 개선
public void incrementView(ZonedDateTime eventTime) { + Objects.requireNonNull(eventTime, "이벤트 시간은 필수입니다."); this.viewCount += 1; this.lastEventAt = eventTime; } public void applyLikeDelta(int delta, ZonedDateTime eventTime) { + Objects.requireNonNull(eventTime, "이벤트 시간은 필수입니다."); long next = this.likeCount + delta; this.likeCount = Math.max(0, next); this.lastEventAt = eventTime; }apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
72-85: ISO 주차 계산 시 연도 경계 케이스 처리가 필요합니다.
WeekFields.ISO를 사용하는 것은 올바르지만, Line 81에서targetDate.getYear()를 사용하면 연말/연초 경계에서 문제가 발생할 수 있습니다. 예를 들어, 2024년 12월 30일은 ISO week 기준 2025-W01에 해당할 수 있습니다.이전 리뷰에서 지적된 locale 이슈는 수정되었으나, week-based year 처리가 추가로 필요합니다.
🔎 제안하는 수정
private String processYearWeekParameter(String yearWeek, LocalDate date) { if (yearWeek != null && !yearWeek.trim().isEmpty()) { return yearWeek; } // date가 있으면 해당 날짜의 주차, 없으면 현재 주차 LocalDate targetDate = date != null ? date : LocalDate.now(); WeekFields weekFields = WeekFields.ISO; - int year = targetDate.getYear(); + int year = targetDate.get(weekFields.weekBasedYear()); int week = targetDate.get(weekFields.weekOfWeekBasedYear()); return String.format("%d-W%02d", year, week); }apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java (1)
111-115: 도메인 계층에서 스레드 안전성을 보장하도록 개선하세요.
getOrCreateMetrics()메서드의 check-then-act 패턴은 여전히 동시성 환경에서 race condition 위험이 있습니다.applyLikeDelta()는 명시적 조건문 패턴으로 이 문제를 올바르게 처리하고 있지만,incrementView()와addSales()는 여전히getOrCreateMetrics()를 사용하여 동일한 취약점을 가지고 있습니다.권장사항:
applyLikeDelta()와 동일하게incrementView()와addSales()도 명시적findByProductIdAndMetricDate()호출 후 if-present 패턴으로 변경하여 일관성을 유지하세요.apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
42-49: 메서드 이름을 스텝 이름과 일치시키세요.메서드 이름이
categorySyncStep()이지만 실제 스텝 이름 상수는STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"입니다. 이는 코드 가독성을 저하시키고 혼란을 야기할 수 있습니다.메서드 이름을
demoSimpleTaskStep()으로 변경하여 스텝 이름과 일치시키는 것을 권장합니다.
🧹 Nitpick comments (25)
README.md (1)
30-30: 프로젝트 구조 문서가 새로운commerce-batch모듈을 반영하고 있습니다.Line 30의 추가는 PR 목표와 일치하며 기존 모듈 구조와 형식 및 배치 순서가 적절합니다.
추가 개선 사항: PR에서 Spring Batch 기반 주간/월간 랭킹 집계 작업을 도입했다면, README의 "About Multi-Module Project" 섹션 아래에 각 app 모듈의 책임을 간단히 명시하면 개발자의 온보딩이 더욱 용이할 것 같습니다.
예를 들어:
- apps/commerce-api: REST API 엔드포인트 제공 (일간/주간/월간 랭킹 조회) - apps/commerce-batch: Spring Batch를 활용한 주간/월간 랭킹 집계 작업 - apps/commerce-streamer: 실시간 메트릭 처리이러한 설명을 통해 각 모듈의 역할이 더욱 명확해질 수 있습니다.
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (1)
15-19: 타임존 설정 방식 검토를 권장합니다.
TimeZone.setDefault()는 JVM 전역 상태를 변경하므로 모든 라이브러리와 프레임워크에 영향을 줄 수 있습니다. 학습 내용에 따르면 이는 의도적인 설계 선택이지만, 날짜/시간 연산 시 명시적으로ZoneId를 지정하는 것이 더 명확하고 안전합니다.배치 Job에서
WeekFields.of(Locale.getDefault())또는YearMonth사용 시 명시적으로ZoneId.of("Asia/Seoul")을 파라미터로 전달하는 방식을 고려해보세요.Based on learnings, 이 코드베이스에서는 모든 인스턴스가 동일한 타임존 설정을 공유하도록 배포 환경이 구성되어 있어 이 패턴이 의도적으로 사용되고 있습니다.
apps/commerce-batch/src/main/resources/application.yml (1)
34-50: 불필요한 빈 프로파일 섹션을 정리하는 것을 권장합니다.dev, qa, prd 프로파일 섹션이 비어있습니다. 향후 프로파일별 설정을 추가할 계획이 없다면 이 섹션들을 제거하는 것이 좋습니다. 만약 향후 추가 예정이라면 TODO 주석을 추가하여 의도를 명확히 하는 것을 권장합니다.
🔎 제안: 향후 설정 추가 계획이 없다면 제거
---- -spring: - config: - activate: - on-profile: dev - ---- -spring: - config: - activate: - on-profile: qa - ---- -spring: - config: - activate: - on-profile: prd - springdoc: api-docs: enabled: false또는 향후 추가 계획이 있다면:
--- spring: config: activate: on-profile: dev + # TODO: dev 환경 전용 배치 설정 추가 예정 --- spring: config: activate: on-profile: qa + # TODO: qa 환경 전용 배치 설정 추가 예정 --- spring: config: activate: on-profile: prd + # TODO: prd 환경 전용 배치 설정 추가 예정apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java (1)
35-47: 입력 파라미터 검증을 추가하는 것을 고려해보세요.현재
yearMonth파라미터에 대한 포맷 검증이 없어, 잘못된 형식이 입력되면 repository 또는 database 레이어에서 예외가 발생합니다. 서비스 계층에서 포맷을 검증하면 더 명확한 에러 메시지를 제공할 수 있습니다.참고:
ProductFacade에서 null/empty 체크를 수행하지만, 포맷 검증("2024-12")은 없습니다.🔎 yearMonth 포맷 검증 추가 제안
public Page<MonthlyRankEntity> getMonthlyRanking(String yearMonth, Pageable pageable) { + if (yearMonth == null || !yearMonth.matches("\\d{4}-\\d{2}")) { + throw new IllegalArgumentException( + String.format("잘못된 yearMonth 형식입니다. 예상: '2024-12', 실제: '%s'", yearMonth)); + } + log.debug("월간 랭킹 조회: yearMonth={}, page={}, size={}", yearMonth, pageable.getPageNumber(), pageable.getPageSize());apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java (1)
45-57: 주석과 테스트명이 다소 혼란스럽습니다.Line 50의 주석에서 "13.8 * 6 = 82"라고 표기되어 있는데, 실제로는 "13.8 * 0.6 * 10 = 82.8"이 맞습니다. 또한, 테스트명은 "판매금액이 가장 높은 가중치를 가진다"이지만, 실제로는 로그 정규화로 인해 매우 큰 금액이 필요하다는 점만 검증하고 있습니다.
🔎 주석 및 테스트명 개선 제안
@Test - @DisplayName("판매금액이 가장 높은 가중치를 가진다") - void should_have_highest_weight_for_sales_amount() { + @DisplayName("로그 정규화로 인해 매우 큰 판매금액이 다른 지표를 압도한다") + void should_dominate_with_very_large_sales_amount() { // given long viewScore = calculator.calculate(100, 0, java.math.BigDecimal.ZERO); // 100 * 0.1 * 10 = 100 - long amountScore = calculator.calculate(0, 0, java.math.BigDecimal.valueOf(1000000)); // log(1000001) * 0.6 * 10 = 13.8 * 6 = 82 + long amountScore = calculator.calculate(0, 0, java.math.BigDecimal.valueOf(1000000)); // log(1000001) * 0.6 * 10 ≈ 82apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java (2)
56-59: 카운트 조회 시 불필요한 데이터 로딩전체 랭킹 개수만 필요한데 모든 엔티티를 메모리에 로드하고 있습니다.
MonthlyRankingService와 일관성을 위해 COUNT 쿼리를 사용하는 레포지토리 메서드를 추가하거나, 페이지네이션을 활용하세요.🔎 제안하는 개선사항
옵션 1: 레포지토리에 COUNT 메서드 추가 (권장)
// WeeklyRankRepository에 추가 long countByYearWeek(String yearWeek);public long getWeeklyRankingCount(String yearWeek) { - List<WeeklyRankEntity> rankings = weeklyRankRepository.findByYearWeek(yearWeek); - return rankings.size(); + return weeklyRankRepository.countByYearWeek(yearWeek); }옵션 2: 기존 페이지네이션 활용
public long getWeeklyRankingCount(String yearWeek) { - List<WeeklyRankEntity> rankings = weeklyRankRepository.findByYearWeek(yearWeek); - return rankings.size(); + Page<WeeklyRankEntity> page = weeklyRankRepository.findByYearWeek(yearWeek, Pageable.ofSize(1)); + return page.getTotalElements(); }
42-45: 불필요한 빈 체크 로직
pagedRankings는 이미Page객체이므로 빈 경우에도 그대로 반환해도 됩니다. 빈 결과에 대해 별도의Page.empty()를 반환할 필요가 없습니다.🔎 제안하는 개선사항
public Page<WeeklyRankEntity> getWeeklyRanking(String yearWeek, Pageable pageable) { log.debug("주간 랭킹 조회: yearWeek={}, page={}, size={}", yearWeek, pageable.getPageNumber(), pageable.getPageSize()); // 1. 전체 랭킹 조회 (순위 순으로 정렬됨) Page<WeeklyRankEntity> pagedRankings = weeklyRankRepository.findByYearWeek(yearWeek , pageable); - if (pagedRankings.isEmpty()) { - log.debug("주간 랭킹 데이터 없음: yearWeek={}", yearWeek); - return Page.empty(pageable); - } + log.debug("주간 랭킹 조회 완료: yearWeek={}, 전체={}, 페이지={}", + yearWeek, pagedRankings.getTotalElements(), pagedRankings.getNumberOfElements()); return pagedRankings; }apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (2)
29-31: 로깅 프레임워크 사용 권장
System.out.println대신 Slf4j를 사용하면 로그 레벨 제어, 포맷팅, 로그 수집 등의 이점이 있습니다.🔎 제안하는 개선사항
+import lombok.extern.slf4j.Slf4j; + @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) @RequiredArgsConstructor +@Slf4j @Component public class DemoTasklet implements Tasklet { @Value("#{jobParameters['requestDate']}") private String requestDate; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { if (requestDate == null) { throw new RuntimeException("requestDate is null"); } - System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + log.info("Demo Tasklet 실행 (실행 일자 : {})", requestDate); Thread.sleep(1000); - System.out.println("Demo Tasklet 작업 완료"); + log.info("Demo Tasklet 작업 완료"); return RepeatStatus.FINISHED; } }
26-28: 구체적인 예외 타입 사용 고려
RuntimeException대신IllegalArgumentException또는 커스텀 예외를 사용하면 예외 원인이 더 명확해집니다.🔎 제안하는 개선사항
if (requestDate == null) { - throw new RuntimeException("requestDate is null"); + throw new IllegalArgumentException("필수 Job 파라미터 'requestDate'가 누락되었습니다"); }apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (2)
41-55: 테스트 메서드 이름이 DisplayName과 불일치메서드 이름
shouldNotSaveCategories_whenApiError는 "API 오류 시 카테고리 저장 안 함"을 의미하지만, 실제 테스트는requestDate파라미터 누락 시 실패를 검증합니다.DisplayName과 일치하도록 메서드 이름을 변경하세요.🔎 제안하는 개선사항
@DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") @Test -void shouldNotSaveCategories_whenApiError() throws Exception { +void shouldFail_whenRequestDateNotProvided() throws Exception {
36-39: 빈 beforeEach 메서드 제거사용하지 않는 빈 메서드는 코드 가독성을 위해 제거하는 것이 좋습니다.
🔎 제안하는 개선사항
- @BeforeEach - void beforeEach() { - - } - @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.")modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java (1)
23-26: String 대신 Value Object 사용 고려yearMonth 파라미터가 현재 String 타입으로 정의되어 있습니다. 형식("2024-12")을 보장하고 타입 안정성을 높이기 위해 YearMonth 같은 전용 Value Object를 사용하는 것을 고려해보세요.
🔎 제안: YearMonth 타입 사용
+import java.time.YearMonth; + /** * 특정 월의 랭킹을 조회합니다. */ -Page<MonthlyRankEntity> findByYearMonth(String yearMonth, Pageable pageable); +Page<MonthlyRankEntity> findByYearMonth(YearMonth yearMonth, Pageable pageable);참고: 구현체에서 String 변환이 필요할 수 있으며, DateRangeParser와의 일관성도 고려해야 합니다.
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java (1)
25-26: yearWeek 형식 검증 고려yearWeek 필드가 "2024-W52" 형식을 기대하지만,
of()팩토리 메서드에서 형식 검증이 없습니다. DateRangeParser에서 검증이 이루어지겠지만, 방어적 프로그래밍 관점에서 복합키 생성 시점에도 기본적인 형식 검증(예: 정규식 매칭)을 고려해볼 수 있습니다.🔎 제안: yearWeek 형식 검증 추가
+import java.util.regex.Pattern; + public class WeeklyRankId implements Serializable { + + private static final Pattern YEAR_WEEK_PATTERN = Pattern.compile("^\\d{4}-W\\d{1,2}$"); @Column(name = "product_id", nullable = false) private Long productId; @Column(name = "year_week", nullable = false, length = 8) private String yearWeek; // e.g., "2024-W52" private WeeklyRankId(Long productId, String yearWeek) { + if (yearWeek != null && !YEAR_WEEK_PATTERN.matcher(yearWeek).matches()) { + throw new IllegalArgumentException("Invalid yearWeek format: " + yearWeek); + } this.productId = productId; this.yearWeek = yearWeek; }참고: 이 검증은 선택사항이며, 상위 레이어에서 이미 검증되고 있다면 중복일 수 있습니다.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (1)
37-37: Chunk 크기와 처리 방식 재검토 권장CHUNK_SIZE가 100으로 설정되어 있고, Reader가 정확히 TOP 100 항목만 반환한다면, chunk-oriented 처리 방식의 이점(부분 커밋, 재시작 가능성)이 제한적입니다. 단일 청크로 처리되므로 사실상 단일 트랜잭션과 동일합니다.
대안:
- 현재 구조 유지: 향후 TOP N 값 변경에 유연하게 대응 가능
- Tasklet 기반 처리로 단순화: 고정된 TOP 100이라면 더 명확한 의도 표현
현재 구현이 틀린 것은 아니지만, 설계 의도를 명확히 하기 위해 주석 보완을 권장합니다.
🔎 제안: 주석 개선
-private static final int CHUNK_SIZE = 100; // TOP 100이므로 한 번에 처리 +private static final int CHUNK_SIZE = 100; // TOP 100 전체를 하나의 청크로 처리 (향후 TOP N 확장 가능성 고려)apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java (2)
20-30: 쿼리 중복 및 메서드 명명 규칙 활용 고려동일한 JPQL 쿼리가 두 메서드(List, Page 반환)에 중복되어 있습니다. Spring Data JPA의 메서드 명명 규칙을 활용하면 @query 없이도 쿼리를 자동 생성할 수 있습니다.
🔎 제안: 메서드 명명 규칙 활용
Spring Data JPA는 메서드 이름에서 쿼리를 파생할 수 있습니다:
- @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") - List<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek); + List<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(String yearWeek); - @Query("SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC") - Page<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable); + Page<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(String yearWeek, Pageable pageable);명시적 @query가 필요한 경우(예: 성능 최적화, 복잡한 조인), 쿼리 문자열을 상수로 추출하여 중복을 제거하세요:
private static final String FIND_BY_YEAR_WEEK_QUERY = "SELECT w FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek ORDER BY w.rankPosition ASC"; @Query(FIND_BY_YEAR_WEEK_QUERY) List<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek); @Query(FIND_BY_YEAR_WEEK_QUERY) Page<WeeklyRankEntity> findByIdYearWeekOrderByRankPosition(@Param("yearWeek") String yearWeek, Pageable pageable);
38-40: @Modifying 속성 설정 고려bulk delete 작업에서 영속성 컨텍스트와의 동기화를 위해
@Modifying(clearAutomatically = true)설정을 고려하세요. 삭제된 엔티티가 영속성 컨텍스트에 캐시되어 있을 경우, 삭제 후에도 조회될 수 있는 문제를 방지합니다.🔎 제안: clearAutomatically 속성 추가
- @Modifying + @Modifying(clearAutomatically = true) @Query("DELETE FROM WeeklyRankEntity w WHERE w.id.yearWeek = :yearWeek") long deleteByIdYearWeek(@Param("yearWeek") String yearWeek);참고: Batch Writer에서
deleteByYearMonth호출 후 바로saveAll을 수행하는 멱등성 보장 패턴에서 특히 중요합니다.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
1-22: 사용하지 않는 import를 제거하세요.Line 6의
PageRequestimport가 코드에서 사용되지 않습니다.🔎 제안하는 수정
import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable;apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)
32-46: 파라미터 사용 조건을 명세에 명시하세요.메서드 시그니처에
date,yearWeek,yearMonth세 가지 날짜 관련 파라미터가 모두 존재하여 어떤 파라미터를 언제 사용해야 하는지 불명확할 수 있습니다.API 문서에 다음 정보를 추가하는 것을 권장합니다:
WEEKLY기간:yearWeek또는date사용MONTHLY기간:yearMonth또는date사용- 우선순위 또는 상호 배타적 관계 명시
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java (1)
449-479: Carry-Over 테스트에서 테스트 격리 고려가 필요합니다.Line 477에서
tomorrowKey를 삭제하는 cleanup 로직이 있지만, 테스트 실패 시 cleanup이 실행되지 않을 수 있습니다.@AfterEach로 이동하거나try-finally블록으로 감싸는 것을 권장합니다.🔎 제안하는 개선
+ @Test + @DisplayName("Carry-Over 후 다음 날 랭킹에 점수가 이월된다") + void should_carry_over_scores_to_next_day() { + LocalDate tomorrow = today.plusDays(1); + String tomorrowKey = cacheKeyGenerator.generateDailyRankingKey(tomorrow); + + try { // given - 오늘 랭킹 데이터 직접 Redis에 적재 (weight 적용된 점수) Long productId = testProductIds.get(0); double weightedScore = 60.0; String todayKey = cacheKeyGenerator.generateDailyRankingKey(today); redisTemplate.opsForZSet().add(todayKey, productId.toString(), weightedScore); - - LocalDate tomorrow = today.plusDays(1); - String tomorrowKey = cacheKeyGenerator.generateDailyRankingKey(tomorrow); redisTemplate.delete(tomorrowKey); // when - Carry-Over 실행 (10%) rankingRedisService.carryOverScores(today, tomorrow, 0.1); // then Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); assertThat(tomorrowScore).isNotNull(); assertThat(tomorrowScore).isCloseTo(weightedScore * 0.1, org.assertj.core.data.Offset.offset(0.01)); - - // Cleanup - redisTemplate.delete(tomorrowKey); + } finally { + redisTemplate.delete(tomorrowKey); + } }apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
223-302: 주간/월간 랭킹 조회 로직에 중복 코드가 있습니다.
getWeeklyRankingProducts와getMonthlyRankingProducts가 거의 동일한 패턴(입력 검증 → 랭킹 조회 → 상품 ID 추출 → MV 조회 → 정렬 → Page 반환)을 따릅니다. 공통 로직을 헬퍼 메서드로 추출하면 유지보수성이 향상됩니다.🔎 제안하는 리팩토링
/** * 랭킹 엔티티 목록을 ProductInfo 페이지로 변환하는 공통 헬퍼 */ private <T> Page<ProductInfo> convertRankingToProductPage( Page<T> rankings, java.util.function.Function<T, Long> productIdExtractor, Pageable pageable) { if (rankings.isEmpty()) { return Page.empty(pageable); } List<Long> productIds = rankings.getContent().stream() .map(productIdExtractor) .collect(Collectors.toList()); List<ProductMaterializedViewEntity> products = mvService.getByIds(productIds); List<ProductInfo> sortedProducts = productIds.stream() .map(productId -> products.stream() .filter(p -> p.getProductId().equals(productId)) .findFirst() .map(ProductInfo::from) .orElse(null)) .filter(Objects::nonNull) .collect(Collectors.toList()); return new PageImpl<>(sortedProducts, pageable, rankings.getTotalElements()); }사용 예:
public Page<ProductInfo> getWeeklyRankingProducts(Pageable pageable, String yearWeek) { if (yearWeek == null || yearWeek.trim().isEmpty()) { log.warn("주간 랭킹 조회 시 yearWeek 파라미터가 필요합니다"); return Page.empty(pageable); } Page<WeeklyRankEntity> weeklyRankings = weeklyRankingService.getWeeklyRanking(yearWeek, pageable); if (weeklyRankings.isEmpty()) { log.debug("주간 랭킹 데이터 없음: yearWeek={}", yearWeek); return Page.empty(pageable); } return convertRankingToProductPage(weeklyRankings, WeeklyRankEntity::getProductId, pageable); }
247-254: 상품 정렬 로직에서 O(n*m) 시간 복잡도 개선 가능합니다.현재
productIds.stream()내에서 매번products.stream().filter()를 호출하여 O(n*m) 복잡도입니다.Map<Long, ProductMaterializedViewEntity>으로 변환하면 O(n+m)으로 개선됩니다.🔎 제안하는 개선
- // 4. 랭킹 순서대로 정렬 - List<ProductInfo> sortedProducts = productIds.stream() - .map(productId -> products.stream() - .filter(p -> p.getProductId().equals(productId)) - .findFirst() - .map(ProductInfo::from) - .orElse(null)) - .filter(Objects::nonNull) - .collect(Collectors.toList()); + // 4. 랭킹 순서대로 정렬 (Map 활용으로 O(n+m) 복잡도) + Map<Long, ProductMaterializedViewEntity> productMap = products.stream() + .collect(Collectors.toMap(ProductMaterializedViewEntity::getProductId, p -> p)); + + List<ProductInfo> sortedProducts = productIds.stream() + .map(productMap::get) + .filter(Objects::nonNull) + .map(ProductInfo::from) + .collect(Collectors.toList());Also applies to: 291-298
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
48-65: 기간별 랭킹 API에서 불필요한 파라미터 처리가 발생할 수 있습니다.
period가DAILY일 때도processYearWeekParameter와processYearMonthParameter가 호출됩니다. 성능에 큰 영향은 없지만,switch분기 전에 필요한 파라미터만 처리하는 것이 더 명확합니다.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1)
44-61: 집계 쿼리에서 타입 안전성을 고려해보세요.
aggregateByDateRange가Object[]를 반환하여 호출 측에서 타입 캐스팅이 필요합니다. Spring Data JPA Projection이나 DTO 클래스를 사용하면 타입 안전성이 향상됩니다.🔎 제안하는 개선 (Projection 활용)
public interface ProductMetricsAggregation { Long getProductId(); Long getViewCount(); Long getLikeCount(); Long getSalesCount(); Long getOrderCount(); java.math.BigDecimal getTotalSalesAmount(); } @Query(""" SELECT m.id.productId as productId, SUM(m.viewCount) as viewCount, SUM(m.likeCount) as likeCount, SUM(m.salesCount) as salesCount, SUM(m.orderCount) as orderCount, SUM(m.totalSalesAmount) as totalSalesAmount FROM ProductMetricsEntity m WHERE m.id.metricDate BETWEEN :startDate AND :endDate GROUP BY m.id.productId """) List<ProductMetricsAggregation> aggregateByDateRange( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate);modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java (1)
119-127:addSales메서드의 null 처리가 일관성 있게 적용되어야 합니다.
totalAmount에 대한 null 처리는 되어 있으나,eventTime에 대한 검증은 누락되었습니다. 또한quantity <= 0시 early return 대신 예외를 던지는 것이 더 명확할 수 있습니다.🔎 제안하는 개선
public void addSales(int quantity, java.math.BigDecimal totalAmount, ZonedDateTime eventTime) { + Objects.requireNonNull(eventTime, "이벤트 시간은 필수입니다."); if (quantity <= 0) { - return; + throw new IllegalArgumentException("판매 수량은 0보다 커야 합니다."); } this.salesCount += quantity; this.orderCount += 1; this.totalSalesAmount = this.totalSalesAmount.add(totalAmount != null ? totalAmount : java.math.BigDecimal.ZERO); this.lastEventAt = eventTime; }apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
6-6: 사용하지 않는 import를 제거하세요.
PageRequest를 import하고 있지만 코드 내에서 사용되지 않습니다.🔎 제안: 사용하지 않는 import 제거
import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable;
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (2)
modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankEntity.javais excluded by!**/generated/**modules/jpa/src/main/generated/com/loopers/domain/ranking/QWeeklyRankId.javais excluded by!**/generated/**
📒 Files selected for processing (76)
.github/workflows/main.ymlREADME.mdapps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.javaapps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.javaapps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.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-api/src/main/java/com/loopers/support/Uris.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.javaapps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.javaapps/commerce-batch/build.gradle.ktsapps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.javaapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.javaapps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsId.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.javamodules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.javamodules/jpa/src/test/java/com/loopers/domain/ranking/MonthlyRankEntityUnitTest.javamodules/jpa/src/test/java/com/loopers/domain/ranking/WeeklyRankEntityUnitTest.javasettings.gradle.kts
💤 Files with no reviewable changes (5)
- apps/commerce-api/src/main/java/com/loopers/domain/point/PointHistoryEntity.java
- apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java
- apps/commerce-api/src/main/java/com/loopers/domain/user/UserEntity.java
- apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
🧰 Additional context used
🧠 Learnings (14)
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java
📚 Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
📚 Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.
Applied to files:
apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-batch/src/main/resources/application.yml
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.
Applied to files:
modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.
Applied to files:
modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsEntityUnitTest.javamodules/jpa/src/test/java/com/loopers/domain/ranking/WeeklyRankEntityUnitTest.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.javamodules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsId.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.javamodules/jpa/src/test/java/com/loopers/domain/ranking/MonthlyRankEntityUnitTest.java
📚 Learning: 2026-01-02T10:52:14.717Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 82
File: apps/commerce-batch/src/main/kotlin/com/loopers/interfaces/scheduler/RankingJobScheduler.kt:79-99
Timestamp: 2026-01-02T10:52:14.717Z
Learning: 주간/월간 랭킹 배치 작업(WeeklyRankingJob, MonthlyRankingJob)은 실행 주기가 주간/월간이 아니라, 최근 7일/30일의 롤링 윈도우를 매일 집계하는 작업이다. 따라서 daily cron schedule이 올바른 설정이며, 작업명의 "weekly"/"monthly"는 집계 기간을 의미한다.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java
📚 Learning: 2026-01-01T14:12:04.385Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/ChunkListener.kt:4-5
Timestamp: 2026-01-01T14:12:04.385Z
Learning: In Spring Batch 6.x, ChunkListener API changed from using ChunkContext to Chunk<T> parameters. The correct method signatures are: beforeChunk(Chunk<T> chunk), afterChunk(Chunk<T> chunk), and onChunkError(Exception exception, Chunk<T> chunk). The import path is org.springframework.batch.core.listener.ChunkListener.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
📚 Learning: 2026-01-01T14:12:07.943Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/JobListener.kt:4-5
Timestamp: 2026-01-01T14:12:07.943Z
Learning: Spring Batch 6.x에서 JobExecution은 org.springframework.batch.core.job 패키지에, JobExecutionListener는 org.springframework.batch.core.listener 패키지에 위치합니다. Spring Batch 5.x와 달리 하위 패키지를 사용합니다.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
📚 Learning: 2025-12-22T16:33:50.678Z
Learnt from: jikimee64
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 71
File: apps/commerce-streamer/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt:167-173
Timestamp: 2025-12-22T16:33:50.678Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository, using system default timezone (ZonedDateTime.now() without explicit ZoneId) in date key calculations is an intentional design choice. The deployment ensures all instances share the same timezone configuration.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
📚 Learning: 2025-12-02T08:12:06.383Z
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService에서 상품 목록 조회 시 Redis 캐시를 적용했으며, 캐시 키는 brandId, sortType, pageNumber, pageSize의 조합으로 구성되고 TTL은 5분으로 설정되어 있다.
Applied to files:
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java
📚 Learning: 2025-11-17T14:23:34.490Z
Learnt from: yeonsu00
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 85
File: apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java:54-60
Timestamp: 2025-11-17T14:23:34.490Z
Learning: In the Point domain entity at apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java, the deduct() method does not validate balance sufficiency. The balance validation is intentionally performed at the service layer in PointService.deductPoint() using hasSufficientAmount() before calling deduct(). This is the team's design decision to handle validation at the service layer rather than within the entity.
Applied to files:
apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java
📚 Learning: 2025-11-27T22:53:56.762Z
Learnt from: CR
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 0
File: .codeguide/loopers-1-week.md:0-0
Timestamp: 2025-11-27T22:53:56.762Z
Learning: Applies to **/*.{test,spec}.{ts,tsx,js} : Integration test: If a user with the given ID exists, their point balance should be returned.
Applied to files:
apps/commerce-api/src/test/java/com/loopers/fixtures/UserTestFixture.java
📚 Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java
🧬 Code graph analysis (23)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (7)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
ConditionalOnProperty(21-50)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java (1)
Component(12-50)apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
Component(42-344)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.java (1)
Component(13-25)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
Component(14-73)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
name(8-81)
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.java (1)
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java (3)
DisplayName(8-94)Nested(13-76)Nested(78-93)
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
log(18-115)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.java (1)
Component(13-25)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
Slf4j(10-22)apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
Slf4j(15-54)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/RankingProcessor.java (4)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java (1)
Component(12-50)apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)
Component(42-344)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
Component(14-73)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java (3)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java (1)
Slf4j(20-52)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (1)
Slf4j(20-52)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
log(18-115)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
ConditionalOnProperty(21-50)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (1)
Configuration(30-80)
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt (1)
main(22-24)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java (1)
ProductV1Dtos(10-168)apps/commerce-api/src/main/java/com/loopers/support/Uris.java (2)
Uris(9-116)Ranking(85-92)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (4)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(18-43)apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(18-43)apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
Repository(18-47)apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
Repository(18-48)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java (4)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
Component(14-73)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java (1)
Slf4j(19-83)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (1)
Slf4j(20-52)
modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsEntity.java (3)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java (1)
Getter(14-89)apps/commerce-api/src/main/java/com/loopers/util/MaskingUtil.java (1)
NoArgsConstructor(10-20)apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
name(8-81)
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java (1)
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java (1)
Entity(19-96)
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java (1)
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java (1)
Embeddable(16-36)
apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingService.java (1)
apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingService.java (1)
Service(20-48)
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankId.java (1)
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankId.java (1)
Embeddable(16-36)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(18-43)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankWriter.java (3)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (1)
Slf4j(20-52)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.java (1)
Slf4j(23-79)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(18-43)apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java (1)
Repository(18-43)apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java (1)
Repository(18-48)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyMetricsReader.java (5)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/ScoreCalculator.java (1)
Component(12-50)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/DateRangeParser.java (1)
Component(14-73)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/support/RankingAggregator.java (1)
Component(18-62)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java (1)
Slf4j(19-83)apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyMetricsReader.java (1)
Slf4j(20-52)
modules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java (1)
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java (1)
Entity(19-96)
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java (2)
modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java (1)
CachePayloads(13-85)apps/commerce-api/src/main/java/com/loopers/support/Uris.java (3)
Uris(9-116)Ranking(85-92)Product(73-80)
| pr_agent_job: | ||
| runs-on: ubuntu-latest |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
명시적 권한 설정을 추가하세요.
최소 권한 원칙에 따라 워크플로우에 필요한 권한을 명시적으로 설정하는 것이 보안 모범 사례입니다. PR Agent는 PR 코멘트 작성 및 저장소 읽기 권한이 필요합니다.
🔎 권장 수정 사항
jobs:
pr_agent_job:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
steps:📝 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.
| pr_agent_job: | |
| runs-on: ubuntu-latest | |
| pr_agent_job: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write |
🤖 Prompt for AI Agents
In .github/workflows/main.yml around lines 6-7, the workflow currently lacks
explicit permissions; add a workflow-level permissions block (or job-level if
preferred) that grants the minimal needed scopes: set contents: read and
pull-requests: write so the PR Agent can read the repository and post PR
comments, and ensure other permissions are left at their defaults or set to
none. Include this permissions stanza at the top of the workflow (or under the
job) to follow least-privilege practices.
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: PR Agent action step | ||
| uses: Codium-ai/pr-agent@main |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
액션 버전을 고정하여 보안을 강화하세요.
@main 브랜치를 사용하면 예고 없이 변경사항이 반영되어 보안 취약점이나 예기치 않은 동작을 초래할 수 있습니다. 특정 커밋 SHA 또는 버전 태그로 고정하는 것을 권장합니다.
🔎 권장 수정 사항
- - name: PR Agent action step
- uses: Codium-ai/pr-agent@main
+ - name: PR Agent action step
+ uses: Codium-ai/pr-agent@v1.2.3 # 또는 특정 커밋 SHA 사용최신 안정 버전을 확인하려면 Codium-ai/pr-agent 릴리스 페이지를 참조하세요.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
.github/workflows/main.yml around line 10: the workflow currently references
Codium-ai/pr-agent@main which tracks the branch and can introduce breaking or
insecure changes; replace the floating ref with a fixed release tag or specific
commit SHA (e.g., a documented release tag from the repository's Releases page)
to pin the action version, update the workflow line accordingly, and commit the
change so CI uses the pinned action; optionally add a comment with the release
URL and consider periodically updating the tag after reviewing release notes.
| uses: Codium-ai/pr-agent@main | ||
| env: | ||
| OPENAI_KEY: ${{ secrets.OPENAI_KEY }} | ||
| GITHUB_TOKEN: ${{ secrets.G_TOKEN }} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the workflow file to understand the context around line 13
cd .github/workflows && ls -laRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 253
🏁 Script executed:
# Read the main.yml workflow file to understand the pr-agent action setup
cat -n main.yml | head -30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 128
🌐 Web query:
pr-agent GitHub action GITHUB_TOKEN requirements documentation
💡 Result:
Short answer: pr-agent (the GitHub Action) requires a GITHUB_TOKEN with permissions:
- contents: read
- pull-requests: write
You can use the automatically provided secrets.GITHUB_TOKEN (recommended). Use a repo-scoped PAT (repo scope) only when you need cross-repo/fork behaviors or to trigger downstream workflows. [1][2][3]
Sources:
- pr-agent repository / action docs (required permissions and usage). [1]
- Third-party summary of the action’s required permissions (contents: read, pull-requests: write) and PAT guidance. [2]
- GitHub docs on using/modifying GITHUB_TOKEN and when a PAT is needed. [3]
🏁 Script executed:
cat -n .github/workflows/main.ymlRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 487
🏁 Script executed:
# Also check if there's any documentation or comments in the workflow explaining the token choice
cat .github/workflows/main.ymlRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 396
🏁 Script executed:
# Search for references to G_TOKEN in the repository to understand if this is intentional
rg "G_TOKEN" --type yaml --type yml --type mdRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 115
🏁 Script executed:
# Check for any GitHub repository settings or documentation about token usage
fd README -type f | head -5 | xargs cat 2>/dev/null | grep -i "token\|G_TOKEN" -A 2 -B 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 258
🏁 Script executed:
# Search for any GitHub Actions documentation or setup guides in the repository
fd -e md -e txt | xargs rg "GITHUB_TOKEN\|G_TOKEN\|secrets\|github.*token" -i 2>/dev/null | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Search for G_TOKEN references in the entire repository
rg "G_TOKEN"Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Search for any documentation about token setup
find . -type f \( -name "*.md" -o -name "*.txt" \) -exec grep -l "token\|TOKEN" {} \;Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Check if there's any GitHub documentation or setup instructions
cat README.md 2>/dev/null | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1242
🏁 Script executed:
# Search for GitHub secrets configuration or environment setup files
find . -type f \( -name "*.md" -o -name ".env*" -o -name "*.properties" \) | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 695
내장된 GITHUB_TOKEN 사용으로 변경하세요.
pr-agent 액션은 공식적으로 secrets.GITHUB_TOKEN(내장 토큰)을 사용할 것을 권장합니다. 커스텀 토큰 G_TOKEN은 필요하지 않으며, 액션에 필요한 권한(contents: read, pull-requests: write)은 이미 내장 토큰에 포함되어 있습니다.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}으로 변경하세요. 저장소 시크릿에서 G_TOKEN이 제대로 설정되지 않으면 워크플로우가 실패할 수 있습니다.
🤖 Prompt for AI Agents
In .github/workflows/main.yml around line 13, the workflow sets GITHUB_TOKEN to
the custom secret secrets.G_TOKEN; update it to use the built-in token by
setting GITHUB_TOKEN to ${{ secrets.GITHUB_TOKEN }} instead so the action uses
the repository's automatic token and avoids relying on a missing custom secret;
ensure any references to the custom G_TOKEN secret are removed or replaced
accordingly.
...commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java
Show resolved
Hide resolved
| // querydsl | ||
| annotationProcessor("com.querydsl:querydsl-apt::jakarta") | ||
| annotationProcessor("jakarta.persistence:jakarta.persistence-api") | ||
| annotationProcessor("jakarta.annotation:jakarta.annotation-api") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/commerce-batch/build.gradle.kts | head -30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1030
🏁 Script executed:
# Check Gradle version and QueryDSL dependency handling
rg "com.querydsl:querydsl-apt" apps/commerce-batch/ -A 2 -B 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 491
🌐 Web query:
Gradle classifier syntax double colon querydsl-apt jakarta
💡 Result:
Use the standard Gradle dependency notation where the fourth colon is the classifier (or use the map form with classifier:). Examples for QueryDSL with the jakarta classifier:
- String notation (classifier as fourth colon):
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' - Map notation:
implementation group: 'com.querydsl', name: 'querydsl-jpa', version: '5.0.0', classifier: 'jakarta'
annotationProcessor group: 'com.querydsl', name: 'querydsl-apt', version: '5.0.0', classifier: 'jakarta'
(You can also use the artifact-only form with @ext, e.g. group:artifact:version:classifier@jar when needed.) [1][2][3]
QueryDSL 의존성 선언 문법 수정 필요
14번 라인의 querydsl-apt::jakarta 문법은 잘못되었습니다. Gradle의 표준 의존성 표기법에서 classifier를 지정할 때는 다음과 같이 버전을 포함하고 단일 콜론으로 구분해야 합니다:
annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")(권장)- 또는 맵 표기법:
annotationProcessor(group: "com.querydsl", name: "querydsl-apt", version: "5.0.0", classifier: "jakarta")
현재 사용된 :: (이중 콜론) 표기법은 표준 Gradle 문법이 아니며, 버전이 명시되지 않아 의존성이 올바르게 해석되지 않을 수 있습니다. 버전 카탈로그를 사용 중이라면 명시적으로 버전을 지정하거나, 루트 프로젝트의 공유 버전 설정을 활용하시기 바랍니다.
🤖 Prompt for AI Agents
In apps/commerce-batch/build.gradle.kts around lines 13 to 16, the QueryDSL
dependency at line 14 uses an invalid double-colon syntax
("com.querydsl:querydsl-apt::jakarta") and omits a version; replace it with a
valid Gradle dependency that includes a version and classifier (e.g. use the
single-colon form with version and classifier like
com.querydsl:querydsl-apt:5.x.x:jakarta or the map notation
group/name/version/classifier), or if you rely on a version catalog/root shared
version, reference that catalog entry instead so the annotationProcessor
declaration includes a concrete version and the jakarta classifier.
| public static RankingAggregation from(Object[] row, ScoreCalculator calculator) { | ||
| if (row == null || row.length < 4) { | ||
| throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다."); | ||
| } | ||
|
|
||
| try { | ||
| Long productId = (Long) row[0]; | ||
| long viewCount = ((Number) row[1]).longValue(); | ||
| long likeCount = ((Number) row[2]).longValue(); | ||
| long salesCount = ((Number) row[3]).longValue(); | ||
| long orderCount = ((Number) row[4]).longValue(); | ||
| BigDecimal totalSalesAmount = (BigDecimal) row[5]; | ||
|
|
||
| long totalScore = calculator.calculate(viewCount, likeCount, totalSalesAmount); | ||
|
|
||
| return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount, totalScore); | ||
| } catch (ClassCastException | NullPointerException e) { | ||
| throw new IllegalArgumentException("집계 결과 데이터 형식이 올바르지 않습니다.", e); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
배열 길이 검증 로직을 수정해 주세요.
Line 47에서 row.length < 4로 검증하고 있지만, Line 57에서 row[5]에 접근하므로 배열에는 최소 6개의 요소가 필요합니다. 현재 로직으로는 row.length가 4 또는 5인 경우 ArrayIndexOutOfBoundsException이 발생할 수 있습니다.
🔎 배열 길이 검증 수정 제안
- if (row == null || row.length < 4) {
- throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다.");
+ if (row == null || row.length < 6) {
+ throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다. (최소 6개 요소 필요)");
}📝 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 static RankingAggregation from(Object[] row, ScoreCalculator calculator) { | |
| if (row == null || row.length < 4) { | |
| throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다."); | |
| } | |
| try { | |
| Long productId = (Long) row[0]; | |
| long viewCount = ((Number) row[1]).longValue(); | |
| long likeCount = ((Number) row[2]).longValue(); | |
| long salesCount = ((Number) row[3]).longValue(); | |
| long orderCount = ((Number) row[4]).longValue(); | |
| BigDecimal totalSalesAmount = (BigDecimal) row[5]; | |
| long totalScore = calculator.calculate(viewCount, likeCount, totalSalesAmount); | |
| return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount, totalScore); | |
| } catch (ClassCastException | NullPointerException e) { | |
| throw new IllegalArgumentException("집계 결과 데이터 형식이 올바르지 않습니다.", e); | |
| } | |
| } | |
| public static RankingAggregation from(Object[] row, ScoreCalculator calculator) { | |
| if (row == null || row.length < 6) { | |
| throw new IllegalArgumentException("집계 결과 배열이 null이거나 길이가 부족합니다. (최소 6개 요소 필요)"); | |
| } | |
| try { | |
| Long productId = (Long) row[0]; | |
| long viewCount = ((Number) row[1]).longValue(); | |
| long likeCount = ((Number) row[2]).longValue(); | |
| long salesCount = ((Number) row[3]).longValue(); | |
| long orderCount = ((Number) row[4]).longValue(); | |
| BigDecimal totalSalesAmount = (BigDecimal) row[5]; | |
| long totalScore = calculator.calculate(viewCount, likeCount, totalSalesAmount); | |
| return new RankingAggregation(productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount, totalScore); | |
| } catch (ClassCastException | NullPointerException e) { | |
| throw new IllegalArgumentException("집계 결과 데이터 형식이 올바르지 않습니다.", e); | |
| } | |
| } |
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/dto/RankingAggregation.java
around lines 46 to 65, the array-length check currently uses row.length < 4 but
the method accesses up to row[5], so update the validation to require at least 6
elements (e.g., row.length < 6) and keep the null check; adjust the
IllegalArgumentException message to reflect that the aggregation array must
contain 6 elements to avoid ArrayIndexOutOfBoundsException when accessing row[4]
and row[5].
| LocalDate[] dateRange = parseDateRange(); | ||
| LocalDate startDate = dateRange[0]; | ||
| LocalDate endDate = dateRange[1]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
기간 파싱 결과의 배열 크기를 검증하세요.
parseDateRange()가 반환하는 배열의 크기가 정확히 2개인지 검증하지 않고 인덱스 접근을 수행하고 있습니다. 하위 클래스가 잘못된 크기의 배열을 반환할 경우 ArrayIndexOutOfBoundsException이 발생할 수 있습니다.
🔎 방어적 검증 추가 제안
try {
// 1. 기간 파싱 (추상 메서드 호출)
LocalDate[] dateRange = parseDateRange();
+ if (dateRange == null || dateRange.length != 2) {
+ throw new IllegalStateException("parseDateRange()는 정확히 2개의 날짜를 반환해야 합니다.");
+ }
LocalDate startDate = dateRange[0];
LocalDate endDate = dateRange[1];📝 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.
| LocalDate[] dateRange = parseDateRange(); | |
| LocalDate startDate = dateRange[0]; | |
| LocalDate endDate = dateRange[1]; | |
| LocalDate[] dateRange = parseDateRange(); | |
| if (dateRange == null || dateRange.length != 2) { | |
| throw new IllegalStateException("parseDateRange()는 정확히 2개의 날짜를 반환해야 합니다."); | |
| } | |
| LocalDate startDate = dateRange[0]; | |
| LocalDate endDate = dateRange[1]; |
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/AbstractMetricsReader.java
around lines 47 to 49, the code assumes parseDateRange() returns an array of
exactly two LocalDate elements and directly accesses [0] and [1]; add a
defensive check that the returned array is non-null and length == 2, and throw a
clear IllegalArgumentException (or handle appropriately) when the contract is
violated so you avoid ArrayIndexOutOfBoundsException and provide a meaningful
error message indicating the method and expected format.
| /** | ||
| * 기간별 상품 집계 (GROUP BY product_id) | ||
| * - 배치 Job에서 사용하는 핵심 쿼리 | ||
| * | ||
| * @param startDate 시작 날짜 (포함) | ||
| * @param endDate 종료 날짜 (포함) | ||
| * @return 집계 결과 [productId, viewCount, likeCount, salesCount, orderCount] | ||
| */ | ||
| @Query(""" | ||
| SELECT m.id.productId, | ||
| SUM(m.viewCount), | ||
| SUM(m.likeCount), | ||
| SUM(m.salesCount), | ||
| SUM(m.orderCount), | ||
| SUM(m.totalSalesAmount) | ||
| FROM ProductMetricsEntity m | ||
| WHERE m.id.metricDate BETWEEN :startDate AND :endDate | ||
| GROUP BY m.id.productId | ||
| """) | ||
| List<Object[]> aggregateByDateRange( | ||
| @Param("startDate") LocalDate startDate, | ||
| @Param("endDate") LocalDate endDate); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Javadoc과 실제 쿼리 결과가 불일치합니다.
Line 25의 Javadoc에는 5개 필드만 명시되어 있지만, 실제 쿼리는 6개 필드를 반환합니다:
- Javadoc:
[productId, viewCount, likeCount, salesCount, orderCount] - 실제 쿼리:
[productId, SUM(viewCount), SUM(likeCount), SUM(salesCount), SUM(orderCount), SUM(totalSalesAmount)]
totalSalesAmount 필드가 Javadoc에서 누락되었습니다.
🔎 Javadoc 수정 제안
/**
* 기간별 상품 집계 (GROUP BY product_id)
* - 배치 Job에서 사용하는 핵심 쿼리
*
* @param startDate 시작 날짜 (포함)
* @param endDate 종료 날짜 (포함)
- * @return 집계 결과 [productId, viewCount, likeCount, salesCount, orderCount]
+ * @return 집계 결과 [productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount]
*/📝 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.
| /** | |
| * 기간별 상품 집계 (GROUP BY product_id) | |
| * - 배치 Job에서 사용하는 핵심 쿼리 | |
| * | |
| * @param startDate 시작 날짜 (포함) | |
| * @param endDate 종료 날짜 (포함) | |
| * @return 집계 결과 [productId, viewCount, likeCount, salesCount, orderCount] | |
| */ | |
| @Query(""" | |
| SELECT m.id.productId, | |
| SUM(m.viewCount), | |
| SUM(m.likeCount), | |
| SUM(m.salesCount), | |
| SUM(m.orderCount), | |
| SUM(m.totalSalesAmount) | |
| FROM ProductMetricsEntity m | |
| WHERE m.id.metricDate BETWEEN :startDate AND :endDate | |
| GROUP BY m.id.productId | |
| """) | |
| List<Object[]> aggregateByDateRange( | |
| @Param("startDate") LocalDate startDate, | |
| @Param("endDate") LocalDate endDate); | |
| /** | |
| * 기간별 상품 집계 (GROUP BY product_id) | |
| * - 배치 Job에서 사용하는 핵심 쿼리 | |
| * | |
| * @param startDate 시작 날짜 (포함) | |
| * @param endDate 종료 날짜 (포함) | |
| * @return 집계 결과 [productId, viewCount, likeCount, salesCount, orderCount, totalSalesAmount] | |
| */ | |
| @Query(""" | |
| SELECT m.id.productId, | |
| SUM(m.viewCount), | |
| SUM(m.likeCount), | |
| SUM(m.salesCount), | |
| SUM(m.orderCount), | |
| SUM(m.totalSalesAmount) | |
| FROM ProductMetricsEntity m | |
| WHERE m.id.metricDate BETWEEN :startDate AND :endDate | |
| GROUP BY m.id.productId | |
| """) | |
| List<Object[]> aggregateByDateRange( | |
| @Param("startDate") LocalDate startDate, | |
| @Param("endDate") LocalDate endDate); |
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
around lines 19 to 40, the Javadoc lists five returned fields but the JPQL query
returns six (adds SUM(totalSalesAmount)); update the method Javadoc to list all
six returned elements in order—[productId, viewCount, likeCount, salesCount,
orderCount, totalSalesAmount]—and adjust the descriptive sentence for the
@return to reflect the sixth field so the documentation matches the query
result.
| @Modifying | ||
| @Query("DELETE FROM MonthlyRankEntity m WHERE m.id.yearMonth = :yearMonth") | ||
| long deleteByIdYearMonth(@Param("yearMonth") String yearMonth); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Search for calls to deleteByIdYearMonth or references to it
rg -n "deleteByIdYearMonth|deleteByYearMonth" --type=java | head -30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1373
🏁 Script executed:
# Also check the repository definition and its context
fd "MonthlyRankJpaRepository" --type fRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 283
🏁 Script executed:
# Search for service classes that might use MonthlyRankJpaRepository
rg -n "MonthlyRankJpaRepository" --type=java | head -30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 809
🏁 Script executed:
# Read the MonthlyRankRepositoryImpl in commerce-batch
cat apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1317
🏁 Script executed:
# Read the MonthlyRankWriter to see the context and transaction management
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankWriter.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 2847
🏁 Script executed:
# Check commerce-api MonthlyRankRepositoryImpl for its delete method
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.javaRepository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1333
🏁 Script executed:
# Search for service classes calling deleteByYearMonth in commerce-api
rg -n "deleteByYearMonth|deleteByYearMonth" apps/commerce-api --type=java -B 3 -A 3 | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 938
@Modifying 쿼리에 명시적 @transactional 추가 필요
@Modifying 어노테이션이 적용된 삭제 쿼리는 MonthlyRankRepositoryImpl.deleteByYearMonth() 메서드에 @Transactional 어노테이션을 추가하여 명시적으로 트랜잭션 컨텍스트를 선언해야 합니다. 현재는 배치 실행 시 Spring Batch의 트랜잭션 매니저가 암묵적으로 제공하지만, API 또는 다른 컨텍스트에서 호출될 경우 InvalidDataAccessApiUsageException이 발생할 수 있습니다. commerce-api의 구현체도 동일한 문제를 가지고 있으므로 함께 수정이 필요합니다.
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java
around lines 32 to 34, the @Modifying delete query requires an explicit
transactional boundary when invoked outside Spring Batch; add @Transactional to
the service/repository method that calls this query
(MonthlyRankRepositoryImpl.deleteByYearMonth) and import
org.springframework.transaction.annotation.Transactional so the delete runs
within a transaction, and apply the same change to the equivalent method in
commerce-api implementation to prevent InvalidDataAccessApiUsageException when
called from non-batch contexts.
- 집계 결과 반환 값에 totalSalesAmount 필드 추가 - 집계 결과 배열 길이 검증 로직 수정
parseDateRange() 메서드가 정확히 2개의 날짜를 반환하는지 검증하는 로직을 추가하여 예외 상황을 처리
- 집계 쿼리 결과를 Object[]에서 ProductMetricsAggregation으로 변경 - 랭킹 집계 로직에서 DTO 변환 및 점수 계산 방식 개선 - rankPosition 타입을 long에서 int로 변경하여 일관성 강화
Feat/batch
Spring Batch
🧩 Ranking API
@coderabbitai 체크리스트를 확인해줘 |
|
Tip For best results, initiate chat on the files or code changes. @looper88888, 체크리스트를 확인한 결과를 말씀드리겠습니다. ✅ Spring Batch✓ Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다.
✓ Chunk Oriented Processing (Reader/Processor/Writer) 기반의 배치 처리를 구현했다.
✓ 집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재했다.
✅ 🧩 Ranking API✓ API가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다.
모든 체크리스트 항목이 구현되었습니다! 🎉 특히 인상적인 부분:
|
📌 Summary
Spring Batch를 활용한 주간/월간 랭킹 집계 시스템을 구현하고, API 구조를 개선했습니다.
주요 구현 내용:
RankingV1Controller분리 및 Java 8 Date API 활용구현된 배치 프로세스:
product_metrics7일치 →mv_product_rank_weeklyTOP 100product_metrics30일치 →mv_product_rank_monthlyTOP 100viewCount×1 + likeCount×3 + salesCount×5 + orderCount×2💬 Review Points
1. Spring Batch Job 설계 (Chunk-Oriented Processing)
배경 및 문제 상황:
대량의 메트릭 데이터를 집계하여 주간/월간 랭킹을 생성해야 하는데, 메모리 효율성과 트랜잭션 안정성을 고려했습니다.
해결 방안:
Chunk-Oriented Processing 패턴을 적용하여 Reader → Processor → Writer 구조로 설계했습니다.
구현 세부사항:
1) Job Configuration - 파라미터 기반 동작:
2) ItemReader - DB 집계 쿼리 + TOP 100 필터링:
3) ItemWriter - 멱등성 보장:
고민한 점:
2. 핵심 비즈니스 로직 모듈화 및 단위 테스트
배경 및 문제 상황: 배치 Job의 복잡한 로직(점수 계산, 날짜 파싱, 집계 처리)을 통합 테스트로만 검증하면 실패 시 원인 파악이 어렵고, 테스트 실행 시간이 오래 걸릴것으로 예상했습니다.
해결 방안: 핵심 비즈니스 로직을 별도 클래스로 분리하고 단위 테스트로 검증한 후, Job에 조립하는 방식을 채택했습니다.
구현 세부사항:
1) ScoreCalculator - 점수 계산 로직:
2) DateRangeParser - 날짜 범위 파싱:
3) RankingAggregator - 집계 및 순위 부여:
고민한 점:
단위 테스트 우선 접근: 복잡한 배치 로직을 작은 단위로 분해하여 각각 단위 테스트로 검증한 후 조립하는 방식으로, 문제 발생 시 빠른 원인 파악이 가능하도록 했습니다.
점수 계산 공식: Redis ZSET의 가중치와 동일하게 유지하여 일간/주간/월간 랭킹 간 일관성을 보장했습니다.
ISO Week 표준: WeekFields.ISO를 사용하여 국제 표준 주차 계산을 적용했습니다.
3. Materialized View 설계 (@EmbeddedId 복합키)
배경 및 문제 상황: 주간/월간 랭킹 데이터를 효율적으로 저장하고 조회하기 위한 테이블 구조가 필요했습니다. 특히 (product_id, year_week) 또는 (product_id, year_month) 복합키로 유니크 제약을 보장해야 했습니다.
해결 방안: Hibernate 6.x 권장 방식인 @EmbeddedId를 사용하여 복합키를 객체로 캡슐화했습니다.
구현 세부사항:
1) 복합키 클래스 (@embeddable):
고민한 점:
@EmbeddedId vs @IdClass: @EmbeddedId는 복합키를 객체로 캡슐화하여 타입 안전성을 제공하고, Hibernate 6.x에서 권장하는 방식을 이용해 구현하도록 했습니다.
인덱스 설계: (year_week, rank_position) 복합 인덱스로 주차별 순위 조회를 최적화했습니다.
순위 검증: Entity 생성 시 1~100 범위 검증으로 데이터 무결성을 보장했습니다.
4. Controller 리팩토링 - 관심사 분리
배경 및 문제 상황: 기존 ProductV1Controller에 랭킹 관련 메서드가 포함되어 있어 관심사가 혼재되어 있었습니다. 또한 Java 8 Date API를 활용한 파라미터 처리가 필요했습니다.
해결 방안: 랭킹 전용 RankingV1Controller를 분리하고, Java 8 Date API(WeekFields, YearMonth)를 활용하여 파라미터를 자동 처리하도록 구현했습니다.
구현 세부사항:
1) RankingV1Controller - 전용 컨트롤러:
2) ProductV1Controller - 랭킹 메서드 제거:
고민한 점:
5. E2E 테스트
구현 세부사항:
테스트 커버리지:
=> 총 11개 테스트 케이스 모두 통과
✅ Checklist
🧱 Spring Batch (3/3)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.javayearWeek,yearMonthWeeklyMetricsReader.java,MonthlyMetricsReader.javaRankingProcessor.java(공통)WeeklyRankWriter.java,MonthlyRankWriter.javamodules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.javamodules/jpa/src/main/java/com/loopers/domain/ranking/MonthlyRankEntity.java@EmbeddedId) + TOP 100 + 순위 정보🧩 Ranking API (1/1)
GET /api/v1/rankings(Redis ZSET)GET /api/v1/rankings/period(MV 테이블)apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java🧪 핵심 로직 단위 테스트 (4/4)
ScoreCalculator) 검증apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/ScoreCalculatorUnitTest.javaDateRangeParser) 검증apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/DateRangeParserUnitTest.javaRankingAggregator) 검증apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/RankingAggregatorUnitTest.javaRankingAggregation) 테스트apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/dto/RankingAggregationUnitTest.java🔧 코드 품질
🧪 E2E 테스트
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RankingBatchE2ETest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/ManualBatchJobTest.java📎 References
주요 구현 파일
배치 시스템:
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java,MonthlyRankingJobConfig.javaWeeklyMetricsReader.java,MonthlyMetricsReader.javaWeeklyRankWriter.java,MonthlyRankWriter.javaScoreCalculator.java,DateRangeParser.java,RankingAggregator.javaAPI 시스템:
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java(리팩토링)도메인 모델:
modules/jpa/src/main/java/com/loopers/domain/ranking/WeeklyRankEntity.java,WeeklyRankId.javaMonthlyRankEntity.java,MonthlyRankId.java테스트:
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/support/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java기술 참고 자료
지금 이 pr 을 깔끔하게 code - block 을 정리해서 재 작성해줘
Summary by CodeRabbit
새로운 기능
개선사항
문서화
테스트
✏️ Tip: You can customize this high-level summary in your review settings.