-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-10] Batch 기반 주간·월간 랭킹 시스템 구현 #415
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: dfdf0202
Are you sure you want to change the base?
Changes from all commits
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,8 @@ | ||
| package com.loopers.domain.ranking.model; | ||
|
|
||
| public record RankingEntry( | ||
| Long productId, | ||
| double score, | ||
| long rank | ||
| ) { | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.loopers.domain.ranking.model; | ||
|
|
||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
|
|
||
| import java.util.Locale; | ||
|
|
||
| public enum RankingPeriod { | ||
| DAILY, | ||
| WEEKLY, | ||
| MONTHLY; | ||
|
|
||
| public static RankingPeriod from(String value) { | ||
| if (value == null || value.isBlank()) { | ||
| return DAILY; | ||
| } | ||
| try { | ||
| return RankingPeriod.valueOf(value.toUpperCase(Locale.ROOT)); | ||
| } catch (IllegalArgumentException e) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 랭킹 기간입니다."); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,35 @@ | ||||||||||||||||||||||||||||||||||||||||
| package com.loopers.domain.ranking.model; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import com.loopers.support.error.CoreException; | ||||||||||||||||||||||||||||||||||||||||
| import com.loopers.support.error.ErrorType; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import java.time.LocalDate; | ||||||||||||||||||||||||||||||||||||||||
| import java.time.format.DateTimeFormatter; | ||||||||||||||||||||||||||||||||||||||||
| import java.time.format.DateTimeParseException; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| public record RankingQuery( | ||||||||||||||||||||||||||||||||||||||||
| RankingPeriod period, | ||||||||||||||||||||||||||||||||||||||||
| LocalDate date, | ||||||||||||||||||||||||||||||||||||||||
| int page, | ||||||||||||||||||||||||||||||||||||||||
| int size | ||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||
| private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| public static RankingQuery of(String period, String date, int page, int size) { | ||||||||||||||||||||||||||||||||||||||||
| validate(page, size); | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| return new RankingQuery(RankingPeriod.from(period), LocalDate.parse(date, DATE_FORMAT), page, size); | ||||||||||||||||||||||||||||||||||||||||
| } catch (DateTimeParseException e) { | ||||||||||||||||||||||||||||||||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+18
to
+25
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.
🛡️ 제안 public static RankingQuery of(String period, String date, int page, int size) {
validate(page, size);
+ if (date == null || date.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "날짜는 필수 입력입니다. (yyyyMMdd)");
+ }
try {
return new RankingQuery(RankingPeriod.from(period), LocalDate.parse(date, DATE_FORMAT), page, size);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private static void validate(int page, int size) { | ||||||||||||||||||||||||||||||||||||||||
| if (page < 1) { | ||||||||||||||||||||||||||||||||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "페이지 번호는 1 이상이어야 합니다."); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (size < 1 || size > 100) { | ||||||||||||||||||||||||||||||||||||||||
| throw new CoreException(ErrorType.BAD_REQUEST, "페이지 크기는 1 이상 100 이하여야 합니다."); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.domain.ranking.model; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record RankingSlice( | ||
| List<RankingEntry> entries, | ||
| long totalCount | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.domain.ranking.repository; | ||
|
|
||
| import com.loopers.domain.ranking.model.RankingEntry; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MonthlyRankingRepository { | ||
| List<RankingEntry> getTopRankings(String periodKey, int offset, int size); | ||
| long getTotalCount(String periodKey); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.domain.ranking.repository; | ||
|
|
||
| import com.loopers.domain.ranking.model.RankingEntry; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface WeeklyRankingRepository { | ||
| List<RankingEntry> getTopRankings(String periodKey, int offset, int size); | ||
| long getTotalCount(String periodKey); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.domain.ranking.service; | ||
|
|
||
| import com.loopers.domain.ranking.model.RankingSlice; | ||
| import com.loopers.domain.ranking.repository.MonthlyRankingRepository; | ||
| import com.loopers.support.util.RankingPeriodKeyFactory; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class MonthlyRankingService { | ||
|
|
||
| private final MonthlyRankingRepository repository; | ||
|
|
||
| public RankingSlice getRankings(LocalDate date, int page, int size) { | ||
| int offset = (page - 1) * size; | ||
| String periodKey = RankingPeriodKeyFactory.toMonthlyKey(date); | ||
| return new RankingSlice( | ||
| repository.getTopRankings(periodKey, offset, size), | ||
| repository.getTotalCount(periodKey) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.domain.ranking.service; | ||
|
|
||
| import com.loopers.domain.ranking.model.RankingSlice; | ||
| import com.loopers.domain.ranking.repository.WeeklyRankingRepository; | ||
| import com.loopers.support.util.RankingPeriodKeyFactory; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDate; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class WeeklyRankingService { | ||
|
|
||
| private final WeeklyRankingRepository repository; | ||
|
|
||
| public RankingSlice getRankings(LocalDate date, int page, int size) { | ||
| int offset = (page - 1) * size; | ||
| String periodKey = RankingPeriodKeyFactory.toWeeklyKey(date); | ||
| return new RankingSlice( | ||
| repository.getTopRankings(periodKey, offset, size), | ||
| repository.getTotalCount(periodKey) | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package com.loopers.infrastructure.ranking.entity; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.IdClass; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.io.Serializable; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Entity | ||
| @Table(name = "mv_product_rank_monthly") | ||
| @IdClass(MonthlyRankingEntity.MonthlyRankingId.class) | ||
| public class MonthlyRankingEntity { | ||
|
|
||
| @Id | ||
| @Column(nullable = false, length = 16) | ||
| private String periodKey; | ||
|
|
||
| @Id | ||
| @Column(nullable = false) | ||
| private int rankNo; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(nullable = false) | ||
| private long viewCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long likeCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long orderCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| public record MonthlyRankingId(String periodKey, int rankNo) implements Serializable { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package com.loopers.infrastructure.ranking.entity; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.IdClass; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.io.Serializable; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Entity | ||
| @Table(name = "mv_product_rank_weekly") | ||
| @IdClass(WeeklyRankingEntity.WeeklyRankingId.class) | ||
| public class WeeklyRankingEntity { | ||
|
|
||
| @Id | ||
| @Column(nullable = false, length = 16) | ||
| private String periodKey; | ||
|
|
||
| @Id | ||
| @Column(nullable = false) | ||
| private int rankNo; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(nullable = false) | ||
| private double score; | ||
|
|
||
| @Column(nullable = false) | ||
| private long viewCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long likeCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private long orderCount; | ||
|
|
||
| @Column(nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| public record WeeklyRankingId(String periodKey, int rankNo) implements Serializable { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.loopers.infrastructure.ranking.repository; | ||
|
|
||
| import com.loopers.infrastructure.ranking.entity.MonthlyRankingEntity; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface MonthlyRankingJpaRepository extends JpaRepository<MonthlyRankingEntity, MonthlyRankingEntity.MonthlyRankingId> { | ||
| List<MonthlyRankingEntity> findByPeriodKeyOrderByRankNoAsc(String periodKey, Pageable pageable); | ||
| long countByPeriodKey(String periodKey); | ||
| } |
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
도메인 모델 통일 목적의 record 도입에 이견이 없다.
다만 컨텍스트 스니펫에 따르면
RankingRepository인터페이스는 여전히 내부RankingRepository.RankingEntry를 선언하고 있고RankingRepositoryImpl.getProductRanking은 이미 신규 도메인RankingEntry를 반환하는 상태다. 또한RankingService.getProductRanking/getProductRank도RankingRepository.RankingEntry를 그대로 참조하고 있어 컴파일 레벨에서는 맞더라도 타입 일원화가 미완이다. 운영 관점에서 두 동명 타입이 공존하면 이후 리팩터링/IDE 자동완성 시 잘못된 타입 참조로 회귀 버그가 유입될 위험이 있다.RankingRepository인터페이스의 내부RankingEntryrecord를 제거하고com.loopers.domain.ranking.model.RankingEntry로 교체,RankingService의 반환/변수 타입도 동일하게 교체한다.RankingService.getProductRanking단위 테스트에서 도메인RankingEntry가 반환되는지 타입을 명시적으로 검증한다.🤖 Prompt for AI Agents