-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-10] Spring Batch 기반 주간·월간 랭킹 시스템 구현 #412
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: leeedohyun
Are you sure you want to change the base?
Changes from 5 commits
4201d31
7eb7dd4
21e0380
cd4ddda
2f8e244
479004d
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,46 @@ | ||
| package com.loopers.application.ranking; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.List; | ||
|
|
||
| import com.loopers.application.shared.annotation.UseCase; | ||
| import com.loopers.domain.ranking.ProductRankingMonthly; | ||
| import com.loopers.domain.ranking.RankingItem; | ||
| import com.loopers.domain.ranking.RankingService; | ||
| import com.loopers.support.page.PageSize; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| /** | ||
| * 월간 인기 상품 랭킹을 페이지 단위로 조회한다. | ||
| * | ||
| * <p>배치가 집계한 지정 scoreDate의 월간 랭킹을 반환한다.</p> | ||
| */ | ||
| @UseCase | ||
| @RequiredArgsConstructor | ||
| public class ReadMonthlyRankingsUseCase { | ||
|
|
||
| private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); | ||
|
|
||
| private final RankingService rankingService; | ||
| private final RankingResultAssembler rankingResultAssembler; | ||
|
|
||
| /** | ||
| * @param userId 사용자 ID (비로그인 시 null) | ||
| * @param date 조회 기준일 (yyyyMMdd 형식) | ||
| * @param pageSize 페이지 정보 | ||
| * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) | ||
| */ | ||
| public RankingPageResult execute(Long userId, String date, PageSize pageSize) { | ||
| LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); | ||
| List<ProductRankingMonthly> rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size()); | ||
| List<RankingItem> rankingItems = RankingItem.toRankingItems( | ||
| rankings, | ||
| pageSize.offset(), | ||
| ProductRankingMonthly::getProductId, | ||
| ProductRankingMonthly::getScore | ||
| ); | ||
| return rankingResultAssembler.assemble(userId, rankingItems, pageSize); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,48 @@ | ||||||||||||||||||||||||||||
| package com.loopers.application.ranking; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import java.time.LocalDate; | ||||||||||||||||||||||||||||
| import java.time.format.DateTimeFormatter; | ||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import com.loopers.application.shared.annotation.UseCase; | ||||||||||||||||||||||||||||
| import com.loopers.domain.ranking.ProductRankingWeekly; | ||||||||||||||||||||||||||||
| import com.loopers.domain.ranking.RankingItem; | ||||||||||||||||||||||||||||
| import com.loopers.domain.ranking.RankingService; | ||||||||||||||||||||||||||||
| import com.loopers.support.page.PageSize; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * 주간 인기 상품 랭킹을 페이지 단위로 조회한다. | ||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||
| * <p>배치가 집계한 지정 scoreDate의 주간 랭킹을 반환한다.</p> | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| @UseCase | ||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||
| public class ReadWeeklyRankingsUseCase { | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| private final RankingService rankingService; | ||||||||||||||||||||||||||||
| private final RankingResultAssembler rankingResultAssembler; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * @param userId 사용자 ID (비로그인 시 null) | ||||||||||||||||||||||||||||
| * @param date 조회 기준일 (yyyyMMdd 형식) | ||||||||||||||||||||||||||||
| * @param pageSize 페이지 정보 | ||||||||||||||||||||||||||||
| * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| public RankingPageResult execute(Long userId, String date, PageSize pageSize) { | ||||||||||||||||||||||||||||
| LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); | ||||||||||||||||||||||||||||
| List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked( | ||||||||||||||||||||||||||||
| scoreDate, pageSize.page(), pageSize.size()); | ||||||||||||||||||||||||||||
|
Comment on lines
+38
to
+46
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.
잘못된 운영 관점에서 API 에러 응답 스키마 불일치는 클라이언트 처리 분기를 늘리고 SLO 지표에도 오탐을 만든다. 주간/월간 UseCase 모두 추가 테스트: 🛡️ 제안 수정 public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
- LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
+ LocalDate scoreDate;
+ try {
+ scoreDate = LocalDate.parse(date, DATE_FORMAT);
+ } catch (DateTimeParseException e) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 한다.");
+ }
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(Based on learnings: "loop-pack-be-l2-vol3-java 프로젝트에서 에러 처리는 CoreException을 통해 ApiControllerAdvice로 라우팅하도록 통일한다." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| List<RankingItem> rankingItems = RankingItem.toRankingItems( | ||||||||||||||||||||||||||||
| rankings, | ||||||||||||||||||||||||||||
| pageSize.offset(), | ||||||||||||||||||||||||||||
| ProductRankingWeekly::getProductId, | ||||||||||||||||||||||||||||
| ProductRankingWeekly::getScore | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| return rankingResultAssembler.assemble(userId, rankingItems, pageSize); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 월간 랭킹 데이터를 조회하는 포트. | ||
| * | ||
| * <p>배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.</p> | ||
| */ | ||
| public interface MonthlyRankingRepository { | ||
|
|
||
| /** | ||
| * 지정한 scoreDate의 월간 랭킹을 점수 내림차순으로 조회한다. | ||
| * | ||
| * @param scoreDate 조회 기준일 | ||
| * @param page 페이지 번호 (0-based) | ||
| * @param size 페이지 크기 | ||
| * @return 월간 랭킹 엔티티 목록 (점수 내림차순) | ||
| */ | ||
| List<ProductRankingMonthly> readTopRanked(LocalDate scoreDate, int page, int size); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||
| package com.loopers.domain.ranking; | ||||||
|
|
||||||
| import java.time.LocalDate; | ||||||
|
|
||||||
| import jakarta.persistence.Column; | ||||||
| import jakarta.persistence.Entity; | ||||||
| import jakarta.persistence.GeneratedValue; | ||||||
| import jakarta.persistence.GenerationType; | ||||||
| import jakarta.persistence.Id; | ||||||
| import jakarta.persistence.Index; | ||||||
| import jakarta.persistence.Table; | ||||||
|
|
||||||
| import lombok.AccessLevel; | ||||||
| import lombok.Getter; | ||||||
| import lombok.NoArgsConstructor; | ||||||
|
|
||||||
| /** | ||||||
| * 월간 인기 상품 랭킹 엔티티. | ||||||
| * | ||||||
| * <p>배치가 집계한 {@code mv_product_rank_monthly} 테이블의 읽기 전용 매핑이다.</p> | ||||||
| */ | ||||||
| @Entity | ||||||
| @Table(name = "mv_product_rank_monthly", indexes = { | ||||||
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | ||||||
|
||||||
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | |
| @Index(name = "idx_score_date_score", columnList = "score_date, score") |
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.
읽기 전용 뷰 엔티티의 쓰기 경로 차단이 필요하다.
본 엔티티는 "배치가 집계한 mv_product_rank_monthly 테이블의 읽기 전용 매핑"으로 문서화되어 있지만, MonthlyRankingJpaRepository가 JpaRepository<ProductRankingMonthly, Long>를 상속하여 save, saveAll, delete, deleteAll 등 쓰기 메서드가 전부 노출된다. 운영 관점에서 다음 리스크가 있다:
- API 모듈에서 실수로
save(...)를 호출하면 배치가 관리하는 테이블 상태가 오염될 수 있다(동일(product_id, score_date)유니크 제약이 본 엔티티에는 빠져 있어 중복 insert 시 원인 파악도 어려워진다). - 도메인 문서(읽기 전용)와 실제 노출 API 간 계약이 불일치한다.
수정안으로는 다음 중 하나를 권장한다:
MonthlyRankingJpaRepository를JpaRepository대신Repository<ProductRankingMonthly, Long>(또는 커스텀 최소 인터페이스)로 변경해 쓰기 메서드를 노출하지 않는다.- 엔티티에
@Immutable(Hibernate) 또는@Entity(..., readOnly=true)에 준하는 제약을 걸고, 세터/팩토리 노출을 축소한다. - 배치 모듈의 엔티티와 동일하게 API 모듈 엔티티에도
(product_id, score_date)유니크 제약을 선언해 스키마 계약을 일치시킨다(현재 배치 모듈ProductRankingMonthly에는 유니크 제약이 있고, API 모듈에는 없다).
추가 테스트로, MonthlyRankingJpaRepository의 쓰기 경로가 허용되지 않음을 확인하는 아키텍처 테스트(ArchUnit 등) 또는 리포지토리 계약 테스트를 제안한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java`
around lines 22 - 49, ProductRankingMonthly is mapped as a read-only view but
MonthlyRankingJpaRepository currently extends JpaRepository, exposing write
APIs; update the code so write paths are blocked: change
MonthlyRankingJpaRepository to extend Spring’s Repository<ProductRankingMonthly,
Long> or a custom read-only repository interface (removing
save/saveAll/delete/deleteAll signatures), and/or annotate ProductRankingMonthly
with a Hibernate `@Immutable` (or equivalent readOnly mapping) and tighten
visibility of mutators/factory if needed; also add the (product_id, score_date)
unique constraint on ProductRankingMonthly to match the batch module and add an
architecture or repository contract test (ArchUnit or similar) asserting that
MonthlyRankingJpaRepository does not expose write methods.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,45 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.loopers.domain.ranking; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.LocalDate; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Column; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Entity; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.GeneratedValue; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.GenerationType; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Id; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Index; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.persistence.Table; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.AccessLevel; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.Getter; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.NoArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Entity | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Table(name = "mv_product_rank_weekly", indexes = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.AccessLevel; | |
| import lombok.Getter; | |
| import lombok.NoArgsConstructor; | |
| @Entity | |
| @Table(name = "mv_product_rank_weekly", indexes = { | |
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | |
| }) | |
| import jakarta.persistence.UniqueConstraint; | |
| import lombok.AccessLevel; | |
| import lombok.Getter; | |
| import lombok.NoArgsConstructor; | |
| @Entity | |
| @Table( | |
| name = "mv_product_rank_weekly", | |
| uniqueConstraints = { | |
| @UniqueConstraint(name = "uk_product_rank_weekly_product_date", columnNames = {"product_id", "score_date"}) | |
| }, | |
| indexes = { | |
| @Index(name = "idx_score_date_score", columnList = "score_date, score") | |
| } | |
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,39 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.function.Function; | ||
| import java.util.function.ToDoubleFunction; | ||
|
|
||
| /** | ||
| * 랭킹 Sorted Set의 단일 항목. | ||
| * 랭킹 단일 항목. | ||
| * | ||
| * @param rank 1-based 순위 | ||
| * @param productId 상품 ID | ||
| * @param score 가중치 기반 누적 점수 | ||
| */ | ||
| public record RankingItem(int rank, Long productId, double score) { | ||
|
|
||
| /** | ||
| * 엔티티 목록을 {@link RankingItem} 목록으로 변환한다. | ||
| * | ||
| * @param items 원본 목록 | ||
| * @param offset 시작 오프셋 (rank 계산용) | ||
| * @param toProductId 상품 ID 추출 함수 | ||
| * @param toScore 점수 추출 함수 | ||
| * @return 순위가 포함된 랭킹 항목 목록 | ||
| */ | ||
| public static <T> List<RankingItem> toRankingItems( | ||
| List<T> items, | ||
| int offset, | ||
| Function<T, Long> toProductId, | ||
| ToDoubleFunction<T> toScore | ||
| ) { | ||
| List<RankingItem> rankingItems = new ArrayList<>(items.size()); | ||
| for (int i = 0; i < items.size(); i++) { | ||
| T item = items.get(i); | ||
| rankingItems.add(new RankingItem(offset + i + 1, toProductId.apply(item), toScore.applyAsDouble(item))); | ||
| } | ||
| return rankingItems; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.loopers.domain.ranking; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 주간 랭킹 데이터를 조회하는 포트. | ||
| * | ||
| * <p>배치가 집계한 {@code mv_product_rank_weekly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.</p> | ||
| */ | ||
| public interface WeeklyRankingRepository { | ||
|
|
||
| /** | ||
| * 지정한 scoreDate의 주간 랭킹을 점수 내림차순으로 조회한다. | ||
| * | ||
| * @param scoreDate 조회 기준일 | ||
| * @param page 페이지 번호 (0-based) | ||
| * @param size 페이지 크기 | ||
| * @return 주간 랭킹 엔티티 목록 (점수 내림차순) | ||
| */ | ||
| List<ProductRankingWeekly> readTopRanked(LocalDate scoreDate, int page, int size); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.loopers.infrastructure.ranking.persistence; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import com.loopers.domain.ranking.ProductRankingMonthly; | ||
|
|
||
| public interface MonthlyRankingJpaRepository extends JpaRepository<ProductRankingMonthly, Long> { | ||
|
|
||
| List<ProductRankingMonthly> findByScoreDateOrderByScoreDesc(LocalDate scoreDate, Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package com.loopers.infrastructure.ranking.persistence; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Repository; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import com.loopers.domain.ranking.MonthlyRankingRepository; | ||
| import com.loopers.domain.ranking.ProductRankingMonthly; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| /** | ||
| * 월간 랭킹 조회 구현체. | ||
| * | ||
| * <p>배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 지정한 scoreDate의 랭킹을 조회한다.</p> | ||
| */ | ||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { | ||
|
|
||
| private final MonthlyRankingJpaRepository monthlyRankingJpaRepository; | ||
|
|
||
| @Override | ||
| @Transactional(readOnly = true) | ||
| public List<ProductRankingMonthly> readTopRanked(LocalDate scoreDate, int page, int size) { | ||
| return monthlyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size)); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.loopers.infrastructure.ranking.persistence; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.util.List; | ||
|
|
||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import com.loopers.domain.ranking.ProductRankingWeekly; | ||
|
|
||
| public interface WeeklyRankingJpaRepository extends JpaRepository<ProductRankingWeekly, Long> { | ||
|
|
||
| List<ProductRankingWeekly> findByScoreDateOrderByScoreDesc(LocalDate scoreDate, Pageable pageable); | ||
| } |
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.
잘못된
date입력 시 500으로 전파된다.LocalDate.parse(date, DATE_FORMAT)에서 던지는DateTimeParseException이 어디에서도 잡히지 않아,ApiControllerAdvice를 통해INTERNAL_ERROR(500)로 응답된다.ErrorType.INVALID_RANKING_DATE_FORMAT(400)이 이미 정의되어 있고daily/hourly측은RankingKeyResolver에서CoreException으로 변환해 400을 반환하는데, 신규weekly/monthly경로만 400이 아닌 500을 반환하게 되어 클라이언트 오용을 장애로 오인하게 된다. 동일한 문제는ReadWeeklyRankingsUseCase에도 존재할 것으로 보이므로 공통 유틸(예:RankingKeyResolver.parseDate(...))로 일원화하는 것을 권장한다.추가 테스트로
RankingV1ApiE2ETest에date=invalid로 요청 시 400과INVALID_RANKING_DATE_FORMAT코드가 반환되는 케이스를 추가해야 한다.♻️ 수정안 예시
public RankingPageResult execute(Long userId, String date, PageSize pageSize) { - LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); + LocalDate scoreDate; + try { + scoreDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT); + } List<ProductRankingMonthly> rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size());As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다." 또한 retrieved learnings("enforce unified error handling by routing errors through CoreException to ApiControllerAdvice")에 따라 도메인 오류를
CoreException으로 일관되게 반환해야 한다.📝 Committable suggestion
🤖 Prompt for AI Agents