Skip to content

Conversation

@7ijin01
Copy link
Member

@7ijin01 7ijin01 commented Aug 15, 2025

📌 PR 개요

  • 사용자 별 코스 기록 조회
  • 선호 관광지 추가, 삭제, 조회
  • 사용자 정보 조회

✅ 변경사항

  • CourseHistoryMapper 구현
  • TourComponent 생성(조회 속도 빠르게 FK만 저장)
  • leftJoin 사용(N+1 해결)
  • swagger 토큰 연결
  • course 엔티티에 사용자 연결

🔍 체크리스트

  • PR 제목은 명확한가요?
  • 관련 이슈가 있다면 연결했나요?
  • 로컬 테스트는 통과했나요?
  • 코드에 불필요한 부분은 없나요?

📎 관련 이슈

Closes #50


💬 기타 참고사항

Summary by CodeRabbit

  • 새로운 기능

    • 마이페이지 API 추가: 코스 이력 조회, 관심 관광지 추가/목록/삭제, 사용자 정보 조회
    • 코스 추천에 종료시간(endTime)·관광지(tourspot) 필터 지원
    • 주요 코스 엔드포인트에 로그인 연동(좋아요 등)
    • 관심 관광지 중복/개수 초과 시 명확한 오류 안내
  • 버그 수정

    • JWT Authorization 헤더의 “Bearer ” 접두어 자동 처리
    • 관광지 상세 조회 시 혼잡도 데이터가 없어도 안정적으로 표시
  • 문서

    • JWT 보안 스키마 적용으로 전체 API 보안 설정 강화
    • 마이페이지·코스 엔드포인트 문서화 개선

@7ijin01 7ijin01 linked an issue Aug 15, 2025 that may be closed by this pull request
1 task
@coderabbitai
Copy link

coderabbitai bot commented Aug 15, 2025

Walkthrough

마이페이지 기능 추가 및 인증 연동. 마이페이지 컨트롤러/서비스/DTO/리포지토리 구현, 코스 기록/선호 관광지/사용자 조회 제공. 코스 좋아요·추천에 인증 사용자 주입. 사용자/투어스팟 관련 커스텀 리포지토리 패키지 재구성, QueryDSL 구현 추가. JWT 필터 Bearer 처리, Swagger 보안 스키마, 에러 코드 및 엔티티/DTO 추가.

Changes

Cohort / File(s) Summary
Docs 업데이트
src/main/java/com/opendata/docs/CourseControllerDocs.java, .../MyPageControllerDocs.java
컨트롤러 문서에 @AuthenticationPrincipal 파라미터 및 마이페이지 엔드포인트 명세 추가
코스 컨트롤러/서비스 인증 연동
.../course/controller/CourseController.java, .../course/service/CourseService.java
findCourses 파라미터 확장(endTime, tourspot) 및 인증 사용자 전달, likeCourse가 CustomUserDetails 기반 사용자 조회로 변경
마이페이지 기능 추가
.../mypage/controller/MypageController.java, .../mypage/service/MypageService.java
코스 기록 조회, 선호 관광지 추가/조회/삭제, 사용자 정보 조회 엔드포인트 및 서비스 로직 추가
코스 히스토리 DTO/매퍼
.../course/dto/response/CourseHistoryResponse.java, .../CourseComponentHistoryDto.java, .../course/mapper/CourseHistoryMapper.java
코스 기록 응답 및 구성요소 DTO 추가, MapStruct 매핑 정의
코스 컴포넌트 조회 확장
.../course/repository/CourseComponentRepository.java, .../repository/custom/CustomCourseComponentRepository*.java
코스별 컴포넌트 조회 커스텀 리포지토리 계약/구현 추가
사용자 도메인 확장
.../user/entity/User.java, .../user/dto/UserResponse.java, .../user/repository/UserRepository.java, .../user/repository/custom/*
회원권 필드 enum(MemberShip)로 변경, UserResponse DTO 추가, 커스텀 리포지토리로 이메일/ID 조회 및 삭제 구현
OAuth2 서비스 보정
.../oauth2/service/CustomOAuth2UserService.java
신규 유저 생성 시 membership 하드코드 → FREE 상수 사용
투어스팟 선호 엔티티/리포지토리
.../tourspot/entity/TourSpotComponent.java, .../tourspot/repository/TourSpotComponentRepository.java, .../repository/custom/component/*
사용자-관광지 매핑 엔티티 및 CRUD/검증/삭제 커스텀 리포지토리 구현
투어스팟 리포지토리 패키지 재구성
.../tourspot/repository/*Repository.java, .../tourspot/repository/custom/**/*
current/future/monthly/event/tag/related/tourSpot 커스텀 리포지토리 패키지 이동 및 임플리 구현 파일 이동
투어스팟 서비스 보정
.../tourspot/service/TourSpotService.java
혼잡도 null-safe 처리 및 불필요 로그 제거
전역 보안/문서화 설정
.../global/config/SwaggerConfig.java, .../global/jwt/JwtFilter.java, .../global/security/CustomUserDetails.java
Swagger에 Bearer JWT 보안 스키마/요구사항 추가, Authorization 헤더 Bearer 프리픽스 제거 처리, CustomUserDetails에 getUserEmail 추가
에러 상태 추가
.../global/response/status/ErrorStatus.java
TOURSPOT_ALREADY_EXISTS, TOURSPOT_EXCEEDS 에러 코드 추가

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant API as MypageController
  participant SVC as MypageService
  participant Repo as Repositories
  participant Ext as TourSpotService

  User->>API: GET /mypage/courses (JWT)
  API->>SVC: getCourses(user)
  SVC->>Repo: findAllByUserId(userId)
  Repo-->>SVC: Courses(+components)
  SVC-->>API: List<CourseHistoryResponse>
  API-->>User: 200 ApiResponse

  User->>API: POST /mypage/preferences?tourSpotId
  API->>SVC: saveUserTourSpot(user, tourSpotId)
  SVC->>Repo: exists/count, save
  SVC-->>API: void
  API-->>User: 200 ApiResponse
Loading
sequenceDiagram
  actor User
  participant CAPI as CourseController
  participant CSVC as CourseService
  participant URepo as UserRepository
  participant Redis as Redis
  participant DB as JPA

  User->>CAPI: POST /courses/{id}/like (JWT)
  CAPI->>CSVC: likeCourse(courseId, user)
  CSVC->>URepo: findUserByEmail(email)
  URepo-->>CSVC: User
  CSVC->>Redis: get course draft
  CSVC->>DB: persist like/course
  CSVC-->>CAPI: void
  CAPI-->>User: 200 ApiResponse
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Assessment against linked issues

Objective Addressed Explanation
사용자 코스 기록 조회 (#50)
선호 관광지 조회/관리 (#50)
사용자 계정 조회 (#50)
계정 탈퇴 추가 (#50) 엔드포인트/서비스 부재; 리포지토리 delete 메서드만 존재

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
코스 추천 파라미터 확장 및 인증 연동 (.../course/controller/CourseController.java) 마이페이지 범위 외 변경.
JWT Authorization 헤더 Bearer 프리픽스 제거 처리 (.../global/jwt/JwtFilter.java) 링크된 이슈와 직접적 연관 없음.
Swagger 보안 스키마 전역 적용 (.../global/config/SwaggerConfig.java) 마이페이지 구현과 무관한 전역 설정 변경.
투어스팟 커스텀 리포지토리 패키지 재구성 다수 (.../tourspot/repository/custom/**/*) 기능 구현과 별개의 리팩터링.
TOURSPOT 에러 코드 추가 (.../global/response/status/ErrorStatus.java) 마이페이지 필수 요구사항에 명시되지 않음.

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • kamillcream

Poem

둥근 달 아래 깡충깡충, 코드밭을 달려가요 🐇
마이페이지 새싹 돋고, 기록 꽃이 피어나요 🌱
선호지 톡톡, 별처럼 줄지어 반짝—✨
JWT 바람 타고, 토큰 껍질 벗겨 보고
오늘도 깔끔한 리포지토리 길 정리 끝!

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#50-mypage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🔭 Outside diff range comments (2)
src/main/java/com/opendata/global/jwt/JwtFilter.java (1)

37-44: 만료 토큰이 그대로 통과되는 로직 버그: isExpired 반환값을 체크하지 않아 인증이 설정됩니다

현재 jwtUtil.isExpired(accessToken)의 반환값을 무시하고 있으며, ExpiredJwtException만 캐치하고 있습니다. 제공된 JwtUtil 구현은 만료 시 예외를 던지지 않고 boolean을 반환합니다. 결과적으로 만료 토큰도 SecurityContext에 인증이 설정되는 심각한 버그가 발생합니다.

다음과 같이 만료 여부를 명시적으로 확인하고, 파싱/검증 예외에 대해서도 401을 반환하도록 처리해 주세요.

-        try {
-            jwtUtil.isExpired(accessToken);
-        } catch (ExpiredJwtException e) {
-            PrintWriter writer = response.getWriter();
-            writer.print("access token expired");
-            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
-            return;
-        }
+        try {
+            if (jwtUtil.isExpired(accessToken)) {
+                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+                response.getWriter().print("access token expired");
+                return;
+            }
+        } catch (Exception e) {
+            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+            response.getWriter().print("invalid access token");
+            return;
+        }
src/main/java/com/opendata/domain/course/service/CourseService.java (1)

218-221: Redis 키 접두사 중복 버그 — likeCourse에서 courseId가 이미 "tempCourse:"인지 검사하세요

검증 결과: recommendCourses에서 tempCourseId = "tempCourse:" + UUID.randomUUID()로 생성해 CourseResponse에 그대로 포함하고(및 Redis에 저장) 있으므로, 클라이언트는 이미 "tempCourse:..." 형태의 ID를 전달합니다. 그런데 likeCourse는 redisTemplate.opsForValue().get("tempCourse:" + courseId)로 무조건 접두사를 붙여 조회해, "tempCourse:tempCourse:..."가 되어 조회에 실패할 수 있음이 확인되었습니다.

주의가 필요한 위치:

  • src/main/java/com/opendata/domain/course/service/CourseService.java
    • lines ~94-100: recommendCourses — String tempCourseId = "tempCourse:" + UUID.randomUUID(); 및 redisTemplate.opsForValue().set(tempCourseId, ...)
    • line 218: likeCourse — List rawList = (List) redisTemplate.opsForValue().get("tempCourse:" + courseId);

권장 수정(예시):

-        List<?> rawList = (List<?>) redisTemplate.opsForValue().get("tempCourse:" + courseId);
+        String redisKey = (courseId != null && courseId.startsWith("tempCourse:")) ? courseId : "tempCourse:" + courseId;
+        List<?> rawList = (List<?>) redisTemplate.opsForValue().get(redisKey);

추가 권장: CourseResponse의 courseId가 접두사 포함 형태임을 API 문서(Swagger)에 명시하거나, 클라이언트에 UUID만 전달하도록 응답 형태를 변경해 혼동을 줄이세요.

🧹 Nitpick comments (61)
src/main/java/com/opendata/domain/tourspot/repository/custom/related/CustomTourSpotRelatedRepository.java (1)

3-5: 빈 커스텀 리포지토리 인터페이스 — 목적 명시 또는 제거 고려

현재 메서드가 전무하여 조각(fragment) 자체가 불필요해 보입니다. 유지가 목적(향후 확장)이라면 최소한의 Javadoc으로 의도를 남기거나 TODO를 추가해 주세요. 불필요하다면 인터페이스/구현체를 제거해도 됩니다.

적용 예시(간단 Javadoc 추가):

+/**
+ * TourSpot 간 연관관계 전용 커스텀 리포지토리 조각.
+ * 향후 연관 조회(LEFT JOIN 최적화), 벌크 업데이트 등의 커스텀 메서드를 선언합니다.
+ */
 public interface CustomTourSpotRelatedRepository {
 
 }
src/main/java/com/opendata/domain/tourspot/repository/custom/related/CustomTourSpotRelatedRepositoryImpl.java (2)

6-8: 불필요한 애너테이션 정리 제안(@repository, @requiredargsconstructor)

Spring Data fragment 구현체는 관례상 애너테이션 없이도 조합되며, 현재 필드 의존성도 없어 RequiredArgsConstructor가 무의미합니다. 제거를 고려해 주세요.

간소화 예시:

package com.opendata.domain.tourspot.repository.custom.related;

public class CustomTourSpotRelatedRepositoryImpl implements CustomTourSpotRelatedRepository {
}

8-10: 구현 메서드가 없는 빈 구현체 — 필요 없으면 파일 제거 고려

인터페이스에 선언된 메서드가 없다면 구현체도 불필요합니다. 유지 비용/스캔 비용을 줄이려면 실제 커스텀 메서드가 추가될 때 도입하는 방식을 권장합니다.

src/main/java/com/opendata/domain/tourspot/repository/TourSpotRelatedRepository.java (1)

8-9: @repository 애너테이션은 선택 사항(간소화 가능)

Spring Data JPA는 인터페이스 기반 리포지토리를 자동 감지/등록합니다. 일관된 컨벤션을 위해 프로젝트 전반에서 @repository를 붙이지 않는 방향이라면 여기서도 제거 가능입니다.

간소화 예시:

package com.opendata.domain.tourspot.repository;

import com.opendata.domain.tourspot.entity.TourSpotRelated;
import com.opendata.domain.tourspot.repository.custom.related.CustomTourSpotRelatedRepository;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TourSpotRelatedRepository
        extends JpaRepository<TourSpotRelated, Long>, CustomTourSpotRelatedRepository {
}
src/main/java/com/opendata/domain/tourspot/repository/custom/event/CustomTourSpotEventRepository.java (1)

8-11: findAllByTourSpot 추가는 구현과 일치합니다. null 인자 방어를 고려해주세요.

현재 구현체에서 null이 들어오면 is null 조건으로 동작할 수 있습니다. 명시적으로 null을 금지(사전 검증 또는 주석/어노테이션)하거나, null이면 빈 리스트를 반환하도록 정책을 정해두는 것이 안전합니다.

src/main/java/com/opendata/domain/tourspot/repository/TourSpotEventRepository.java (1)

10-12: exists 메서드는 Spring Data 파생 쿼리로 대체하는 것을 고려해 주세요.

커스텀 레포지토리를 경유하지 않고도 간단히 표현 가능합니다. 유지보수성과 테스트 용이성이 개선됩니다.

아래처럼 메서드 시그니처를 본 레포지토리에 직접 추가하면 커스텀 인터페이스/구현의 동일 메서드를 제거할 수 있습니다.

 @Repository
 public interface TourSpotEventRepository extends JpaRepository<TourSpotEvent, Long>, CustomTourSpotEventRepository {
 
+    boolean existsByEventNameAndEventPeriod(String eventName, String eventPeriod);
 }
src/main/java/com/opendata/domain/tourspot/repository/custom/event/CustomTourSpotEventRepositoryImpl.java (3)

22-31: exists 쿼리를 간결하게 정리하고 입력값 방어를 고려해 주세요.

로컬 변수 없이 바로 반환하면 가독성이 좋아집니다. 또한 eventName, eventPeriod가 null일 경우를 명확히 금지하거나 처리 정책을 정해두면 예기치 않은 "is null" 비교를 피할 수 있습니다. 반복 호출이 잦다면 DB 인덱스(event_name, event_period) 추가도 검토 가치가 있습니다.

아래처럼 간소화할 수 있습니다:

-        Integer fetchOne = queryFactory
-                .selectOne()
-                .from(qTourSpotEvent)
-                .where(
-                        qTourSpotEvent.eventName.eq(eventName),
-                        qTourSpotEvent.eventPeriod.eq(eventPeriod)
-                )
-                .fetchFirst();
-        return fetchOne != null;
+        return queryFactory
+                .selectOne()
+                .from(qTourSpotEvent)
+                .where(
+                        qTourSpotEvent.eventName.eq(eventName),
+                        qTourSpotEvent.eventPeriod.eq(eventPeriod)
+                )
+                .fetchFirst() != null;

18-19: 읽기 전용 트랜잭션 적용을 고려해 주세요.

조회 전용 메서드는 @Transactional(readOnly = true)를 부여하면 JPA 플러시/더티체크 비용을 줄일 수 있습니다. 서비스 계층에서 트랜잭션을 관리한다면 생략 가능하지만, 리포지토리 단에서도 명확히 해두면 의도가 드러납니다.

아래와 같이 어노테이션을 추가할 수 있습니다(메서드/클래스 단위 택1):

+    @Transactional(readOnly = true)
     @Override
     public boolean existsByEventNameAndEventPeriod(String eventName, String eventPeriod) {
+    @Transactional(readOnly = true)
     @Override
     public List<TourSpotEvent> findAllByTourSpot(TourSpot tourSpot) {

추가 import(파일 상단에 필요):

import org.springframework.transaction.annotation.Transactional;

33-39: 엔티티 비교(eq(entity)) 대신 ID 비교 또는 null 인자 방어를 고려해 주세요.

eq(tourSpot)은 정상 동작하지만, 호출부에서 영속성 컨텍스트 밖의 프록시/분리된 엔티티가 전달될 가능성이 있다면 ID 비교가 더 안전하고 의도가 분명합니다. 최소한 tourSpot이 null일 경우를 방어(예: IllegalArgumentException)하거나 빈 리스트 반환 정책을 문서화해 주세요.

src/main/java/com/opendata/domain/tourspot/repository/custom/tag/CustomTourSpotTagRepositoryImpl.java (2)

12-15: Custom 구현 클래스에 @repository는 불필요 — 중복 빈/모호한 주입 가능성

프래그먼트 구현체는 Spring Data가 조합하여 사용하므로 일반적으로 @Repository를 붙이지 않습니다. 현재 애노테이션이 있으면 동일 인터페이스 타입 빈이 2개가 되어(프래그먼트 포함된 리포지토리, 그리고 구현체 자체) CustomTourSpotTagRepository 타입 주입 시 모호성이 생길 수 있습니다. 제거를 권장합니다.

적용 예:

-import org.springframework.stereotype.Repository;
@@
-@Repository
 @RequiredArgsConstructor
 public class CustomTourSpotTagRepositoryImpl implements CustomTourSpotTagRepository {

17-22: 입력 파라미터 null 가드(방어적 코드) 소폭 추가 제안

서비스 레이어에서 이미 보장한다면 생략 가능하나, 리포지토리 단에서도 명시적으로 실패시키면 디버깅이 수월합니다.

 public List<TourSpotTag> findAllByTourSpot(TourSpot tourSpot) {
+    if (tourSpot == null) {
+        throw new IllegalArgumentException("tourSpot must not be null");
+    }
     QTourSpotTag qTourSpotTag = QTourSpotTag.tourSpotTag;
     return queryFactory.selectFrom(qTourSpotTag)
             .where(qTourSpotTag.tourspot.eq(tourSpot))
             .fetch();
 }
src/main/java/com/opendata/domain/tourspot/repository/custom/tag/CustomTourSpotTagRepository.java (1)

8-10: FK 기반 조회 오버로드 메서드 추가 제안(컴포넌트는 FK만 저장 목표와 정합성)

PR 요약에 맞춰 FK만 들고 다니는 경로를 지원하려면 ID 기반 오버로드가 유용합니다. 기존 메서드는 유지하고, 아래 메서드를 추가하는 방식을 권장합니다.

 public interface CustomTourSpotTagRepository {
     List<TourSpotTag> findAllByTourSpot(TourSpot tourSpot);
+    /**
+     * FK 기반 조회를 위한 경량 오버로드.
+     */
+    List<TourSpotTag> findAllByTourSpotId(Long tourspotId);
 }

원하시면 구현부(QueryDSL)까지 포함해 추가 커밋 패치를 드리겠습니다.

src/main/java/com/opendata/domain/tourspot/repository/custom/tourSpot/CustomTourSpotRepository.java (2)

1-1: 패키지명에 대문자(S) 사용 — Java 컨벤션과 파일시스템 이슈 가능

패키지 경로에 대문자 tourSpot가 포함되어 있습니다. Java 패키지는 소문자 사용이 관례이며, 대소문자 구분 파일시스템(예: Linux)에서 경로 혼선을 유발할 수 있습니다. 일관성을 위해 소문자 tourspot로 변경을 권장합니다.

적용 예(해당 파일 기준):

-package com.opendata.domain.tourspot.repository.custom.tourSpot;
+package com.opendata.domain.tourspot.repository.custom.tourspot;

참고: 같은 변경이 구현체와 import를 사용하는 모든 파일에도 필요합니다.


8-11: findByAddress의 반환 타입/도메인 제약 확인 필요

현재 시그니처는 Optional 반환인데, 동일 Address에 여러 TourSpot이 존재할 수 있다면 모호합니다. 비즈니스 상 Address별 단일 TourSpot이 보장되지 않는다면 아래 중 하나를 권장합니다.

옵션:

  • Address별 복수 가능: 메서드를 컬렉션 반환으로 변경
  • Optional findByAddress(Address address);
  • List findAllByAddress(Address address);
  • 단일만 기대하되 “아무거나 1개” 의도라면 메서드명 명확화 + 구현부 정렬 보장
  • Optional findByAddress(Address address);
  • Optional findFirstByAddress(Address address);
    (구현부에는 orderBy 추가 권장 — 구현체 코멘트 참고)
    
    

또한 findByName 역시 유일성이 보장되지 않으면 동일한 논의가 필요합니다.

src/main/java/com/opendata/domain/tourspot/repository/custom/tourSpot/CustomTourSpotRepositoryImpl.java (6)

1-1: 패키지명에 대문자(S) 사용 — 소문자 일원화 권장

상위 인터페이스와 동일하게 tourSpottourspot로 정리하는 것이 안전합니다.

적용 예:

-package com.opendata.domain.tourspot.repository.custom.tourSpot;
+package com.opendata.domain.tourspot.repository.custom.tourspot;

18-27: findByName: fetchFirst 사용 시 결과 비결정성 — 정렬 추가 또는 유일성 보장 확인

동일 이름이 다수일 수 있다면 fetchFirst는 정렬 없이 DB 임의 순서를 반환할 수 있어 비결정적입니다. 의도에 따라 아래 중 하나를 권장합니다.

  • 유일성 보장(스키마/업무 규칙)이라면 fetchOne 사용 + DB 유니크 인덱스 검토
  • 유일성 미보장이라면 결정적 정렬 추가

정렬 추가 예:

         TourSpot result = queryFactory.selectFrom(qTourSpot)
                 .where(qTourSpot.tourspotNm.eq(name))
+                .orderBy(qTourSpot.tourspotId.asc())
                 .fetchFirst();

29-37: findByAddress: 복수 가능성 고려 시 정렬 또는 반환 타입 조정

Address로 여러 TourSpot이 조회될 수 있으면 fetchFirst의 반환이 임의일 수 있습니다. 인터페이스 논의(해당 파일 코멘트)와 함께 정렬 추가 또는 List 반환으로 일관성을 확보해 주세요.

정렬 추가 예:

         TourSpot tourSpot = queryFactory.selectFrom(qTourSpot)
                 .where(qTourSpot.address.eq(address))
+                .orderBy(qTourSpot.tourspotId.asc())
                 .fetchFirst();

16-16: 경미한 중복 제거 제안(Q 인스턴스 재사용)

메서드마다 QTourSpot qTourSpot = QTourSpot.tourSpot;를 생성하기보다 클래스 필드로 재사용하면 가독성이 좋아집니다. 선택사항입니다.

예:

private static final QTourSpot Q = QTourSpot.tourSpot;

// 사용처
queryFactory.selectFrom(Q).where(Q.tourspotNm.eq(name))...

33-35: 인덱스/성능 관점 제안: address_id, tourspot_nm 인덱스 확인

where 절이 address, name 동등 비교라면 DB 인덱스 유무가 크게 영향을 줍니다. 운영 데이터 규모를 고려해 인덱스 존재 여부를 확인해 주세요.

  • 추천 인덱스: tourspot(address_id), tourspot(tourspot_nm)
  • 대안: 조회 빈도/카디널리티에 따라 복합 인덱스 검토

18-37: 리포지토리 테스트 보강 제안

새 메서드(findByAddress)와 정렬/유일성 의도에 대한 단위/슬라이스 테스트를 추가하면 회귀 방지에 도움이 됩니다. 필요 시 테스트 템플릿 제공 가능합니다.

src/main/java/com/opendata/domain/tourspot/repository/custom/futureCongestion/CustomFutureCongestionRepository.java (1)

1-1: 패키지 명명 규칙: camelCase 대신 소문자 사용 권장

Java 패키지는 관례적으로 모두 소문자를 사용합니다. futureCongestion는 동작상 문제는 없지만, 케이스 민감하지 않은 파일시스템에서 혼동을 줄이고 일관성을 위해 소문자로 맞추는 것을 권장합니다.

아래와 같이 변경을 제안합니다:

-package com.opendata.domain.tourspot.repository.custom.futureCongestion;
+package com.opendata.domain.tourspot.repository.custom.futurecongestion;

다른 파일에 필요한 연쇄 변경(참고):

  • FutureCongestionRepository.java의 import 경로
-import com.opendata.domain.tourspot.repository.custom.futureCongestion.CustomFutureCongestionRepository;
+import com.opendata.domain.tourspot.repository.custom.futurecongestion.CustomFutureCongestionRepository;
  • CustomFutureCongestionRepositoryImpl.java의 package 경로
-package com.opendata.domain.tourspot.repository.custom.futureCongestion;
+package com.opendata.domain.tourspot.repository.custom.futurecongestion;
src/main/java/com/opendata/domain/tourspot/repository/custom/futureCongestion/CustomFutureCongestionRepositoryImpl.java (3)

1-1: 패키지 명명 규칙: camelCase 대신 소문자 사용 권장

패키지 세그먼트는 소문자 사용이 관례입니다. 가능하면 futurecongestion로 통일을 권장합니다.

-package com.opendata.domain.tourspot.repository.custom.futureCongestion;
+package com.opendata.domain.tourspot.repository.custom.futurecongestion;

12-14: Custom Repository 구현체에는 @repository 제거 권장 (중복 빈 생성 방지)

Spring Data가 리포지토리 프래그먼트를 합성해 프록시 빈을 생성하므로 구현체(@repository)까지 빈으로 등록하면 동일 타입 빈 2개가 생겨 주입 충돌 위험이 있습니다. 구현체의 @repository는 제거하는 것을 권장합니다.

-@Repository
 @RequiredArgsConstructor
 public class CustomFutureCongestionRepositoryImpl implements CustomFutureCongestionRepository {

참고: @repository 제거 후 불필요한 import(org.springframework.stereotype.Repository)도 정리 가능합니다.


21-26: QueryDSL 벌크 업데이트 후 1차 캐시 불일치 가능성 — flush/clear 처리 권장

JPA 벌크 업데이트는 영속성 컨텍스트를 우회합니다. 동일 트랜잭션 내 후속 조회에서 스테일 데이터가 나올 수 있으니, 필요 시 flush/clear를 고려해 주세요.

변경 예시(해당 범위 내 수정):

-        return queryFactory.update(qTourSpotFutureCongestion)
+        long affected = queryFactory.update(qTourSpotFutureCongestion)
                 .set(qTourSpotFutureCongestion.congestionLvl, newLevel)
                 .where(qTourSpotFutureCongestion.tourspot.tourspotId.eq(tourspotId),
                         qTourSpotFutureCongestion.fcstTime.eq(fcstTime))
-                .execute();
+                .execute();
+        // 영속성 컨텍스트 동기화 (EntityManager 주입 필요)
+        entityManager.flush();
+        entityManager.clear();
+        return affected;

선행 추가(파일 상단 import 및 필드 주입 필요):

// import 추가
import jakarta.persistence.EntityManager;

// 필드 추가 (생성자 주입)
private final EntityManager entityManager;

혹은 서비스 계층에서 해당 메서드 호출 후 명시적으로 clear를 수행하는 운영 방안도 가능합니다.

src/main/java/com/opendata/domain/tourspot/repository/custom/montlyCongestion/CustomMonthlyCongestionRepositoryImpl.java (1)

16-19: 구현 미완료 메서드가 상수 1L을 반환 — 오동작 가능성

updateCongestionLevel(...)이 현재 1L을 반환하는 스텁 상태입니다. 호출 시 정상 동작으로 오인될 수 있어 위험합니다. 최소한 명시적으로 미구현임을 드러내거나, 실제 로직을 구현해주세요.

안전장치(선택) diff:

-    public long updateCongestionLevel(List<TourSpotMonthlyCongestion> monthlyCongestions) {
-
-        return 1L;
-    }
+    public long updateCongestionLevel(List<TourSpotMonthlyCongestion> monthlyCongestions) {
+        // TODO: Querydsl bulk update 로직 구현
+        throw new UnsupportedOperationException("updateCongestionLevel is not implemented yet");
+    }
  • 실제 구현 시 대량 업데이트는 Querydsl bulk 연산 또는 batch 처리로 구현하고, 반환값은 영향받은 row 수로 맞추는 것을 권장합니다.
  • 사용 예정이 없다면 인터페이스/구현에서 일시 제거하거나, 명확한 TODO 주석을 남겨주세요.
src/main/java/com/opendata/global/jwt/JwtFilter.java (1)

50-51: 이미 설정된 인증을 덮어쓰지 않도록 가드 추가 제안

SecurityContext에 기존 인증이 있는 경우 새 인증으로 덮어쓰지 않도록 하는 것이 일반적인 패턴입니다.

-        Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
-        SecurityContextHolder.getContext().setAuthentication(authToken);
+        if (SecurityContextHolder.getContext().getAuthentication() == null) {
+            Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
+            SecurityContextHolder.getContext().setAuthentication(authToken);
+        }
src/main/java/com/opendata/global/config/SwaggerConfig.java (1)

25-37: 스키마 키 이름을 'BearerAuth'로 변경 권장 (가독성/관례 일치)

스키마 키 이름을 HTTP 헤더명(Authorization)과 동일하게 두면 혼동될 수 있습니다. 관례적으로 'BearerAuth'를 사용합니다. 전역 SecurityRequirement 및 Components 등록 키도 함께 변경해 주세요.

-        SecurityRequirement securityRequirement = new SecurityRequirement()
-                .addList("Authorization");
+        SecurityRequirement securityRequirement = new SecurityRequirement()
+                .addList("BearerAuth");

-        Components components = new Components()
-                .addSecuritySchemes("Authorization", apiKey);
+        Components components = new Components()
+                .addSecuritySchemes("BearerAuth", apiKey);
src/main/java/com/opendata/global/response/status/ErrorStatus.java (1)

29-32: 관광지 관련 에러 코드 추가 — HTTP 상태 코드 정합성 재검토 제안

상태 자체는 적절하나, 다음과 같은 HTTP 상태 코드를 고려해볼 수 있습니다.

  • 존재하지 않음: NOT_FOUND(404)
  • 이미 존재: CONFLICT(409)
  • 용량 초과(비즈니스 제약 위반): CONFLICT(409) 또는 BAD_REQUEST(400)

API 소비자 입장에서 상태 코드의 의미가 명확해집니다.

src/main/java/com/opendata/domain/tourspot/entity/TourSpotComponent.java (1)

10-11: JPA 엔티티에 @DaTa 사용은 지양 권장 (equals/hashCode/Setter 과다 생성 이슈)

@DaTa는 equals/hashCode/toString/Setter를 모두 생성해 지연 로딩/식별자 기반 비교에서 문제를 유발할 수 있습니다. 엔티티에는 보통 @Getter만 두고, 필요한 경우에 한해 메서드를 추가합니다.

-@Data
+@Getter

필요 시 개별 필드에 한정하여 Setter를 추가하거나, 생성/팩토리 메서드로만 상태를 변경하도록 유지해 주세요.

Also applies to: 17-20

src/main/java/com/opendata/global/security/CustomUserDetails.java (1)

50-53: getUserEmail()는 getEmail()과 중복 — 하나로 통일 권장

이미 getEmail()이 동일 기능을 제공합니다. 현재 코드 전반에서 getEmail()getUserEmail()이 혼용되고 있어 API 일관성이 떨어집니다. 우선 하위 호환을 위해 메서드에 @deprecated를 달고, 점진적으로 호출부를 getEmail()로 교체하는 것을 제안합니다.

다음 변경을 권장합니다(호출부 정리는 별도 PR로 진행 권장):

-    public String getUserEmail() {
-        return user.getEmail();
-    }
+    /**
+     * @deprecated getEmail()과 동일합니다. 새 코드는 getEmail()을 사용하세요.
+     */
+    @Deprecated
+    public String getUserEmail() {
+        return getEmail();
+    }

호출부 점검 포인트:

  • UserService.findUserByEmailOrThrow는 이미 getEmail()을 사용
  • Course/마이페이지 서비스 등에서 getUserEmail() 사용 중이면 getEmail()로 교체 검토
src/main/java/com/opendata/domain/course/dto/response/CourseHistoryResponse.java (1)

6-12: ID 타입을 도메인과 맞추고 날짜 포맷 일관성 확보 제안

  • Course.idLong이라면 DTO의 courseIdLong으로 맞추는 편이 변환 비용/오류를 줄입니다.
  • 다른 DTO들(CourseComponentResponse)과 동일하게 LocalDateTime@JsonFormat을 부여해 응답 포맷을 고정하는 것을 권장합니다.
 package com.opendata.domain.course.dto.response;

+import com.fasterxml.jackson.annotation.JsonFormat;
 import java.time.LocalDateTime;
 import java.util.List;

 public record CourseHistoryResponse(
-        String courseId,
-        LocalDateTime startDtm,
-        LocalDateTime endDtm,
+        Long courseId,
+        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+        LocalDateTime startDtm,
+        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+        LocalDateTime endDtm,
         List<CourseComponentHistoryDto> history
 ) {
 }

주의:

  • Mapper에서 course.getId()Long 매핑으로 맞추어야 합니다.
  • 기존 API 스펙이 String을 강제한다면 이 변경은 API 호환성 영향이 있으므로 문서/프론트 확인이 필요합니다.
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseComponentRepository.java (1)

7-10: 정렬 기준의 명시성 보강(주석 또는 시그니처에 반영) 권장

구현체에서 tourspotTm ASC로 정렬한다면, 인터페이스에도 의도를 남겨두면 협업/유지보수에 유리합니다(메서드명으로 드러내거나 Javadoc으로 명시).

 public interface CustomCourseComponentRepository
 {
-    List<CourseComponent> findAllByCourseId(Long courseId);
+    /**
+     * 해당 코스의 컴포넌트를 조회합니다.
+     * 정렬: tourspotTm ASC
+     */
+    List<CourseComponent> findAllByCourseId(Long courseId);
 }
src/main/java/com/opendata/domain/user/repository/UserRepository.java (1)

4-7: 의도 재확인 요청: Specification 사용 계획 여부

JpaSpecificationExecutor는 import만 있고 확장은 하지 않습니다.

  • 스펙 쿼리를 사용할 계획이 없다면 import를 정리하고,
  • 사용할 계획이라면 extends JpaSpecificationExecutor<User> 추가가 필요합니다.

스펙 사용 의도가 있는지 확인 후 다음 중 하나를 적용해 주세요.

옵션 A) 확장 추가:

-public interface UserRepository extends JpaRepository<User, Long>, CustomUserRepository {
+public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User>, CustomUserRepository {

옵션 B) import 정리:

-import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
src/main/java/com/opendata/domain/tourspot/service/TourSpotService.java (1)

70-74: NPE 방어 로직 보강 제안

tourSpotCurrentCongestion가 null이 아닌 경우에도 getCongestionLvl()이 null이면 NPE가 발생할 수 있습니다. 레벨 null도 안전하게 처리하도록 방어 로직을 한 단계 보강하세요.

다음과 같이 수정 제안드립니다:

-        String congestionLabel = null;
-        if (tourSpotCurrentCongestion != null) {
-            congestionLabel = tourSpotCurrentCongestion.getCongestionLvl().getCongestionLabel();
-        }
+        String congestionLabel = null;
+        if (tourSpotCurrentCongestion != null) {
+            var lvl = tourSpotCurrentCongestion.getCongestionLvl();
+            congestionLabel = (lvl != null) ? lvl.getCongestionLabel() : null;
+        }

Also applies to: 79-79

src/main/java/com/opendata/domain/user/dto/UserResponse.java (1)

1-25: DTO 팩토리 메서드 보강 제안(엔티티 중심 변환 추가)

현재 정적 팩토리는 필드 나열식입니다. 서비스/컨트롤러에서 중복 매핑을 줄이기 위해 User 엔티티를 직접 받아 생성하는 팩토리(또는 Mapper)를 제공하면 재사용성이 높아집니다.

다음 메서드 추가를 제안드립니다(필요시 User import 추가):

 public class UserResponse
 {
     private String email;
     private MemberShip membership;
     private String name;

     public static UserResponse toUserResponse(String email, MemberShip membership, String name)
     {
         return UserResponse.builder()
                 .email(email)
                 .membership(membership)
                 .name(name)
                 .build();
     }
+
+    // 필요 시 엔티티 기반 팩토리 추가
+    public static UserResponse from(com.opendata.domain.user.entity.User user) {
+        if (user == null) return null;
+        return UserResponse.builder()
+                .email(user.getEmail())
+                .membership(user.getMembership())
+                .name(user.getName())
+                .build();
+    }
 }

추가로, Swagger 문서화를 사용 중이라면 각 필드에 @Schema 설명을 부여하면 API 명세 가독성이 향상됩니다.

src/main/java/com/opendata/domain/course/dto/response/CourseComponentHistoryDto.java (2)

3-10: String 필드에 @jsonformat 적용 비효율 및 시간 포맷 불일치 가능성

  • @jsonformat은 보통 날짜/시간 타입(LocalDateTime 등)에 적용할 때 직렬화 포맷에 효과가 있습니다. 현재 String에 붙어 있어 실효성이 떨어집니다.
  • 본 PR 내 다른 곳에서는 "yyyy-MM-dd HH:mm" 포맷을 사용합니다(CourseService). 여기서는 초까지 포함("yyyy-MM-dd HH:mm:ss"). 포맷 불일치로 파싱/매핑 이슈 가능성이 있습니다.

권장 수정(예시):

 import com.fasterxml.jackson.annotation.JsonFormat;
+import java.time.LocalDateTime;

 public record CourseComponentHistoryDto(
-        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
-        String tourSpotTime,
+        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+        LocalDateTime tourSpotTime,
         String tourSpotName,
         Long tourspotId,
         Double lat, Double lon)
 {
  • LocalDateTime으로 변경 시, 관련 Mapper(CourseHistoryMapper)의 dateFormat도 일관되게 맞춰주세요. 프로젝트 전반에서 "초 포함 여부(…:ss)"를 하나로 통일 확인 부탁드립니다.

8-10: 네이밍 일관성 제안: tourSpotName vs tourspotId

  • 동일 엔티티 기준으로 필드 네이밍이 혼재되어 있습니다. tourSpotName(카멜) vs tourspotId(소문자 spot). 레코드/DTO 전반에 걸쳐 일관된 스타일을 권장합니다(예: tourspotName, tourspotId).

권장 수정(예시):

-        String tourSpotName,
+        String tourspotName,

주의: 변경 시 Mapper 및 프론트/문서(Swagger)까지 영향이 있으므로 팀 합의 후 일괄 반영을 권장합니다.

src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java (1)

27-31: fetch join + orderBy 사용 시 컬렉션 정렬 보장되지 않음

  • 컬렉션 fetch join에 대한 orderBy(cc.tourspotTm.asc())는 영속성 컨텍스트 내 컬렉션(List)의 순서를 보장하지 않습니다. 결과 Course의 컬렉션 정렬이 필요하다면:
    • 엔티티 매핑에 @orderby("tourspotTm ASC") 추가, 또는
    • 조회 후 애플리케이션 단에서 컴포넌트 정렬

엔티티 측 권장(예시):

  • Course 엔티티의 courseComponents 매핑에 @orderby("tourspotTm ASC") 추가
src/main/java/com/opendata/domain/tourspot/repository/custom/currentCongestion/CustomCurrentCongestionRepositoryImpl.java (1)

16-21: 파라미터 표기 오탈자(fsctTime) 및 읽기 전용 트랜잭션 권장

  • 메서드명/필드가 fcstTime인데 파라미터명이 fsctTime으로 오탈자입니다. 혼란을 줄이기 위해 통일을 권장합니다.
  • 조회성 쿼리는 @transactional(readOnly = true) 적용을 권장합니다.

권장 수정(예시):

 import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;

 @Repository
 @RequiredArgsConstructor
 public class CustomCurrentCongestionRepositoryImpl implements CustomCurrentCongestionRepository {

     private final JPAQueryFactory queryFactory;
     @Override
-    public TourSpotCurrentCongestion findByTourSpotAndCurTime(TourSpot tourSpot, String fsctTime) {
+    @Transactional(readOnly = true)
+    public TourSpotCurrentCongestion findByTourSpotAndCurTime(TourSpot tourSpot, String fcstTime) {
         QTourSpotCurrentCongestion qTourSpotCurrentCongestion = QTourSpotCurrentCongestion.tourSpotCurrentCongestion;

         return queryFactory.selectFrom(qTourSpotCurrentCongestion)
-                .where(qTourSpotCurrentCongestion.tourspot.eq(tourSpot), qTourSpotCurrentCongestion.fcstTime.eq(fsctTime))
+                .where(qTourSpotCurrentCongestion.tourspot.eq(tourSpot),
+                       qTourSpotCurrentCongestion.fcstTime.eq(fcstTime))
                 .fetchFirst();
     }
 }
src/main/java/com/opendata/domain/course/service/CourseService.java (1)

59-105: Optional.get() 무방비 사용(참고 사항)

  • 본 구간은 이번 변경 범위는 아니지만, congestion.get() 호출은 빈 Optional일 경우 예외가 발생합니다. 안전 처리 고려 바랍니다.

권장 수정(아이디어): orElse, orElseThrow, 또는 기본 CongestionLevel 부여 등 도메인 정책에 맞게 처리

src/main/java/com/opendata/docs/CourseControllerDocs.java (2)

63-69: Swagger에 인증 Principal 파라미터 숨김 권장 (@parameter(hidden = true))

OpenAPI 문서에 customUserDetails가 노출되면 혼선을 줄 수 있습니다. 인증 컨텍스트에서 주입되는 파라미터는 숨기는 것이 일반적입니다.

아래처럼 파라미터에 @Parameter(hidden = true)를 추가하세요.

-            @AuthenticationPrincipal CustomUserDetails customUserDetails,
+            @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails customUserDetails,
             @RequestParam double lat,
             @RequestParam double lon,
             @RequestParam String startTime,
             @RequestParam String endTime,
             @RequestParam String tourspot

추가로 필요한 import (선택):

// 파일 상단 import 섹션에 추가
import io.swagger.v3.oas.annotations.Parameter;

또한, 본 API가 인증이 필요한 경우라면 @Operation에 SecurityRequirement를 명시하면 Swagger UI에서 토큰 연동이 보다 명확해집니다(전역 설정이 이미 있다면 생략 가능).

import io.swagger.v3.oas.annotations.security.SecurityRequirement;

@Operation(
  summary = "...",
  description = "...",
  security = @SecurityRequirement(name = "bearerAuth")
)

94-96: PathVariable 이름 명시 및 Principal 숨김

  • @PathVariable은 명시적으로 변수명을 지정하면 구현부/문서 상의 혼선을 줄일 수 있습니다.
  • 마찬가지로 @AuthenticationPrincipal은 문서에서 숨기는 편이 좋습니다.
-            @AuthenticationPrincipal CustomUserDetails customUserDetails,
-            @PathVariable String courseId
+            @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails customUserDetails,
+            @PathVariable("courseId") String courseId

필요 시 상단에 다음 import 추가:

import io.swagger.v3.oas.annotations.Parameter;
src/main/java/com/opendata/domain/tourspot/repository/CurrentCongestionRepository.java (1)

4-4: 패키지명에 CamelCase 사용: 자바 패키지 컨벤션과 불일치

custom.currentCongestion처럼 CamelCase가 섞인 패키지명은 자바 컨벤션(소문자, 점 구분)과 맞지 않습니다. OS/툴체인에 따라 경로 대소문자 이슈를 유발할 수 있어 추후 유지보수성에 불리합니다.

권장:

  • com.opendata.domain.tourspot.repository.custom.currentcongestion
  • 또는 의미를 더 간결히 한 custom.current

패키지명을 일괄 변경 시, 구현체/사용처 import도 함께 정리해야 합니다.

src/main/java/com/opendata/domain/tourspot/repository/custom/currentCongestion/CustomCurrentCongestionRepository.java (1)

1-8: 오타 및 네이밍 일관성: fsctTime → fcstTime, CurTime → FcstTime

  • 파라미터명이 fsctTime로 오타입니다. 엔티티 필드(fcstTime) 및 다른 레포(findByTourSpotIdAndFcstTime)와 일관되게 맞추세요.
  • 메서드명도 CurTime 대신 FcstTime로 통일하면 가독성과 검색성이 좋아집니다.
-package com.opendata.domain.tourspot.repository.custom.currentCongestion;
+package com.opendata.domain.tourspot.repository.custom.currentCongestion;
@@
 public interface CustomCurrentCongestionRepository {
-    TourSpotCurrentCongestion findByTourSpotAndCurTime(TourSpot tourSpot, String fsctTime);
+    TourSpotCurrentCongestion findByTourSpotAndFcstTime(TourSpot tourSpot, String fcstTime);
 }

추가 제안(선택):

  • 다른 커스텀 레포지토리(CustomFutureCongestionRepository)와 동일하게 반환 타입을 Optional<TourSpotCurrentCongestion>로 맞추면 널 처리/오류 전파가 명확해집니다.
-    TourSpotCurrentCongestion findByTourSpotAndFcstTime(TourSpot tourSpot, String fcstTime);
+    Optional<TourSpotCurrentCongestion> findByTourSpotAndFcstTime(TourSpot tourSpot, String fcstTime);

이 경우 구현체와 사용처도 함께 수정해야 합니다.

src/main/java/com/opendata/domain/course/mapper/CourseHistoryMapper.java (2)

28-33: dateFormat 제거 권장: 대상 타입이 String이 아닐 경우 무의미하거나 혼동 유발

tourspotTmtourSpotTime 매핑에서 dateFormat은 보통 String↔Date/Time 타입 변환 시에만 의미가 있습니다. CourseComponentHistoryDto.tourSpotTimeLocalDateTime(예: @JsonFormat)이라면 dateFormat은 제거하는 편이 명확합니다.

-    @Mapping(target = "tourSpotTime", source = "tourspotTm", dateFormat = "yyyy-MM-dd HH:mm:ss")
+    @Mapping(target = "tourSpotTime", source = "tourspotTm")
     @Mapping(target = "tourSpotName", source = "tourSpot.tourspotNm")
     @Mapping(target = "tourspotId", source = "tourSpot.tourspotId")
     @Mapping(target = "lat", source = "tourSpot.address.latitude")
     @Mapping(target = "lon", source = "tourSpot.address.longitude")
     CourseComponentHistoryDto toHistoryDto(CourseComponent component);

만약 반환을 문자열로 원하신다면 DTO의 필드 타입을 String으로 변경하거나, 별도의 @Named 포매팅 메서드를 제공해주시는 방식을 고려할 수 있습니다.


15-18: 중첩 널 안전성 확보(MapStruct null-check 전략 추가) 권장

tourSpot.address.latitude/longitude 접근 시 중간 객체가 null이면 NPE가 발생할 수 있습니다. 도메인 제약으로 null이 절대 아니라면 그대로 유지해도 되지만, 안전하게 가려면 null-check 전략을 켜는 방법이 있습니다.

 @Mapper(
-        componentModel = SPRING,
-        unmappedTargetPolicy = ReportingPolicy.IGNORE
+        componentModel = SPRING,
+        unmappedTargetPolicy = ReportingPolicy.IGNORE,
+        nullValueCheckStrategy = org.mapstruct.NullValueCheckStrategy.ALWAYS
 )

필요 시 import 추가:

import org.mapstruct.NullValueCheckStrategy;
src/main/java/com/opendata/domain/tourspot/repository/custom/component/CustomTourSpotComponentRepository.java (1)

13-14: 삭제 결과 반환값 제공 고려

deleteByUserIdAndTourSpotIdvoid를 반환하면 실제 삭제 건수를 상위 계층에서 검증하기 어렵습니다. 삭제된 행 수를 반환하면 서비스 계층에서 결과 검증/로깅이 용이합니다.

-    void deleteByUserIdAndTourSpotId(Long userId, Long tourSpotId);
+    long deleteByUserIdAndTourSpotId(Long userId, Long tourSpotId);

추가로, 계정 탈퇴 시 일괄정리를 고려해 long deleteByUserId(Long userId); 같은 메서드도 유용할 수 있습니다.

src/main/java/com/opendata/domain/course/repository/custom/CustomCourseComponentRepositoryImpl.java (2)

4-4: 미사용 import 제거 제안(QCourse)

QCourse는 사용되지 않습니다. 정리해 두면 경고 제거 및 가독성에 좋습니다.

적용 diff:

-import com.opendata.domain.course.entity.QCourse;

20-25: 중복 방지용 distinct() 추가를 고려

현재는 ManyToOne(fetch join)이라 중복 가능성이 낮지만, 조인 변경 시 중복이 발생할 수 있습니다. 방어적으로 distinct()를 추가하는 것을 고려해 주세요.

적용 diff:

         return queryFactory
                 .selectFrom(cc)
                 .leftJoin(cc.tourSpot, ts).fetchJoin()
                 .where(cc.course.id.eq(courseId))
+                .distinct()
                 .orderBy(cc.tourspotTm.asc())
                 .fetch();
src/main/java/com/opendata/domain/tourspot/repository/custom/component/CustomTourSpotComponentRepositoryImpl.java (2)

4-4: 미사용 import 제거 제안(TourSpot)

TourSpot 타입은 사용되지 않습니다. 제거를 권장합니다.

적용 diff:

-import com.opendata.domain.tourspot.entity.TourSpot;

40-48: count 쿼리 null 안전성 보강(선택사항)

COUNT는 일반적으로 0을 반환해 null 가능성이 낮지만, 방어적으로 null 대비를 넣어두면 안전합니다.

적용 diff:

     public long countByUserId(Long userId) {
         QTourSpotComponent q = QTourSpotComponent.tourSpotComponent;
-        return queryFactory
-                .select(q.count())
-                .from(q)
-                .where(q.userId.eq(userId))
-                .fetchOne();
+        Long count = queryFactory
+                .select(q.count())
+                .from(q)
+                .where(q.userId.eq(userId))
+                .fetchOne();
+        return count != null ? count : 0L;
     }
src/main/java/com/opendata/domain/course/controller/CourseController.java (2)

25-35: findCourses의 @AuthenticationPrincipal 파라미터가 사용되지 않음 — 의도 확인 필요

인증이 필수인 엔드포인트라면 파라미터를 실제 로직에 활용하거나, 보안 어노테이션 추가(@PreAuthorize 등)로 의도를 명확히 해주세요. 공개 엔드포인트라면 파라미터 제거를 고려해도 됩니다.


37-43: 좋아요 API 인증 강제 여부와 Null Principal 대응 확인

postCourseLike는 인증 사용자를 요구하는 흐름으로 보입니다. Security 설정에서 해당 경로를 인증 필수로 강제하고, 혹시나 null principal이 주입될 수 있는 상황에 대한 방어가 서비스 레이어에 있는지 확인 부탁드립니다. 필요 시 아래처럼 선언적 권한 체크를 추가할 수 있습니다.

예시:

+import org.springframework.security.access.prepost.PreAuthorize;
 ...
-@PostMapping("/like/{courseId}")
+@PreAuthorize("isAuthenticated()")
+@PostMapping("/like/{courseId}")
 public ResponseEntity<ApiResponse<Void>> postCourseLike(
         @AuthenticationPrincipal CustomUserDetails customUserDetails,
         @PathVariable String courseId){
src/main/java/com/opendata/docs/MyPageControllerDocs.java (2)

8-22: Swagger에 보안 요구사항을 클래스 레벨로 명시하면 문서 일관성이 좋아집니다

전역 설정이 있더라도, 문서 상에서 잠금 아이콘/Authorize 버튼 연계를 위해 명시적 @securityrequirement 추가를 권장합니다. 스키마 이름은 프로젝트 설정과 일치해야 합니다(예: "bearerAuth").

적용 diff:

 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.media.Content;
 import io.swagger.v3.oas.annotations.media.ExampleObject;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.responses.ApiResponses;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
 import org.springframework.http.ResponseEntity;
 import org.springframework.security.core.annotation.AuthenticationPrincipal;
 import org.springframework.web.bind.annotation.RequestParam;
 
 import java.util.List;
 
 @Tag(name = "마이페이지 API")
+@SecurityRequirement(name = "bearerAuth")
 public interface MyPageControllerDocs {

106-110: 메서드명(updateTourSpot)과 설명(“추가”)이 불일치합니다

동작이 “추가”라면 add/save 계열 네이밍으로 맞추는 것이 더 명확합니다. 컨트롤러/서비스 시그니처와 함께 일관성 확보를 권장합니다.

src/main/java/com/opendata/domain/mypage/controller/MypageController.java (4)

31-38: POST /preferences는 201 Created가 더 적합하며, 메서드명도 추가 동작을 반영하는 것이 좋습니다

리소스 생성(“선호 관광지 추가”) 성격의 엔드포인트이므로 201을 반환하는 것이 REST 관례에 더 맞습니다. 또한 메서드명을 update→add로 바꾸면 의도가 명확해집니다.

적용 예시:

 @PostMapping("/preferences")
-    public ResponseEntity<ApiResponse<Void>> updateTourSpot(
+    public ResponseEntity<ApiResponse<Void>> addTourSpot(
             @AuthenticationPrincipal CustomUserDetails customUserDetails,
             @RequestParam Long tourSpotId
     ){
         mypageService.saveUserTourSpot(customUserDetails,tourSpotId);
-        return ResponseEntity.ok(ApiResponse.onSuccessVoid());
+        return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.onSuccessVoid());
     }

추가 import (파일 상단):

import org.springframework.http.HttpStatus;

33-35: tourSpotId 파라미터에 유효성 검증 어노테이션을 추가하세요

음수/널 입력을 방지하기 위해 @NotNull, @positive를 권장합니다. 클래스에 @validated도 함께 적용해야 동작합니다.

-            @RequestParam Long tourSpotId
+            @RequestParam @NotNull @Positive Long tourSpotId

추가 import:

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

18-21: 컨트롤러에 @validated 추가로 메서드 파라미터 검증 활성화

메서드 파라미터의 Bean Validation을 활성화하려면 클래스에 @validated가 필요합니다.

 @RestController
 @RequiredArgsConstructor
+@Validated
 @RequestMapping("/mypage")
 public class MypageController implements MyPageControllerDocs

추가 import:

import org.springframework.validation.annotation.Validated;

45-52: DELETE는 PathVariable 사용을 권장합니다 (일관성 및 REST 관례 측면)

리소스 식별자는 URL 경로로 표현하는 것이 일반적입니다. 기존 클라이언트 영향 범위를 고려해 선택 적용을 권장합니다. 문서(MyPageControllerDocs)와의 동기화도 함께 확인 바랍니다.

-    @DeleteMapping("/preferences")
+    @DeleteMapping("/preferences/{tourSpotId}")
     public ResponseEntity<ApiResponse<Void>> deleteTourSpot(
             @AuthenticationPrincipal CustomUserDetails customUserDetails,
-            @RequestParam Long tourSpotId
+            @PathVariable Long tourSpotId
     ){
         mypageService.deleteTourSpot(customUserDetails,tourSpotId);
         return ResponseEntity.ok(ApiResponse.onSuccessVoid());
     }
src/main/java/com/opendata/domain/mypage/service/MypageService.java (2)

87-101: JsonProcessingException을 도메인 예외로 변환하고, 리스트 생성 일관성 유지

현재 RuntimeException으로 래핑하면 글로벌 에러 핸들러에서 일관된 에러 응답을 만들기 어렵습니다. GlobalException + ErrorStatus로 변환하고, 컬렉션 수집은 toList()로 맞추세요.

 return tourSpotComponentRepository.findAllByUserId(userId).stream()
         .map(tourSpotComponent -> {
             try {
                 return tourSpotService.combineTourSpotDetail(tourSpotComponent.getTourSpotId());
             } catch (JsonProcessingException e) {
-                throw new RuntimeException(e);
+                throw new GlobalException(ErrorStatus.INTERNAL_SERVER_ERROR);
             }
 
         })
-        .collect(Collectors.toList());
+        .toList();

ErrorStatus.INTERNAL_SERVER_ERROR가 없다면 공통 서버 오류 상태를 하나 정의해 사용하세요. 필요 시 정의/매핑도 도와드릴 수 있습니다.


44-53: 매직 넘버(5) 상수화로 가독성과 변경 용이성 개선

선호 관광지 최대 개수 5는 상수로 추출해 의미를 부여하면 유지보수성이 좋아집니다.

 public class MypageService {
+    private static final int MAX_PREFERENCES = 5;
@@
-        if (tourSpotComponentRepository.countByUserId(userId) >= 5) {
+        if (tourSpotComponentRepository.countByUserId(userId) >= MAX_PREFERENCES) {
             throw new GlobalException(ErrorStatus.TOURSPOT_EXCEEDS);
         }

Also applies to: 80-81

Comment on lines +12 to +14
@RequiredArgsConstructor
public class CustomCourseComponentRepositoryImpl implements CustomCourseComponentRepository {
private final JPAQueryFactory queryFactory;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

@repository 누락으로 커스텀 구현체가 빈으로 등록되지 않습니다 (런타임 실패 가능)

현재 클래스는 JPAQueryFactory를 생성자 주입(@requiredargsconstructor)으로 사용하지만, 스프링 빈이 아니면 주입되지 않습니다. Spring Data가 커스텀 프래그먼트 구현을 찾더라도 EntityManager 기반 생성자가 없으면 인스턴스화에 실패할 수 있습니다. @repository를 추가해 주세요.

적용 diff:

 import com.querydsl.jpa.impl.JPAQueryFactory;
 import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
 
 import java.util.List;
 
-@RequiredArgsConstructor
+@Repository
+@RequiredArgsConstructor
 public class CustomCourseComponentRepositoryImpl implements CustomCourseComponentRepository {
     private final JPAQueryFactory queryFactory;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@RequiredArgsConstructor
public class CustomCourseComponentRepositoryImpl implements CustomCourseComponentRepository {
private final JPAQueryFactory queryFactory;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class CustomCourseComponentRepositoryImpl implements CustomCourseComponentRepository {
private final JPAQueryFactory queryFactory;
🤖 Prompt for AI Agents
In
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseComponentRepositoryImpl.java
around lines 12-14, the class is not annotated as a Spring bean so constructor
injection can fail at runtime; add the @Repository annotation to the class
declaration and ensure Spring can instantiate it by providing a constructor that
accepts an EntityManager and creates the JPAQueryFactory (or alternatively
autowire an existing JPAQueryFactory bean) so the custom repository
implementation is registered and injectable at runtime.

Comment on lines +8 to 10
public interface CustomCourseRepository {
List<Course> findAllByUserId(Long userId);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

findAllByUserId 추가 OK — 다만 Course-User 매핑 존재 여부 확인 필요 + 페이징 확장 제안

  • 구현부(QueryDSL)에서 c.user.id.eq(userId)를 사용할 가능성이 높습니다. Course 엔티티에 User user 연관관계가 실제로 선언되어 있는지 확인이 필요합니다(제공된 스니펫에는 주석 처리 흔적이 보임).
  • 사용량이 많을 수 있는 마이페이지 히스토리 특성상 페이징/슬라이싱 시그니처도 함께 제공하면 확장성이 좋아집니다.

확인용 스크립트(엔티티에 User 연관관계가 있는지 탐색):

선택적 확장(페이징 시그니처 추가 제안):

+import org.springframework.data.domain.Pageable;
+
 public interface CustomCourseRepository {
     List<Course> findAllByUserId(Long userId);
+    List<Course> findAllByUserId(Long userId, Pageable pageable);
 }

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Course 엔티티 파일 찾기
course_file="$(fd -a 'Course.java' | rg 'domain/course/entity/Course\.java' || true)"
if [ -z "$course_file" ]; then
  echo "Course.java 파일을 찾지 못했습니다."
  exit 1
fi

echo "검사 대상: $course_file"
# User 연관 필드/어노테이션 유무 확인
rg -n -C3 -e 'class\s+Course\b' -e '@ManyToOne' -e '@JoinColumn' -e 'private\s+User\b' "$course_file"

Length of output: 1097


findAllByUserId 추가 OK — Course-User 매핑 확인됨; 페이징 오버로드 권장

검증 결과: Course 엔티티에 User 연관관계(@manytoone, @joincolumn, private User user)가 존재하므로 현재 인터페이스 시그니처는 적절합니다. 다만 마이페이지 사용량을 고려해 페이징/슬라이스 오버로드 추가를 권장합니다.

  • 확인된 위치
    • src/main/java/com/opendata/domain/course/entity/Course.java — @manytoone(fetch = FetchType.LAZY) / @joincolumn(name = "user_id") / private User user (라인 약 24–26)
    • src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepository.java — findAllByUserId(Long userId) (PR 스니펫: 라인 8–10)

권장 변경(선택):

+import org.springframework.data.domain.Pageable;
+
 public interface CustomCourseRepository {
     List<Course> findAllByUserId(Long userId);
+    List<Course> findAllByUserId(Long userId, Pageable pageable);
 }

선택적으로 반환형을 Page 또는 Slice로 변경하는 것도 고려하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public interface CustomCourseRepository {
List<Course> findAllByUserId(Long userId);
}
import org.springframework.data.domain.Pageable;
public interface CustomCourseRepository {
List<Course> findAllByUserId(Long userId);
List<Course> findAllByUserId(Long userId, Pageable pageable);
}
🤖 Prompt for AI Agents
In
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepository.java
around lines 8 to 10, add paginated overloads for findAllByUserId so callers can
request results with Pageable; specifically keep the existing List<Course>
findAllByUserId(Long userId) and add signatures like Page<Course>
findAllByUserId(Long userId, Pageable pageable) and/or Slice<Course>
findAllByUserId(Long userId, Pageable pageable), and ensure corresponding
imports (org.springframework.data.domain.Page, Slice, Pageable) and any custom
implementation are updated to support the new methods.

Comment on lines +4 to +18
import com.opendata.domain.course.entity.Course;
import com.opendata.domain.course.entity.QCourse;
import com.opendata.domain.course.entity.QCourseComponent;
import com.opendata.domain.tourspot.entity.QTourSpot;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;

import java.util.List;

@RequiredArgsConstructor
public class CustomCourseRepositoryImpl implements CustomCourseRepository{

private final JPAQueryFactory queryFactory;

@Override
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Custom Repository에서 JPAQueryFactory 주입 방식 오류 가능성

  • Spring Data 커스텀 리포지토리 구현은 보통 ‘Impl’ 클래스를 리포지토리 인프라가 직접 인스턴스화합니다. 이때 EntityManager 인자를 받는 생성자를 통해 내부에서 JPAQueryFactory를 생성하는 패턴이 안전합니다.
  • 현재 @requiredargsconstructor + JPAQueryFactory 필드 주입 방식은 빈 주입이 보장되지 않아 런타임 주입 실패(NoSuchBeanDefinition 등) 가능성이 큽니다.

권장 수정(예시):

 import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
 import lombok.RequiredArgsConstructor;

-@RequiredArgsConstructor
 public class CustomCourseRepositoryImpl implements CustomCourseRepository{
-
-    private final JPAQueryFactory queryFactory;
+    private final JPAQueryFactory queryFactory;
+
+    public CustomCourseRepositoryImpl(EntityManager em) {
+        this.queryFactory = new JPAQueryFactory(em);
+    }

설명: 이 패턴은 Spring Data가 Impl 클래스를 생성할 때 EntityManager를 전달해주므로, 추가 빈 주입 없이 안정적으로 동작합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import com.opendata.domain.course.entity.Course;
import com.opendata.domain.course.entity.QCourse;
import com.opendata.domain.course.entity.QCourseComponent;
import com.opendata.domain.tourspot.entity.QTourSpot;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import java.util.List;
@RequiredArgsConstructor
public class CustomCourseRepositoryImpl implements CustomCourseRepository{
private final JPAQueryFactory queryFactory;
@Override
import com.opendata.domain.course.entity.Course;
import com.opendata.domain.course.entity.QCourse;
import com.opendata.domain.course.entity.QCourseComponent;
import com.opendata.domain.tourspot.entity.QTourSpot;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import java.util.List;
public class CustomCourseRepositoryImpl implements CustomCourseRepository{
private final JPAQueryFactory queryFactory;
public CustomCourseRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
🤖 Prompt for AI Agents
In
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java
around lines 4 to 18, the class currently relies on a JPAQueryFactory being
injected via @RequiredArgsConstructor which can fail when Spring Data
instantiates the custom repository; change the implementation to accept an
EntityManager in the constructor (remove @RequiredArgsConstructor and the
injected JPAQueryFactory field), and inside that constructor instantiate a new
JPAQueryFactory(entityManager) to use for queries so the repository is created
reliably by Spring Data without requiring an external JPAQueryFactory bean.

Comment on lines +213 to 216
public void likeCourse(String courseId, CustomUserDetails customUserDetails) {
String email = customUserDetails.getUserEmail();
User user = userRepository.findUserByEmail(email);
ObjectMapper objectMapper = new ObjectMapper();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

인증 사용자 조회 시 NPE 가능성 — NotFound 처리 추가 권장

  • userRepository.findUserByEmail(email) 결과가 null일 수 있어 이후 빌더(user 세팅)에서 NPE/제약 위반 위험이 있습니다. 도메인 표준 예외로 명시적으로 처리하세요.

권장 수정(예시):

 @Transactional
 public void likeCourse(String courseId, CustomUserDetails customUserDetails) {
-        String email = customUserDetails.getUserEmail();
-        User user = userRepository.findUserByEmail(email);
+        String email = customUserDetails.getUserEmail();
+        User user = Optional.ofNullable(userRepository.findUserByEmail(email))
+                .orElseThrow(() -> new UserNotFoundException(UserMessages.USER_NOT_FOUND));

참고: UserNotFoundException/UserMessages 네임스페이스는 프로젝트의 기존 사용 위치와 동일하게 맞춰주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void likeCourse(String courseId, CustomUserDetails customUserDetails) {
String email = customUserDetails.getUserEmail();
User user = userRepository.findUserByEmail(email);
ObjectMapper objectMapper = new ObjectMapper();
public void likeCourse(String courseId, CustomUserDetails customUserDetails) {
String email = customUserDetails.getUserEmail();
User user = Optional.ofNullable(userRepository.findUserByEmail(email))
.orElseThrow(() -> new UserNotFoundException(UserMessages.USER_NOT_FOUND));
ObjectMapper objectMapper = new ObjectMapper();
🤖 Prompt for AI Agents
In src/main/java/com/opendata/domain/course/service/CourseService.java around
lines 213 to 216, the call userRepository.findUserByEmail(email) can return null
which may cause an NPE later; update the code to explicitly handle the not-found
case by replacing the raw null-return usage with a safe lookup that throws the
project’s standard domain exception (e.g. UserNotFoundException or equivalent
from UserMessages) when the user is absent (use Optional/orElseThrow or an
if-null throw), and ensure the thrown exception includes the email for context.

Comment on lines +54 to +72
public List<CourseHistoryResponse> getCourses(CustomUserDetails customUserDetails)
{
Long userId = customUserDetails.getUserId();

return courseRepository.findAllByUserId(userId).stream()
.map(course -> {
var components = Optional.ofNullable(course.getCourseComponents())
.orElseGet(Collections::emptyList);

var history = components.stream()
.map(courseHistoryMapper::toHistoryDto)
.toList();

return courseHistoryMapper.toHistoryResponse(course, history);
})
.toList();


}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

getCourses: N+1 위험과 페이징 필요성 점검

  • courseRepository.findAllByUserId(userId)가 코스-컴포넌트 연관 로딩 시 fetch join(또는 batch fetch)을 사용하지 않으면 N+1이 발생할 수 있습니다.
  • 사용자 코스 기록이 많을 수 있으므로 페이징 도입도 고려해 주세요.

다음 스크립트로 해당 메서드 구현을 확인해 주세요(페치 조인 여부 포함):


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# findAllByUserId 정의/사용부 확인
rg -nP --type=java -C3 '\bfindAllByUserId\s*\('

# fetch join 사용 흔적 탐색
rg -nP --type=java -C2 '(join\s+fetch|fetchJoin)'

Length of output: 7009


getCourses: fetch join으로 N+1는 해소되었으나 — 페이징 도입 필요

확인 요약:

  • 확인: src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java 의 findAllByUserId(...)에서 QueryDSL로
    .leftJoin(c.courseComponents, cc).fetchJoin() 및 .leftJoin(cc.tourSpot, ts).fetchJoin()를 사용하고 있어 연관 엔티티를 미리 로드합니다(따라서 N+1 문제는 해소됨).
  • 참고: selectDistinct(c)로 중복 코스도 제거하고 있습니다.

권장 조치(필수 권고):

  • 페이징 도입: 사용자의 코스 수가 많을 수 있으므로 페이징 또는 제한(limit) 도입 검토
    • 구현 방식 예시:
      • repository에 Pageable/limit/offset 파라미터 추가(또는 별도 페이징 메서드)하고 QueryDSL에 .limit/.offset 적용, 총건수 카운트 쿼리 제공 → Page 형태로 반환.
      • 또는 service에서 page/size 파라미터를 받아 부분 로드 후 반환 형식 변경.
  • 변경이 필요한 위치:
    • src/main/java/com/opendata/domain/mypage/service/MypageService.java — getCourses(...) (현재 54–72행)
    • src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java — findAllByUserId(...): 페이징 파라미터/쿼리 추가
🤖 Prompt for AI Agents
In src/main/java/com/opendata/domain/mypage/service/MypageService.java around
lines 54–72 and in
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java,
introduce paging to avoid unbounded result sets: change the service
getCourses(...) signature to accept page and size (or a Pageable) and call a new
repository method that accepts limit/offset or Pageable; update
CustomCourseRepositoryImpl.findAllByUserId(...) to apply QueryDSL .limit(...)
and .offset(...) (and implement a matching count query) so you can return a
Page-like result, then adapt the service to map the page content to
List<CourseHistoryResponse> and return a Page<CourseHistoryResponse> (or a DTO
containing items + total/pages) accordingly.

@@ -1,4 +1,4 @@
package com.opendata.domain.tourspot.repository.custom;
package com.opendata.domain.tourspot.repository.custom.montlyCongestion;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

패키지명 오탈자(montly → monthly) 수정 필요

인터페이스와 동일하게 패키지 오탈자를 수정해주세요. 구현체와 인터페이스의 패키지가 항상 일치해야 합니다.

-package com.opendata.domain.tourspot.repository.custom.montlyCongestion;
+package com.opendata.domain.tourspot.repository.custom.monthlyCongestion;

폴더명도 함께 montlyCongestionmonthlyCongestion로 변경되어야 합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
package com.opendata.domain.tourspot.repository.custom.montlyCongestion;
package com.opendata.domain.tourspot.repository.custom.monthlyCongestion;
🤖 Prompt for AI Agents
In
src/main/java/com/opendata/domain/tourspot/repository/custom/montlyCongestion/CustomMonthlyCongestionRepositoryImpl.java
around line 1, the package name has a typo ("montlyCongestion" should be
"monthlyCongestion"); update the package declaration to
com.opendata.domain.tourspot.repository.custom.monthlyCongestion, rename the
directory from montlyCongestion to monthlyCongestion to match, update any
imports/usages and the interface package to the corrected package name so the
implementation and interface packages match, and run a compile/IDE refactor to
fix any remaining references.


import com.opendata.domain.tourspot.entity.TourSpotMonthlyCongestion;
import com.opendata.domain.tourspot.repository.custom.CustomMonthlyCongestionRepository;
import com.opendata.domain.tourspot.repository.custom.montlyCongestion.CustomMonthlyCongestionRepository;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

import 경로 오탈자(montly → monthly)로 인한 컴파일 오류 가능

패키지 오탈자 수정에 맞춰 import 경로도 함께 변경되어야 합니다.

-import com.opendata.domain.tourspot.repository.custom.montlyCongestion.CustomMonthlyCongestionRepository;
+import com.opendata.domain.tourspot.repository.custom.monthlyCongestion.CustomMonthlyCongestionRepository;

추가 확인:

  • Spring Data JPA 커스텀 프래그먼트 결합 규칙상 CustomMonthlyCongestionRepositoryImpl 명명은 적절합니다. 패키지 이동 후에도 base package 스캔 범위 내에 있으면 자동 결합됩니다.

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "→ Searching repo for occurrences of 'montly' (common typo)..."
rg -n --hidden -S "montly" || true

echo
echo "→ Searching repo for occurrences of 'monthlyCongestion' (expected correct package)..."
rg -n --hidden -S "monthlyCongestion" || true

echo
echo "→ Searching for types/filenames referenced in the review (CustomMonthlyCongestionRepository, MonthlyCongestionRepository)..."
rg -n --hidden -S "CustomMonthlyCongestionRepository|MonthlyCongestionRepository" || true

echo
TARGET="src/main/java/com/opendata/domain/tourspot/repository/MonthlyCongestionRepository.java"
if [ -f "$TARGET" ]; then
  echo "→ Showing top of $TARGET"
  sed -n '1,200p' "$TARGET"
else
  echo "→ File $TARGET not found in workspace."
fi

Length of output: 5704


패키지 오타(montly → monthly) 수정 필요

검증 결과 코드베이스에 montlyCongestion 패키지명이 실제로 사용되고 있습니다. 단순 import 수정만으로는 불일치가 발생하니 패키지 선언, 디렉터리명, 관련 import를 함께 변경하세요.

수정 대상(예시)

  • src/main/java/com/opendata/domain/tourspot/repository/MonthlyCongestionRepository.java — import 수정
  • src/main/java/com/opendata/domain/tourspot/repository/custom/montlyCongestion/CustomMonthlyCongestionRepository.java — package 선언 수정
  • src/main/java/com/opendata/domain/tourspot/repository/custom/montlyCongestion/CustomMonthlyCongestionRepositoryImpl.java — package 선언 수정
  • 디렉터리명 변경: src/main/java/com/opendata/domain/tourspot/repository/custom/montlyCongestion → .../monthlyCongestion

권장 diff 예시:

- import com.opendata.domain.tourspot.repository.custom.montlyCongestion.CustomMonthlyCongestionRepository;
+ import com.opendata.domain.tourspot.repository.custom.monthlyCongestion.CustomMonthlyCongestionRepository;

패키지 선언(두 파일 모두)

- package com.opendata.domain.tourspot.repository.custom.montlyCongestion;
+ package com.opendata.domain.tourspot.repository.custom.monthlyCongestion;

주의 및 확인사항:

  • CustomMonthlyCongestionRepositoryImpl 네이밍은 적절합니다. 구현 클래스가 base package 스캔 범위 내에 있으면 Spring Data JPA가 커스텀 프래그먼트를 자동으로 결합합니다.
  • 변경 후 프로젝트 전체 검색으로 남은 참조(import/패키지)를 모두 갱신하고 빌드 확인하세요.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import com.opendata.domain.tourspot.repository.custom.montlyCongestion.CustomMonthlyCongestionRepository;
import com.opendata.domain.tourspot.repository.custom.monthlyCongestion.CustomMonthlyCongestionRepository;
🤖 Prompt for AI Agents
In
src/main/java/com/opendata/domain/tourspot/repository/MonthlyCongestionRepository.java
around line 4, the import references the misspelled package "montlyCongestion";
update import to use "monthlyCongestion" and then rename the package declaration
in
src/main/java/com/opendata/domain/tourspot/repository/custom/montlyCongestion/CustomMonthlyCongestionRepository.java
and CustomMonthlyCongestionRepositoryImpl.java to
"com.opendata.domain.tourspot.repository.custom.monthlyCongestion", move/rename
the directory
src/main/java/com/opendata/domain/tourspot/repository/custom/montlyCongestion →
.../monthlyCongestion, and run a project-wide search to update all
imports/references and verify the project builds successfully.

Comment on lines +8 to +9
User findUserById(Long userId);
void deleteUserById(Long userId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

JpaRepository가 이미 제공하는 기능과 중복됩니다

  • findUserById(Long)JpaRepository#findById(Optional)와 기능상 유사합니다.
  • deleteUserById(Long)JpaRepository#deleteById와 중복됩니다.
    QueryDSL이 필요한 특별한 사유(복잡한 조인/성능 최적화 등)가 없다면, 중복 메서드는 제거하고 서비스 레이어에서 Optional 처리 및 예외 변환을 권장합니다. 중복은 API 표면적을 넓히고 혼동을 유발합니다.

다음과 같이 인터페이스에서 제거하는 방향을 제안합니다(구현체에서도 함께 제거 필요):

 public interface CustomUserRepository {
     User findUserByEmail(String email);
-    User findUserById(Long userId);
-    void deleteUserById(Long userId);
 }

또는, 만약 실제 사용 중인지 확인이 필요하다면 다음 스크립트로 호출처를 점검해 주세요.


🏁 Script executed:

#!/bin/bash
# findUserById / deleteUserById 호출부 검색
rg -nP '\bfindUserById\s*\(' -C3
rg -nP '\bdeleteUserById\s*\(' -C3

Length of output: 2886


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- CustomUserRepository.java ----"
sed -n '1,240p' src/main/java/com/opendata/domain/user/repository/custom/CustomUserRepository.java || true

echo
echo "---- CustomUserRepositoryImpl.java ----"
sed -n '1,240p' src/main/java/com/opendata/domain/user/repository/custom/CustomUserRepositoryImpl.java || true

echo
echo "---- 찾기: UserRepository 인터페이스(또는 JpaRepository 확장) ----"
rg -nP --hidden --no-ignore 'interface\s+\w*UserRepository\b' -S || true

echo
echo "---- 프로젝트 내 JpaRepository 확장 목록(간단히) ----"
rg -nP --hidden --no-ignore 'extends\s+JpaRepository<|extends\s+PagingAndSortingRepository<' -S || true

Length of output: 4825


CustomUserRepository의 중복된 메서드 제거 권고

JpaRepository(UserRepository가 JpaRepository<User, Long>를 확장)가 이미 findById/deleteById를 제공하고 있으며, 현재 Custom 구현은 QueryDSL로 단순 id 조회/삭제만 수행합니다. 전체 검색 결과 해당 메서드들의 선언/구현 외에 다른 호출처는 발견되지 않았습니다. 특별한 이유(예: 복잡한 조인, 성능 튜닝, Optional 미사용 의도)가 없다면 인터페이스와 구현체에서 제거하세요. 서비스 레이어에서 Optional 처리 및 예외 변환을 권장합니다.

수정 대상:

  • src/main/java/com/opendata/domain/user/repository/custom/CustomUserRepository.java
  • src/main/java/com/opendata/domain/user/repository/custom/CustomUserRepositoryImpl.java

제안 diff:

 public interface CustomUserRepository {
     User findUserByEmail(String email);
-    User findUserById(Long userId);
-    void deleteUserById(Long userId);
 }
 public class CustomUserRepositoryImpl implements CustomUserRepository{
 
     private final JPAQueryFactory queryFactory;
 
     @Override
     public User findUserByEmail(String email) {
         QUser u = QUser.user;
         return queryFactory
                 .selectFrom(u)
                 .where(u.email.eq(email))
                 .fetchFirst();
     }
-
-    @Override
-    public User findUserById(Long userId) {
-        QUser u = QUser.user;
-        return queryFactory
-                .selectFrom(u)
-                .where(u.id.eq(userId))
-                .fetchFirst();
-    }
-
-    @Override
-    public void deleteUserById(Long userId) {
-        QUser u = QUser.user;
-        queryFactory
-                .delete(u)
-                .where(u.id.eq(userId))
-                .execute();
-    }
 }

서비스에서 Optional 반환을 원치 않아 메서드를 유지하려면 의도를 명시해 주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
User findUserById(Long userId);
void deleteUserById(Long userId);
public interface CustomUserRepository {
User findUserByEmail(String email);
}
Suggested change
User findUserById(Long userId);
void deleteUserById(Long userId);
public class CustomUserRepositoryImpl implements CustomUserRepository{
private final JPAQueryFactory queryFactory;
@Override
public User findUserByEmail(String email) {
QUser u = QUser.user;
return queryFactory
.selectFrom(u)
.where(u.email.eq(email))
.fetchFirst();
}
}
🤖 Prompt for AI Agents
In
src/main/java/com/opendata/domain/user/repository/custom/CustomUserRepository.java
around lines 8-9 and the corresponding
src/main/java/com/opendata/domain/user/repository/custom/CustomUserRepositoryImpl.java,
remove the duplicate method declarations and implementations for
findUserById(Long) and deleteUserById(Long) because JpaRepository already
provides findById/deleteById; update any callers in service layer to use
repository.findById(id) and handle the returned Optional (or convert to
exception there), and delete the QueryDSL-based implementations from the Impl
class; if you intentionally want non-Optional semantics, instead of duplicating
methods keep them but rename (e.g., getUserByIdOrThrow) and implement explicit
behavior with Javadoc describing the intent.

Comment on lines +33 to 40
@Override
public void deleteUserById(Long userId) {
QUser u = QUser.user;
queryFactory
.delete(u)
.where(u.id.eq(userId))
.execute();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

삭제 쿼리에 트랜잭션 명시 필요

  • QueryDSL delete는 트랜잭션 경계 내에서 실행되어야 합니다. 현재 메서드에 @transactional이 없어 호출 컨텍스트에 따라 실패할 수 있습니다.

권장 수정(예시):

 import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;

 ...

     @Override
-    public void deleteUserById(Long userId) {
+    @Transactional
+    public void deleteUserById(Long userId) {
         QUser u = QUser.user;
         queryFactory
                 .delete(u)
                 .where(u.id.eq(userId))
                 .execute();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public void deleteUserById(Long userId) {
QUser u = QUser.user;
queryFactory
.delete(u)
.where(u.id.eq(userId))
.execute();
}
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Override
@Transactional
public void deleteUserById(Long userId) {
QUser u = QUser.user;
queryFactory
.delete(u)
.where(u.id.eq(userId))
.execute();
}
🤖 Prompt for AI Agents
In
src/main/java/com/opendata/domain/user/repository/custom/CustomUserRepositoryImpl.java
around lines 33 to 40, the QueryDSL delete is executed outside an explicit
transaction which can fail depending on the caller; annotate the method with
Spring's @Transactional (or annotate the class) to ensure a transactional
boundary, import org.springframework.transaction.annotation.Transactional, and
ensure the transaction is writable (i.e., no readOnly=true) so the delete
executes reliably.

Comment on lines 33 to +37
return new OpenAPI()
.info(info);
.addServersItem(new Server().url("/")) // 리버스 프록시 등일 때 베이스 경로
.components(components)
.addSecurityItem(securityRequirement)
.info(apiInfo());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Server URL("/") 전역 설정은 배포 환경에 따라 경로 해석에 영향 — 프로필별 검증 권장

리버스 프록시/게이트웨이/서브패스 배포 시 Swagger UI의 "Try it out"가 잘못된 베이스 경로를 조합할 수 있습니다. 환경별 프로필에서 server URL을 분기하거나 제거하는 방안을 검토해 주세요.

실제 배포 환경에서 경로가 올바르게 계산되는지 확인하는 간단한 점검 스크립트입니다(문자열 체크 수준):


🏁 Script executed:

#!/bin/bash
# openapi.yaml/json 산출물을 찾아 servers 섹션과 url 값을 살펴봅니다.
fd -t f -a --hidden 'openapi.*\.(ya?ml|json)$' | while read -r f; do
  echo "== $f =="
  rg -n -A2 -B2 -i 'servers:|\"servers\"|url:|\"url\"' "$f"
done

Length of output: 60


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "추가 검증: Swagger/OpenAPI 관련 코드 및 설정(searching for OpenAPI/Swagger usages)..."

rg -n --hidden -S -g '!**/target/**' -g '!**/.git/**' \
  'addServersItem\(|new\s+Server\(\)\.url\(|OpenAPI\(|class\s+SwaggerConfig\b|springdoc|v3/api-docs|swagger-ui|servers:' -C3 || true

# SwaggerConfig 파일이 있으면 내용 출력
files=$(rg -n --hidden -S -g '!**/target/**' -g '!**/.git/**' 'class\s+SwaggerConfig\b' -l || true)
if [ -n "$files" ]; then
  echo
  for f in $files; do
    echo "== $f =="
    nl -ba "$f" | sed -n '1,240p'
    echo
  done
fi

# 리포지토리 내 정적 OpenAPI 산출물 검색
echo "== searching for openapi.*.(yml|yaml|json) artifacts =="
rg -n --hidden -S -g '!**/target/**' -g '!**/.git/**' 'openapi.*\.(ya?ml|json)$' || true

# application.*(yml|properties)에서 springdoc 또는 server 관련 설정 검색
echo "== searching application.* for springdoc/server settings =="
rg -n --hidden -S -g '!**/target/**' -g '!**/.git/**' 'springdoc|server\.servlet\.context-path|server\.forward-headers-strategy|server\.base-path|spring\.web\.mvc\.servlet\.path' -C2 || true

Length of output: 3973


Server URL("/") 전역 설정 — 프로필/설정 분기 또는 제거 필요

SwaggerConfig에서 new Server().url("/")가 하드코딩되어 있어 리버스 프록시/게이트웨이/서브패스 배포 시 Swagger UI(특히 "Try it out")의 요청 URL이 잘못 계산될 수 있습니다. 검증 결과와 권고는 아래와 같습니다.

  • 확인된 위치

    • src/main/java/com/opendata/global/config/SwaggerConfig.java (약 라인 33–37)
      return new OpenAPI()
              .addServersItem(new Server().url("/")) // 리버스 프록시 등일 때 베이스 경로
              .components(components)
              .addSecurityItem(securityRequirement)
              .info(apiInfo());
      
    • src/main/java/com/opendata/global/config/SecurityConfig.java (약 라인 94–95) — Swagger UI 및 /v3/api-docs 경로는 permitAll로 접근 허용되어 있음.
    • 리포지토리에서 정적 openapi.*.(yml|yaml|json) 산출물 또는 application.*에서의 springdoc/server 관련 설정은 검색 결과로 발견되지 않았습니다.
  • 권장 조치 (우선순위)

    1. 가장 간단: .addServersItem(new Server().url("/")) 제거(또는 주석 처리) — 다양한 배포 환경에서 잘못된 기본경로 적용 방지.
    2. 권장: 프로퍼티/프로파일로 분기 — 배포 환경별로 server URL을 주입하도록 변경.
      예시:
      @Value("${openapi.server-url:}")
      private String openapiServerUrl;
      
      public OpenAPI openAPI() {
          OpenAPI openAPI = new OpenAPI();
          if (!openapiServerUrl.isBlank()) {
              openAPI.addServersItem(new Server().url(openapiServerUrl));
          }
          return openAPI
                  .components(components)
                  .addSecurityItem(securityRequirement)
                  .info(apiInfo());
      }
    3. (옵션) CI/배포 문서에 각 환경의 base path 처리 방식 명시.

위 사항을 반영해 코드/설정(또는 프로파일)을 분기하거나 서버 URL을 제거해 주세요.

🤖 Prompt for AI Agents
In src/main/java/com/opendata/global/config/SwaggerConfig.java around lines
33–37, remove the hardcoded .addServersItem(new Server().url("/")) or replace it
with a property-driven check: add a String field injected from a property (e.g.
openapi.server-url) and only call addServersItem when that value is non-blank;
update application.properties/application-<profile>.properties with
environment-specific server URLs as needed (leave property empty by default), so
Swagger UI won’t assume a root base path in reverse-proxy/subpath deployments.

@7ijin01 7ijin01 requested a review from kamillcream August 15, 2025 04:59
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🔭 Outside diff range comments (1)
src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepositoryImpl.java (1)

16-19: 구현 누락: updateCongestionLevel이 항상 1L을 반환합니다

현재 메서드가 고정값을 반환하여 실제 업데이트가 수행되지 않습니다. 호출부는 업데이트된 행 수를 기대할 가능성이 높아 기능 오작동으로 이어질 수 있습니다. QueryDSL 업데이트 구현과 트랜잭션 보장을 추가해 주세요.

아래 diff로 메서드 본문을 구현하세요(선택적으로 @transactional 추가):

-    public long updateCongestionLevel(List<TourSpotMonthlyCongestion> monthlyCongestions) {
-        return 1L;
-    }
+    @Transactional
+    public long updateCongestionLevel(List<TourSpotMonthlyCongestion> monthlyCongestions) {
+        if (monthlyCongestions == null || monthlyCongestions.isEmpty()) {
+            return 0L;
+        }
+        long affected = 0L;
+        for (TourSpotMonthlyCongestion mc : monthlyCongestions) {
+            if (mc.getMonthlyCongestionId() == null || mc.getCongestionLvl() == null) {
+                continue;
+            }
+            affected += queryFactory
+                    .update(tourSpotMonthlyCongestion)
+                    .set(tourSpotMonthlyCongestion.congestionLvl, mc.getCongestionLvl())
+                    .where(tourSpotMonthlyCongestion.monthlyCongestionId.eq(mc.getMonthlyCongestionId()))
+                    .execute();
+        }
+        return affected;
+    }

메서드 외부에 다음 import를 추가하세요:

import org.springframework.transaction.annotation.Transactional;
import static com.opendata.domain.tourspot.entity.QTourSpotMonthlyCongestion.tourSpotMonthlyCongestion;

참고:

  • 대량 업데이트(벌크 업데이트)를 사용하므로 동일 영속성 컨텍스트에 로드된 엔티티가 있다면, 이후 읽기 전에 clear/refresh를 고려하세요(서비스 계층에서 처리 권장).
🧹 Nitpick comments (1)
src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepositoryImpl.java (1)

1-1: 패키지 세그먼트에 카멜케이스 사용은 비권장입니다

Java 패키지명은 관례적으로 전부 소문자를 사용합니다. custom.monthlyCongestioncustom.monthlycongestion 또는 custom.monthly 등으로 통일을 검토해 주세요. 팀 컨벤션과도 일치하는지 확인 바랍니다.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1c8bf6e and 4de269b.

📒 Files selected for processing (3)
  • src/main/java/com/opendata/domain/tourspot/repository/MonthlyCongestionRepository.java (1 hunks)
  • src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepository.java (1 hunks)
  • src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepositoryImpl.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/opendata/domain/tourspot/repository/MonthlyCongestionRepository.java
🧰 Additional context used
🧬 Code Graph Analysis (2)
src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepository.java (2)
src/main/java/com/opendata/domain/tourspot/repository/custom/CustomMonthlyCongestionRepositoryImpl.java (2)
  • CustomMonthlyCongestionRepositoryImpl (10-20)
  • updateCongestionLevel (16-19)
src/main/java/com/opendata/domain/tourspot/repository/custom/CustomMonthlyCongestionRepository.java (2)
  • CustomMonthlyCongestionRepository (7-10)
  • updateCongestionLevel (9-9)
src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepositoryImpl.java (5)
src/main/java/com/opendata/domain/tourspot/repository/custom/CustomMonthlyCongestionRepositoryImpl.java (2)
  • CustomMonthlyCongestionRepositoryImpl (10-20)
  • updateCongestionLevel (16-19)
src/main/java/com/opendata/domain/tourspot/repository/custom/CustomMonthlyCongestionRepository.java (2)
  • CustomMonthlyCongestionRepository (7-10)
  • updateCongestionLevel (9-9)
src/main/java/com/opendata/domain/tourspot/repository/custom/CustomFutureCongestionRepositoryImpl.java (1)
  • CustomFutureCongestionRepositoryImpl (12-39)
src/main/java/com/opendata/domain/tourspot/entity/TourSpotMonthlyCongestion.java (1)
  • TourSpotMonthlyCongestion (8-24)
src/main/java/com/opendata/domain/tourspot/repository/custom/CustomFutureCongestionRepository.java (1)
  • CustomFutureCongestionRepository (9-12)
🔇 Additional comments (2)
src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepositoryImpl.java (1)

1-12: 패키지 이동 검증 완료 — 레거시 참조/중복 없음

검색 결과: CustomMonthlyCongestionRepository는 새 경로에만 존재하며(아래 참조), 기존 패키지 경로를 가리키는 import는 발견되지 않았습니다. 중복 정의도 없습니다.

  • src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepository.java
  • src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepositoryImpl.java

문제 없음.

src/main/java/com/opendata/domain/tourspot/repository/custom/monthlyCongestion/CustomMonthlyCongestionRepository.java (1)

1-10: 패키지 이동과 인터페이스 시그니처 유지, 문제 없습니다

마이그레이션된 패키지 경로와 시그니처가 일관적입니다. 구현체와의 네이밍 규약(Spring Data JPA 커스텀 레포지토리 바인딩)도 적합해 보입니다.

@7ijin01 7ijin01 merged commit 64d7d3d into main Aug 16, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 마이페이지 구현

2 participants