-
Notifications
You must be signed in to change notification settings - Fork 44
[Volume 10] Spring Batch시스템 구현 #420
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: MINJOOOONG
Are you sure you want to change the base?
Changes from all commits
7c9d5f4
0451247
fcffa97
9abc3ce
bfb798a
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,22 @@ | ||
| package com.loopers.application.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.RankPeriod; | ||
| import com.loopers.domain.ranking.RankingService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class RankingFacade { | ||
|
|
||
| private final RankingService rankingService; | ||
|
|
||
| public List<RankingInfo> getRanking(RankPeriod period, LocalDate date, int size) { | ||
| return rankingService.getRanking(period, date, size).stream() | ||
| .map(RankingInfo::from) | ||
| .toList(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.application.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.RankingRepository; | ||
|
|
||
| public record RankingInfo(Long productId, double score, int ranking) { | ||
|
|
||
| public static RankingInfo from(RankingRepository.RankingEntry entry) { | ||
| return new RankingInfo(entry.productId(), entry.score(), entry.ranking()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| public enum RankPeriod { | ||
| DAILY, | ||
| WEEKLY, | ||
| MONTHLY | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| public interface RankingRepository { | ||
|
|
||
| List<RankingEntry> findDailyRanking(LocalDate date, int size); | ||
|
|
||
| List<RankingEntry> findWeeklyRanking(LocalDate baseDate, int size); | ||
|
|
||
| List<RankingEntry> findMonthlyRanking(LocalDate baseDate, int size); | ||
|
|
||
| record RankingEntry(Long productId, double score, int ranking) {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class RankingService { | ||
|
|
||
| private final RankingRepository rankingRepository; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public List<RankingRepository.RankingEntry> getRanking(RankPeriod period, LocalDate date, int size) { | ||
| return switch (period) { | ||
| case DAILY -> rankingRepository.findDailyRanking(date, size); | ||
| case WEEKLY -> rankingRepository.findWeeklyRanking(date, size); | ||
| case MONTHLY -> rankingRepository.findMonthlyRanking(date, size); | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import com.loopers.domain.ranking.RankingRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.jdbc.core.JdbcTemplate; | ||
| import org.springframework.jdbc.core.RowMapper; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
| import java.util.concurrent.atomic.AtomicInteger; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class RankingRepositoryImpl implements RankingRepository { | ||
|
|
||
| private final JdbcTemplate jdbcTemplate; | ||
|
|
||
| private static final String DAILY_SQL = """ | ||
| SELECT product_id, score | ||
| FROM product_metrics | ||
| WHERE metric_date = ? | ||
| ORDER BY score DESC | ||
| LIMIT ? | ||
| """; | ||
|
|
||
| private static final String WEEKLY_SQL = """ | ||
| SELECT product_id, total_score, ranking | ||
| FROM mv_product_rank_weekly | ||
| WHERE base_date = ? | ||
| ORDER BY ranking ASC | ||
| LIMIT ? | ||
| """; | ||
|
|
||
| private static final String MONTHLY_SQL = """ | ||
| SELECT product_id, total_score, ranking | ||
| FROM mv_product_rank_monthly | ||
| WHERE base_date = ? | ||
| ORDER BY ranking ASC | ||
| LIMIT ? | ||
| """; | ||
|
|
||
| @Override | ||
| public List<RankingEntry> findDailyRanking(LocalDate date, int size) { | ||
| AtomicInteger rank = new AtomicInteger(0); | ||
| return jdbcTemplate.query(DAILY_SQL, dailyRowMapper(rank), date, size); | ||
| } | ||
|
|
||
| @Override | ||
| public List<RankingEntry> findWeeklyRanking(LocalDate baseDate, int size) { | ||
| return jdbcTemplate.query(WEEKLY_SQL, mvRowMapper(), baseDate, size); | ||
| } | ||
|
|
||
| @Override | ||
| public List<RankingEntry> findMonthlyRanking(LocalDate baseDate, int size) { | ||
| return jdbcTemplate.query(MONTHLY_SQL, mvRowMapper(), baseDate, size); | ||
| } | ||
|
|
||
| private RowMapper<RankingEntry> dailyRowMapper(AtomicInteger rank) { | ||
| return (rs, rowNum) -> new RankingEntry( | ||
| rs.getLong("product_id"), | ||
| rs.getDouble("score"), | ||
| rank.incrementAndGet() | ||
| ); | ||
| } | ||
|
|
||
| private RowMapper<RankingEntry> mvRowMapper() { | ||
| return (rs, rowNum) -> new RankingEntry( | ||
| rs.getLong("product_id"), | ||
| rs.getDouble("total_score"), | ||
| rs.getInt("ranking") | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.interfaces.api.ranking; | ||
|
|
||
| import com.loopers.interfaces.api.ApiResponse; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| @Tag(name = "Ranking V1 API", description = "상품 랭킹 조회 API") | ||
| public interface RankingV1ApiSpec { | ||
|
|
||
| @Operation( | ||
| summary = "랭킹 조회", | ||
| description = "기간별(DAILY/WEEKLY/MONTHLY) 상품 랭킹을 조회합니다." | ||
| ) | ||
| ApiResponse<RankingV1Dto.RankingListResponse> getRankings( | ||
| @Parameter(description = "조회 기간 (DAILY, WEEKLY, MONTHLY)", required = true) | ||
| String period, | ||
| @Parameter(description = "기준 날짜 (yyyy-MM-dd)", required = true) | ||
| LocalDate date, | ||
| @Parameter(description = "조회 개수 (기본 100, 최대 100)") | ||
| int size | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| package com.loopers.interfaces.api.ranking; | ||
|
|
||
| import com.loopers.application.ranking.RankingFacade; | ||
| import com.loopers.application.ranking.RankingInfo; | ||
| import com.loopers.domain.ranking.RankPeriod; | ||
| import com.loopers.interfaces.api.ApiResponse; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.format.annotation.DateTimeFormat; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @RestController | ||
| @RequestMapping("/api/v1/rankings") | ||
| public class RankingV1Controller implements RankingV1ApiSpec { | ||
|
|
||
| private static final int DEFAULT_SIZE = 100; | ||
| private static final int MAX_SIZE = 100; | ||
|
|
||
| private final RankingFacade rankingFacade; | ||
|
|
||
| @GetMapping | ||
| @Override | ||
| public ApiResponse<RankingV1Dto.RankingListResponse> getRankings( | ||
| @RequestParam("period") String period, | ||
| @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, | ||
| @RequestParam(value = "size", defaultValue = "100") int size | ||
| ) { | ||
| RankPeriod rankPeriod = parseRankPeriod(period); | ||
| int validSize = Math.min(Math.max(size, 1), MAX_SIZE); | ||
|
|
||
| List<RankingInfo> rankings = rankingFacade.getRanking(rankPeriod, date, validSize); | ||
| RankingV1Dto.RankingListResponse response = RankingV1Dto.RankingListResponse.from(rankings); | ||
|
|
||
| return ApiResponse.success(response); | ||
| } | ||
|
|
||
| private RankPeriod parseRankPeriod(String period) { | ||
| try { | ||
| return RankPeriod.valueOf(period.toUpperCase()); | ||
| } catch (IllegalArgumentException e) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period); | ||
| } | ||
| } | ||
|
Comment on lines
+45
to
+51
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.
JVM 기본 로케일이 터키어( ♻️ 제안 수정안- private RankPeriod parseRankPeriod(String period) {
- try {
- return RankPeriod.valueOf(period.toUpperCase());
- } catch (IllegalArgumentException e) {
- throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period);
- }
- }
+ private RankPeriod parseRankPeriod(String period) {
+ try {
+ return RankPeriod.valueOf(period.toUpperCase(java.util.Locale.ROOT));
+ } catch (IllegalArgumentException | NullPointerException e) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period);
+ }
+ }추가 테스트: 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.loopers.interfaces.api.ranking; | ||
|
|
||
| import com.loopers.application.ranking.RankingInfo; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public class RankingV1Dto { | ||
|
|
||
| public record RankingResponse(int ranking, Long productId, double score) { | ||
| public static RankingResponse from(RankingInfo info) { | ||
| return new RankingResponse(info.ranking(), info.productId(), info.score()); | ||
| } | ||
| } | ||
|
|
||
| public record RankingListResponse(List<RankingResponse> rankings) { | ||
| public static RankingListResponse from(List<RankingInfo> infos) { | ||
| List<RankingResponse> rankings = infos.stream() | ||
| .map(RankingResponse::from) | ||
| .toList(); | ||
| return new RankingListResponse(rankings); | ||
| } | ||
| } | ||
| } |
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:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2526
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 458
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 959
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 802
@transactional을 Application 계층(RankingFacade)으로 이동해야 한다
PR#189 아키텍처 원칙에 따르면
@Transactional선언은 Application 계층(ApplicationService)의 책임이다. 현재 RankingService는 Domain 계층에 위치하고 있으므로@Transactional(readOnly=true)를 제거하고, Application 계층인 RankingFacade의 getRanking() 메서드에@Transactional(readOnly=true)를 선언해야 한다. Infrastructure 계층인 RankingRepositoryImpl은 JdbcTemplate 기반이므로 올바르게@Transactional이없다.🤖 Prompt for AI Agents