Conversation
There was a problem hiding this comment.
Code Review
이번 PR은 맛집 상세 정보를 조회하는 API 기능을 추가하고, 관련 데이터 동기화 로직에 '지하철역(station)' 필드를 포함하는 변경사항을 담고 있습니다. 전반적으로 레이어드 아키텍처와 Record 패턴을 잘 활용하여 구현되었습니다. 다만, 데이터베이스 조회 성능 최적화를 위해 Region 정보 조회 시 발생하는 N+1 문제를 해결해야 하며, Admin 전용 쿼리 재사용에 따른 비효율성 개선이 필요합니다. 또한, 코드 중복 제거와 예외 상황(404 Not Found)에 대한 테스트 케이스 보강을 통해 코드의 완성도와 안정성을 높일 수 있습니다.
| entity.getName(), | ||
| entity.getStation(), | ||
| entity.getAddress(), | ||
| resolveRegion(entity.getRegionId()), |
There was a problem hiding this comment.
[🟠 P1] Performance: Region 정보 조회 시 추가 쿼리 발생 (N+1 위험)
문제점:
findRestaurantDetailById 메서드 내에서 resolveRegion(entity.getRegionId())을 호출하고 있습니다. resolveRegion은 내부적으로 regionJpaRepository.findByIdAndDeletedAtIsNull을 호출하여 별도의 SELECT 쿼리를 실행합니다.
위험성:
현재는 단일 맛집 조회이므로 1번의 추가 쿼리만 발생하지만, 이러한 패턴이 목록 조회 로직 등에 확산될 경우 심각한 N+1 성능 문제를 야기할 수 있습니다.
제안:
- QueryDSL 쿼리 단계에서
regionEntity를 조인하여 한 번의 쿼리로 모든 데이터를 가져오도록 개선하십시오.
예시:
// ❌ Bad
resolveRegion(entity.getRegionId())
// ✅ Good
// 쿼리에서 .leftJoin(regionEntity).on(restaurantEntity.regionId.eq(regionEntity.id)) 추가 후
// regionEntity.code를 통해 Region 객체 생성| Tuple tuple = createAdminRestaurantTupleQuery(RestaurantAdminListCriteria.of(null, null, null, null)) | ||
| .where(restaurantEntity.id.eq(restaurantId)) | ||
| .fetchOne(); |
There was a problem hiding this comment.
[🟡 P2] Architecture & Efficiency: Admin 전용 쿼리 재사용 및 불필요한 데이터 조회
문제점:
공용 API인 맛집 상세 조회를 구현하면서 Admin 전용으로 설계된 createAdminRestaurantTupleQuery를 재사용하고 있습니다. 이로 인해 결과 DTO에서 사용하지 않는 mediumCategory까지 조회하게 되며, Admin 전용 필터 로직에 결합되는 문제가 있습니다.
제안:
- 공용 상세 조회를 위한 전용 쿼리 메서드를 작성하거나,
Projections를 사용하여 필요한 필드만 선택적으로 조회하도록 개선하십시오.
예시:
// ❌ Bad
Tuple tuple = createAdminRestaurantTupleQuery(RestaurantAdminListCriteria.of(null, null, null, null))
.where(restaurantEntity.id.eq(restaurantId))
.fetchOne();
// ✅ Good
RestaurantDetailResult result = jpaQueryFactory
.select(Projections.constructor(RestaurantDetailResult.class, ...))
.from(restaurantEntity)
.where(restaurantEntity.id.eq(restaurantId))
.fetchOne();| entity.getRepresentativeReview(), | ||
| entity.getReviewCount(), | ||
| entity.getAiMateSummaryTitle(), | ||
| parseAiMateSummaryContents(entity.getAiMateSummaryContents()) |
There was a problem hiding this comment.
[🟡 P2] Maintainability: 중복된 파싱 로직 존재
문제점:
parseAiMateSummaryContents 메서드가 RestaurantCoreRepository와 RestaurantEntity 양쪽에 동일하게 중복 구현되어 있습니다.
제안:
RestaurantEntity에 이미 정의된 정적 메서드를 활용하거나, 공통 유틸리티 클래스로 추출하여 코드 중복을 제거하십시오.
예시:
// ❌ Bad
parseAiMateSummaryContents(entity.getAiMateSummaryContents())
// ✅ Good
RestaurantEntity.parseAiMateSummaryContents(entity.getAiMateSummaryContents())| void getRestaurantDetail_ShouldReturnOk() throws Exception { | ||
| when(restaurantFacade.getRestaurantDetailBy(1L)).thenReturn(RestaurantDetailResult.of( | ||
| 1L, | ||
| "맛집 이름", | ||
| "고속터미널역", | ||
| "서울 서초구 ...", | ||
| Region.GANGNAM, | ||
| LargeCategory.KOREAN, | ||
| 4.6, | ||
| "https://img.example.com/restaurant.jpg", | ||
| "https://place.map.kakao.com/123", | ||
| "맛집 설명", | ||
| "MEDIUM", | ||
| "된장찌개", | ||
| 10000, | ||
| "대표 리뷰", | ||
| 120, | ||
| "AI 요약 제목", | ||
| List.of("요약1", "요약2") | ||
| )); | ||
|
|
||
| mockMvc.perform(get("/api/v1/restaurants/1")) | ||
| .andExpect(status().isOk()) | ||
| .andExpect(jsonPath("$.data.restaurantId").value(1L)) | ||
| .andExpect(jsonPath("$.data.restaurantName").value("맛집 이름")) | ||
| .andExpect(jsonPath("$.data.station").value("고속터미널역")) | ||
| .andExpect(jsonPath("$.data.largeCategory").value("KOREAN")) | ||
| .andExpect(jsonPath("$.data.representMenuPrice").value(10000)); | ||
| } |
There was a problem hiding this comment.
[🟡 P2] Testing: 존재하지 않는 맛집 조회에 대한 테스트 케이스 누락
문제점:
맛집 상세 조회 API 테스트에서 성공 케이스(200 OK)만 다루고 있으며, 맛집이 존재하지 않을 경우(404 Not Found)에 대한 검증이 누락되었습니다.
제안:
restaurantFacade.getRestaurantDetailBy가CustomException(ErrorCode.RESTAURANT_NOT_FOUND)을 던지는 상황을 모킹하여 404 응답이 정상적으로 반환되는지 확인하는 테스트를 추가하십시오.
예시:
@Test
@DisplayName("존재하지 않는 맛집 조회 시 404 에러를 반환한다")
void getRestaurantDetail_NotFound() throws Exception {
when(restaurantFacade.getRestaurantDetailBy(999L))
.thenThrow(new CustomException(ErrorCode.RESTAURANT_NOT_FOUND));
mockMvc.perform(get("/api/v1/restaurants/999"))
.andExpect(status().isNotFound());
}There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8ab992312b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| String priceLevel, | ||
| String aiMateSummaryTitle, | ||
| List<String> aiMateSummaryContents, | ||
| String station, |
There was a problem hiding this comment.
Update constructors after adding station field
Adding station to KakaoPlaceDetailData changes the canonical constructor arity, but existing call sites (notably the detailData* fixtures in RestaurantSyncServiceTest) still pass the old argument list. This causes test compilation to fail when running ./gradlew test (or any task that compiles test sources), which blocks CI for this change.
Useful? React with 👍 / 👎.
🌱 관련 이슈
📌 작업 내용
station_simple_name파싱 로직 추가📝 참고
💬 리뷰 요구사항(선택)