-
Notifications
You must be signed in to change notification settings - Fork 44
[10주차] Spring Batch 기반 주간/월간 랭킹 시스템 구현 - 정인철 #401
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
base: incheol789
Are you sure you want to change the base?
Changes from all commits
fb8c99b
9db58e6
8abb653
781df41
ffad6f0
933b4cb
d7652e0
4cb6777
3be29f7
3f247eb
d37ea7b
eb70ab7
62ccbca
1efd47b
eca8412
0b117cb
e529603
6b0e5f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import lombok.Getter; | ||
| import org.hibernate.annotations.Immutable; | ||
|
|
||
| @Entity | ||
| @Immutable | ||
| @Table(name = "mv_product_rank_monthly") | ||
| @Getter | ||
| public class MvProductRankMonthlyReadModel { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(nullable = false) | ||
| private long viewCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long likeCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long salesCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(name = "`ranking`", nullable = false) | ||
| private int ranking; | ||
|
|
||
| @Column(name = "`year_month`", nullable = false, length = 10) | ||
| private String yearMonth; | ||
|
|
||
| protected MvProductRankMonthlyReadModel() {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import lombok.Getter; | ||
| import org.hibernate.annotations.Immutable; | ||
|
|
||
| /** | ||
| * 주간 MV 읽기 전용 엔티티 (commerce-api용). | ||
| * commerce-batch에서 적재한 mv_product_rank_weekly 테이블을 조회만 한다. | ||
| * @Immutable: Hibernate의 dirty checking 대상에서 제외 (읽기 전용 최적화) | ||
| */ | ||
| @Entity | ||
| @Immutable | ||
| @Table(name = "mv_product_rank_weekly") | ||
| @Getter | ||
| public class MvProductRankWeeklyReadModel { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(nullable = false) | ||
| private long viewCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long likeCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long salesCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(name = "`ranking`", nullable = false) | ||
| private int ranking; | ||
|
|
||
| @Column(name = "year_week", nullable = false, length = 10) | ||
| private String yearWeek; | ||
|
|
||
| protected MvProductRankWeeklyReadModel() {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankMonthlyReadModel; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthlyReadModel, Long> { | ||
|
|
||
| /** 특정 월의 랭킹을 순위순으로 페이징 조회 */ | ||
| List<MvProductRankMonthlyReadModel> findByYearMonthOrderByRankingAsc(String yearMonth, Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.MvProductRankWeeklyReadModel; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeeklyReadModel, Long> { | ||
|
|
||
| /** 특정 주차의 랭킹을 순위순으로 페이징 조회 */ | ||
| List<MvProductRankWeeklyReadModel> findByYearWeekOrderByRankingAsc(String yearWeek, Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ | |
| import com.loopers.application.ranking.RankingFacade; | ||
| import com.loopers.application.ranking.RankingInfo; | ||
| import com.loopers.interfaces.api.ApiResponse; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
|
|
@@ -11,6 +13,8 @@ | |
|
|
||
| import java.time.LocalDate; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.time.format.DateTimeParseException; | ||
| import java.util.Set; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @RestController | ||
|
|
@@ -21,22 +25,42 @@ public class RankingV1Controller implements RankingV1ApiSpec { | |
|
|
||
| private static final DateTimeFormatter DATE_FORMAT = | ||
| DateTimeFormatter.ofPattern("yyyyMMdd"); | ||
| private static final Set<String> VALID_PERIODS = Set.of("daily", "weekly", "monthly"); | ||
|
|
||
| @GetMapping | ||
| @Override | ||
| public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings( | ||
| @RequestParam(defaultValue = "daily") String period, | ||
| @RequestParam(required = false) String date, | ||
| @RequestParam(defaultValue = "20") int size, | ||
| @RequestParam(defaultValue = "1") int page | ||
| ) { | ||
| // date 미지정 시 오늘 날짜를 기본값으로 사용 | ||
| if (!VALID_PERIODS.contains(period)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 기간입니다: " + period); | ||
| } | ||
| if (page < 1) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다."); | ||
| } | ||
| if (size < 1 || size > 100) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "size는 1~100 범위여야 합니다."); | ||
| } | ||
|
|
||
| if (date == null || date.isBlank()) { | ||
| date = LocalDate.now().format(DATE_FORMAT); | ||
| } else { | ||
| try { | ||
| LocalDate.parse(date, DATE_FORMAT); | ||
| } catch (DateTimeParseException e) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); | ||
| } | ||
|
Comment on lines
+50
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: No, LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) does not reject invalid dates strictly by default. It uses SMART resolver style, which adjusts invalid day-of-month values to the last valid day of the month (e.g., "20240230" parses to 2024-02-29). For strict validation of an 8-digit yyyyMMdd string, use DateTimeFormatter.BASIC_ISO_DATE, which matches the exact format and uses STRICT resolver style internally, rejecting invalid dates like "20240230" with DateTimeParseException. Alternatively, create a custom formatter: DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT), which also rejects invalid dates. DateTimeFormatter.BASIC_ISO_DATE is recommended as it is predefined, official for basic ISO date (yyyyMMdd), and ensures strict parsing without lenient adjustments. Citations:
엄격한 날짜 검증으로 불가능한 입력을 400으로 거부해야 한다 현재 50번 줄의 포맷터를 두 가지 방법 중 선택하여 수정한다:
이후 방법 1: BASIC_ISO_DATE 적용 (권장) import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
...
private static final DateTimeFormatter DATE_FORMAT =
- DateTimeFormatter.ofPattern("yyyyMMdd");
+ DateTimeFormatter.BASIC_ISO_DATE;방법 2: STRICT 리졸버 스타일 명시 import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
...
private static final DateTimeFormatter DATE_FORMAT =
- DateTimeFormatter.ofPattern("yyyyMMdd");
+ DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT);🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Facade에서 ZSET 조회 + 상품/브랜드 Aggregation 수행 | ||
| RankingInfo.RankingPageResponse info = rankingFacade.getRankings(date, page, size); | ||
| // application 레이어 Info → interfaces 레이어 DTO 변환 | ||
| RankingInfo.RankingPageResponse info = switch (period) { | ||
| case "weekly" -> rankingFacade.getRankingsWeekly(date, page, size); | ||
| case "monthly" -> rankingFacade.getRankingsMonthly(date, page, size); | ||
| default -> rankingFacade.getRankings(date, page, size); | ||
| }; | ||
|
|
||
| return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(info)); | ||
| } | ||
|
|
||
|
|
||
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.
월간 키 생성에서
substring에 의존하지 말아야 한다Line 60은 입력 앞 6자리만 잘라
yearMonth를 만들기 때문에, 이 서비스가 다른 진입점에서 재사용되면 짧은 값은StringIndexOutOfBoundsException, 잘못된 값은 존재하지 않는 월 키로 흘러갈 수 있다. 운영에서는 같은 날짜 입력인데 주간/월간 경로의 실패 방식이 달라져 원인 추적이 어려워진다. 주간 경로처럼 날짜를 먼저 해석한 뒤yyyyMM으로 포맷하도록 맞추는 편이 안전하다. 추가 테스트로는"202604","20261301","20260230"입력에서 월간 경로가 예측 가능한 예외 흐름을 가지는지 확인해야 한다.예시 수정안
/** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */ public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) { - String yearMonth = date.substring(0, 6); // "yyyyMMdd" → "yyyyMM" + LocalDate parsedDate = LocalDate.parse(date, DATE_FORMAT); + String yearMonth = parsedDate.format(DateTimeFormatter.ofPattern("yyyyMM")); int offset = (page - 1) * size; return rankingRepository.getTopNMonthly(yearMonth, offset, size); }🤖 Prompt for AI Agents