Skip to content

Conversation

@kamillcream
Copy link
Contributor

@kamillcream kamillcream commented Aug 19, 2025

📌 PR 개요

  • 코스 상세 정보 반환 API 추가

✅ 변경사항

  • 우선 redis에서 조회 후, 없으면 DB에서 조회 ( 임시 코스, 즐겨찾기한 코스 둘 다 대응하기 위함 )
  • 코스 객체 속성에 uuid ( redis에 저장할 때 임시로 넣은 id ) 추가

🔍 체크리스트

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

📎 관련 이슈

Closes #53


💬 기타 참고사항

Summary by CodeRabbit

  • 신기능
    • 코스 상세 조회 API 추가: 코스 ID로 구성 요소와 혼잡도 정보를 포함한 상세를 반환합니다.
  • 변경
    • 기존 코스 스펙 조회 API가 제거되었습니다. 해당 엔드포인트를 사용 중이라면 새 코스 상세 조회 API로 전환하세요.
  • 버그 수정
    • 시간 파싱 로직 개선: ISO 형식(‘T’ 포함) 및 분 단위 입력도 안정적으로 처리합니다.
  • 문서
    • 코스 상세 조회 API 응답(성공/404) 예시와 설명을 추가했습니다.

@kamillcream kamillcream requested a review from 7ijin01 August 19, 2025 01:27
@kamillcream kamillcream self-assigned this Aug 19, 2025
@kamillcream kamillcream linked an issue Aug 19, 2025 that may be closed by this pull request
1 task
@coderabbitai
Copy link

coderabbitai bot commented Aug 19, 2025

Walkthrough

코스 상세 조회 API를 추가하고, 기존 코스 사양(spec) 엔드포인트와 DTO를 제거했습니다. 코스 엔티티에 uuid 필드를 도입하고, uuid 기반 조회 리포지토리 메서드를 추가했습니다. 서비스는 Redis 선조회 후 DB 조회 흐름으로 CourseResponse를 생성합니다. DateUtil 파싱 로직과 애플리케이션 비밀번호 환경변수를 수정했습니다.

Changes

Cohort / File(s) Summary
API Surface (Controller & Docs)
src/main/java/com/opendata/docs/CourseControllerDocs.java, src/main/java/com/opendata/domain/course/controller/CourseController.java
GET /course/{courseId} 엔드포인트 및 문서 추가(getCourseDetail). 기존 GET /course/spec/{courseId} 및 문서 제거/주석 처리. 사소한 포맷팅 정리.
Service Logic & Caching
src/main/java/com/opendata/domain/course/service/CourseService.java, src/main/java/com/opendata/global/util/DateUtil.java
fetchCourseDetail(String) 추가: Redis "tempCourse:{id}" 캐시 선조회, 미스 시 uuid로 코스 조회 후 컴포넌트 혼잡도 조회·매핑하여 CourseResponse 반환. DateUtil parseTime이 'T' 처리 및 분 단위 입력 보정(:00 추가).
Persistence Layer
src/main/java/com/opendata/domain/course/entity/Course.java, src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepository.java, .../CustomCourseRepositoryImpl.java
Courseuuid 필드 추가(빌더 포함). 리포지토리에 findByUuid(String) 추가 및 구현(QueryDSL로 단일 조회).
DTO Cleanup
src/main/java/com/opendata/domain/course/dto/response/CourseSpecResponse.java
CourseSpecResponse 레코드 삭제.
Configuration
src/main/resources/application.yml
MySQL 비밀번호 참조를 ${MYSQL_USER}에서 ${MYSQL_PASSWORD}로 변경.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Controller as CourseController
  participant Service as CourseService
  participant Redis as RedisCache
  participant Repo as CourseRepository
  participant Ext as Congestion API

  Client->>Controller: GET /course/{courseId}
  Controller->>Service: fetchCourseDetail(courseId)
  Service->>Redis: GET tempCourse:{courseId}
  alt Cache hit
    Redis-->>Service: List<CourseComponentDto>
    Service-->>Controller: CourseResponse(from cache)
  else Cache miss
    Service->>Repo: findByUuid(courseId)
    Repo-->>Service: Course
    loop for each component
      Service->>Ext: get congestion forecast
      Ext-->>Service: CongestionLevel
      Service->>Service: map to CourseComponentDto
    end
    Service-->>Controller: CourseResponse(from DB)
  end
  Controller-->>Client: 200 ApiResponse<CourseResponse>
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Assessment against linked issues

Objective Addressed Explanation
코스 상세 정보 조회 API 제공 (#53)
Redis 선조회 후 미스 시 DB 조회 구현 (#53)

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
기존 코스 스펙 엔드포인트 제거 및 DTO 삭제 (CourseController.getCourseSpec 제거, .../CourseSpecResponse.java 삭제) 이슈 #53에는 스펙 엔드포인트 제거 요구가 명시되지 않음.
DB 비밀번호 환경변수 키 변경 (src/main/resources/application.yml) 이슈 #53의 범위(코스 상세/캐시 로직)와 직접적 연관 없음. 운영/환경 설정 변경은 별도 이슈가 필요.

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • 7ijin01

Poem

귀 쫑긋, 캐시 먼저 살짝 훑고
못 찾으면 폴짝 DB로 둥둥—
코스의 길, UUID에 숨었네.
시간 포맷도 탁, T를 톡 빼내고,
오늘도 나는 리뷰 밭을 뛰는 토끼! 🥕

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/#53-course-detail

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: 2

🔭 Outside diff range comments (2)
src/main/resources/application.yml (2)

38-42: JWT 시크릿 하드코딩은 보안 사고로 직결됩니다 — 환경변수로 전환하세요

레포지토리에 비밀키가 노출되어 있습니다. 최소 prod에서는 반드시 환경변수로 분리하고, 이 커밋 이전 히스토리를 포함해 키 교체(폐기)도 권장합니다.

다음과 같이 환경변수로 치환하는 것을 제안합니다:

 jwt:
   issuer: [email protected]
-  secret: "jfsgkhdsjkweoiruwqejkrfbhnsadkjlfghbwerqewrsdfgdsfagbdf"
+  secret: ${JWT_SECRET}
   access-expiration: 3600
   refresh-expiration: 8640

63-66: 외부 API 키 하드코딩 제거 필요

API 키가 코드에 남아 있습니다. 키 노출은 회수/재발급이 필요한 사고입니다. 환경변수로 치환하세요.

 api:
   seoul_city_data_key: ${SEOUL_CITY_DATA_KEY}
   tour_api_tourspot_key: ${TOUR_API_TOURSPOT_KEY}
-  tour_api_congestion_key: B4FraEdNAEHerMG6ZQUi5OXCzio%2FQJ4IRx9rOOz7%2BeiPBh4L8pX4XAygutNaYnOoL%2BD8vS%2F3qZ53efN6daHZ%2Fg%3D%3D
-  zz: wabF%2F2ep3dqrmXQcTNupVTpQXrL3wBuOK1TSRhAo8mwZ9Wqop4GCNUXkqSdf4%2Bwxa1%2FdOCkfmbBXYQL13wlwKQ%3D%3D
-  xx: J28I%2B2X%2FLQ8vHJaw2Yorr492RFOq2%2FRBGCkl1hVMJCu65fNG5xDvDi7GazJpx3ZKa9xb4fYhq14vtXjWSaHrSw%3D%3D
+  tour_api_congestion_key: ${TOUR_API_CONGESTION_KEY}
+  zz: ${API_ZZ}
+  xx: ${API_XX}
🧹 Nitpick comments (8)
src/main/resources/application.yml (1)

56-59: 프로덕션 로그 레벨 점검 권장

Hibernate SQL/파라미터 로그가 전역으로 debug/trace입니다. 운영 환경에서는 성능/보안(민감 파라미터 노출) 이슈가 있습니다. profile(local)로 한정하거나 prod에서는 info 이하로 낮추는 것을 권장합니다.

src/main/java/com/opendata/domain/course/entity/Course.java (1)

24-25: uuid 칼럼에 유니크 제약 추가 권장

findByUuid 조회가 추가되었으므로, 데이터 정합성과 성능(인덱스)을 위해 유니크 제약(및 길이)을 명시하는 것이 좋습니다. 중복 UUID가 생기면 현재 구현(fetchFirst)은 비결정적 결과를 반환할 수 있습니다.

아래와 같이 칼럼 제약을 추가하는 것을 제안합니다(기존 데이터에 uuid가 비어있을 수 있으므로 nullable은 상황에 맞게 결정):

-    private String uuid;
+    @Column(name = "uuid", length = 36, unique = true)
+    private String uuid;

운영 DB에 제약이 생성되는지(DDL-auto 전략/마이그레이션) 확인 부탁드립니다. 필요 시 명시적인 마이그레이션 스크립트로 배포하는 것이 안전합니다.

src/main/java/com/opendata/global/util/DateUtil.java (1)

35-40: 시간 파싱 로직을 포맷러로 일반화하면 견고성이 올라갑니다

현재는 길이 비교와 문자 치환으로 분기합니다. T 또는 공백 구분자, 초 존재 유무를 모두 포괄하는 포맷터를 사용하면 예외 경우를 줄일 수 있습니다. null/blank 입력에 대한 방어도 추가해 주세요.

아래와 같이 개선을 제안합니다(추가 import 없이 FQCN 사용):

-        String normalized = time.replace("T", " ");
-        if (normalized.length() == 16) { // yyyy-MM-dd HH:mm
-            normalized += ":00";
-        }
-        return LocalDateTime.parse(normalized, FORMATTER);
+        if (time == null || time.isBlank()) {
+            throw new IllegalArgumentException("time must not be null/blank");
+        }
+        String normalized = time.trim();
+        java.time.format.DateTimeFormatter flexible =
+                new java.time.format.DateTimeFormatterBuilder()
+                        .parseCaseInsensitive()
+                        .appendPattern("yyyy-MM-dd")
+                        .optionalStart().appendLiteral('T').optionalEnd()
+                        .optionalStart().appendLiteral(' ').optionalEnd()
+                        .appendPattern("HH:mm")
+                        .optionalStart().appendLiteral(':').appendPattern("ss").optionalEnd()
+                        .toFormatter();
+        return LocalDateTime.parse(normalized, flexible);

참고: 타임존이 포함된 입력(e.g. Z, +09:00)을 지원해야 한다면 OffsetDateTime 파싱으로 별도 분기하는 것을 권장합니다.

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

7-12: 파라미터 null 계약을 명시하면 사용성이 좋아집니다

uuid에 대한 null/blank 허용 여부를 인터페이스 차원에서 명확히 해두면 호출부 오류를 줄일 수 있습니다. 스프링 표준 어노테이션으로 명시를 제안합니다.

 import java.util.List;
 import java.util.Optional;
+import org.springframework.lang.NonNull;

 public interface CustomCourseRepository {
     List<Course> findAllByUserId(Long userId);
-    Optional<Course> findByUuid(String uuid);
+    Optional<Course> findByUuid(@NonNull String uuid);
 }
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java (1)

35-43: findByUuid 메서드: Null/Blank 검증 및 fetchOne 사용, fetchCourseDetail 시 연관 엔티티 즉시 로딩 검토

  • findByUuid(String uuid)에서 입력값이 null 또는 공백일 경우 빠르게 Optional.empty()를 반환해 불필요한 쿼리 실행 방지
  • UUID가 유니크하다면 fetchOne()을 사용해 중복 발생 시 예외를 던져 조기에 이상 징후 탐지
  • fetchCourseDetail 내부에서 course.getCourseComponents()를 바로 순회하므로, 트랜잭션 범위 바깥에서 호출될 경우 LazyInitializationException 또는 N+1 문제가 발생할 수 있음
    • 서비스 메서드에 @Transactional(readOnly = true) 추가
    • 또는 CustomCourseRepositoryImpl.findByUuid에 fetchJoin()으로 연관 엔티티 미리 로딩

예시 변경안:

 @Override
 public Optional<Course> findByUuid(String uuid) {
-   QCourse qCourse = QCourse.course;
-
-   return Optional.ofNullable(queryFactory.selectFrom(qCourse)
-           .where(qCourse.uuid.eq(uuid))
-           .fetchFirst());
+   if (uuid == null || uuid.isBlank()) {
+       return Optional.empty();
+   }
+   QCourse c = QCourse.course;
+   return Optional.ofNullable(
+           queryFactory.selectFrom(c)
+                   .where(c.uuid.eq(uuid))
+                   .fetchOne()
+   );
 }

또는 상세 조회용 메서드에 아래와 같이 fetch join 적용:

return Optional.ofNullable(
    queryFactory.selectFrom(c)
        .leftJoin(c.courseComponents, cc).fetchJoin()
        .leftJoin(cc.tourSpot, ts).fetchJoin()
        .where(c.uuid.eq(uuid))
        .fetchOne()
);
src/main/java/com/opendata/docs/CourseControllerDocs.java (1)

71-93: 500 응답 스펙도 문서화해 주세요 (다른 문서와의 일관성).

TourSpot 상세 문서처럼 500 응답 예시를 추가하면 에러 응답 포맷 일관성이 좋아집니다.

아래와 같이 500 응답을 추가하는 것을 제안합니다:

     @ApiResponses(value = {
             @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "코스 상세 정보 조회 성공"),
             @io.swagger.v3.oas.annotations.responses.ApiResponse(
                     responseCode = "404",
                     description = "코스 정보 없음",
                     content = @Content(
                             mediaType = "application/json",
                             examples = @ExampleObject(
                                     name = "코스 없음",
                                     summary = "존재하지 않는 코스 ID",
                                     value = """
                                 {
                                   "success": false,
                                   "code": "COURSE_404",
                                   "message": "해당 코스를 찾을 수 없습니다."
                                 }
                                 """
                             )
                     )
             )
+            ,
+            @io.swagger.v3.oas.annotations.responses.ApiResponse(
+                    responseCode = "500",
+                    description = "서버 에러",
+                    content = @Content(
+                            mediaType = "application/json",
+                            examples = @ExampleObject(
+                                    name = "서버 에러 응답",
+                                    summary = "예상치 못한 서버 에러",
+                                    value = """
+                                    {
+                                      "success": false,
+                                      "code": "COMMON_500",
+                                      "message": "서버 에러, 관리자에게 문의 바랍니다."
+                                    }
+                                    """
+                            )
+                    )
+            )
     })
src/main/java/com/opendata/domain/course/service/CourseService.java (2)

249-251: ObjectMapper 매번 생성 대신 빈 주입 사용 권장.

서비스 전역에서 동일 직렬화 정책을 유지하도록 빈 주입(ObjectMapper)을 사용하세요. 성능과 유지보수에 유리합니다.

적용 예(클래스 상단 필드 추가):

private final ObjectMapper objectMapper;

그리고 메서드 내

- ObjectMapper objectMapper = new ObjectMapper();

삭제 후 주입된 필드를 사용해 주세요.

likeCourse(...)의 동일 패턴도 함께 정리 권장.


249-276: 코스 ID 표현 일관성 강화

Redis-Key, 외부 API, DB UUID가 혼용되어 있어 사용성과 유지보수에 혼란을 줄 수 있습니다. 아래 세 위치를 중심으로 “외부 API ↔ Redis ↔ DB” 간 코스 ID를 일관되게 관리할 것을 권장드립니다.

주요 위치

  • src/main/java/com/opendata/domain/course/service/CourseService.java:97–99
  • src/main/java/com/opendata/domain/course/service/CourseService.java:218–219
  • src/main/java/com/opendata/domain/course/service/CourseService.java:252–253

제안 리팩터링 예시

  1. 추천코스 생성부 (recommendCourses)
- String tempCourseId = "tempCourse:" + UUID.randomUUID();
- courseResponses.add(courseResponseMapper.toResponse(tempCourseId, singleCourse));
- redisTemplate.opsForValue().set(tempCourseId, singleCourse, Duration.ofMinutes(30));
+ // ① 외부에는 UUID만 노출
+ String uuid = UUID.randomUUID().toString();
+ String redisKey = "tempCourse:" + uuid;
+ courseResponses.add(courseResponseMapper.toResponse(uuid, singleCourse));
+ redisTemplate.opsForValue().set(redisKey, singleCourse, Duration.ofMinutes(30));
  1. 조회부 (likeCourse, fetchCourseDetail 등)
final String PREFIX = "tempCourse:";
String normalized = courseId.startsWith(PREFIX)
    ? courseId.substring(PREFIX.length())
    : courseId;
String redisKey = PREFIX + normalized;
List<?> rawList = (List<?>) redisTemplate.opsForValue().get(redisKey);

이렇게 처리하면

  • 외부 API 요청/응답에서는 순수 UUID만 주고받고
  • Redis 키 조립·조회 로직은 항상 PREFIX+UUID 형태로 일관화
  • DB 조회도 UUID 기반으로 통일

코드 반영 후, CourseResponseMapper.toResponse(UUID) 시그니처 변경 여부도 함께 검토해 주세요.

📜 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 35e0ad0 and 71619fd.

📒 Files selected for processing (9)
  • src/main/java/com/opendata/docs/CourseControllerDocs.java (2 hunks)
  • src/main/java/com/opendata/domain/course/controller/CourseController.java (3 hunks)
  • src/main/java/com/opendata/domain/course/dto/response/CourseSpecResponse.java (0 hunks)
  • src/main/java/com/opendata/domain/course/entity/Course.java (1 hunks)
  • src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepository.java (1 hunks)
  • src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java (2 hunks)
  • src/main/java/com/opendata/domain/course/service/CourseService.java (2 hunks)
  • src/main/java/com/opendata/global/util/DateUtil.java (1 hunks)
  • src/main/resources/application.yml (1 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/com/opendata/domain/course/dto/response/CourseSpecResponse.java
🧰 Additional context used
🧬 Code Graph Analysis (6)
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepositoryImpl.java (3)
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseComponentRepositoryImpl.java (2)
  • RequiredArgsConstructor (12-27)
  • Override (15-26)
src/main/java/com/opendata/domain/course/repository/CourseRepository.java (1)
  • CourseRepository (12-15)
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseComponentRepository.java (2)
  • CustomCourseComponentRepository (7-10)
  • findAllByCourseId (9-9)
src/main/java/com/opendata/domain/course/repository/custom/CustomCourseRepository.java (1)
src/main/java/com/opendata/domain/course/repository/CourseRepository.java (1)
  • CourseRepository (12-15)
src/main/java/com/opendata/domain/course/entity/Course.java (4)
src/main/java/com/opendata/domain/course/mapper/CourseMapper.java (2)
  • toEntity (16-16)
  • Mapper (10-17)
src/main/java/com/opendata/domain/course/dto/response/CourseComponentHistoryDto.java (1)
  • CourseComponentHistoryDto (5-13)
src/main/java/com/opendata/domain/course/repository/CourseRepository.java (1)
  • CourseRepository (12-15)
src/main/java/com/opendata/domain/course/dto/response/CourseResponse.java (1)
  • CourseResponse (5-8)
src/main/java/com/opendata/domain/course/service/CourseService.java (2)
src/main/java/com/opendata/domain/course/dto/response/CourseComponentHistoryDto.java (1)
  • CourseComponentHistoryDto (5-13)
src/main/java/com/opendata/domain/course/dto/response/CourseComponentDto.java (1)
  • CourseComponentDto (12-23)
src/main/java/com/opendata/domain/course/controller/CourseController.java (3)
src/main/java/com/opendata/domain/mypage/service/MypageService.java (1)
  • course (59-68)
src/main/java/com/opendata/docs/MyPageControllerDocs.java (1)
  • Operation (22-64)
src/main/java/com/opendata/domain/mypage/controller/MypageController.java (1)
  • RestController (18-65)
src/main/java/com/opendata/docs/CourseControllerDocs.java (2)
src/main/java/com/opendata/domain/course/dto/response/CourseResponse.java (1)
  • CourseResponse (5-8)
src/main/java/com/opendata/docs/TourSpotControllerDocs.java (1)
  • Operation (42-82)
🔇 Additional comments (4)
src/main/resources/application.yml (1)

75-75: 로컬 DB 비밀번호 변수 정합성 확인 필요

prod와 동일하게 ${MYSQL_PASSWORD}로 통일된 점은 좋습니다. 로컬 실행 환경(IDE, docker-compose, .env)에 MYSQL_PASSWORD가 실제로 주입되는지 확인 부탁드립니다. 기존에 MYSQL_USER로 오해하고 설정해둔 케이스가 있다면 로컬 부팅 실패 가능성이 있습니다.

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

12-12: Optional import 추가는 타당합니다

findByUuidOptional을 반환하므로 해당 import 추가는 적절합니다.

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

93-93: courseId 형식(임시/영구) 명시 검토 요청.

현재 API는 Redis 임시 코스와 DB 영구 코스를 모두 지원합니다. path 변수 courseId가 "UUID만"인지, "tempCourse:UUID" 같은 prefix를 포함하는지 명확히 문서에 표기되면 통신 오류를 줄일 수 있습니다. 서비스 구현과 합의된 포맷을 문서에 반영해 주세요.

src/main/java/com/opendata/domain/course/controller/CourseController.java (1)

38-41: 신규 상세 조회 엔드포인트 위임 로직 깔끔합니다.

서비스 위임과 ApiResponse 래핑이 기존 컨벤션과 일치합니다.

Comment on lines +249 to +276
public CourseResponse fetchCourseDetail(String courseId){
ObjectMapper objectMapper = new ObjectMapper();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
List<?> rawList = (List<?>) redisTemplate.opsForValue().get("tempCourse:" + courseId);
if (rawList == null || rawList.isEmpty()){
List<CourseComponentDto> courseComponentDtoList = new ArrayList<>();
Course course = courseRepository.findByUuid(courseId).orElseThrow();
course.getCourseComponents().forEach(
courseComponent -> {
Long spotId = courseComponent.getTourSpot().getTourspotId();
LocalDateTime time = courseComponent.getTourspotTm();

Optional<TourSpotFutureCongestion> congestion =
futureCongestionRepository.findByTourSpotIdAndFcstTime(spotId, time.format(formatter));

CongestionLevel level = congestion.get().getCongestionLvl();
courseComponentDtoList.add(CourseComponentDto.from(courseComponent, level));

}
);
return new CourseResponse(courseId, courseComponentDtoList);
}
List<CourseComponentDto> tempCourse = objectMapper.convertValue(
rawList,
new TypeReference<List<CourseComponentDto>>() {}
);
return new CourseResponse(courseId, tempCourse);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Redis 키 prefix 중복 가능성(tempCourse:tempCourse:...) → 캐시 미스 및 조회 실패 유발.

추천 응답에서 반환하는 ID에 이미 "tempCourse:" prefix가 포함될 여지가 있는 반면, 여기서는 다시 "tempCourse:"를 덧붙여 조회합니다. 이 경우 실제 Redis 키와 불일치하여 캐시 미스가 발생할 수 있습니다. 또한 캐시 미스 후 DB 조회 시에도 prefix가 포함된 ID를 그대로 사용하면 영구 코스 UUID 조회가 실패할 수 있습니다.

Minimal-impact 수정: 전달받은 courseId가 prefix를 포함해도/포함하지 않아도 동작하도록 키와 조회용 ID를 분리하세요.

-    public CourseResponse fetchCourseDetail(String courseId){
-        ObjectMapper objectMapper = new ObjectMapper();
-        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
-        List<?> rawList = (List<?>) redisTemplate.opsForValue().get("tempCourse:" + courseId);
+    public CourseResponse fetchCourseDetail(String courseId){
+        ObjectMapper objectMapper = new ObjectMapper();
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
+        final String PREFIX = "tempCourse:";
+        final boolean hasPrefix = courseId != null && courseId.startsWith(PREFIX);
+        final String redisKey = hasPrefix ? courseId : PREFIX + courseId;
+        final String normalizedId = hasPrefix ? courseId.substring(PREFIX.length()) : courseId;
+        List<?> rawList = (List<?>) redisTemplate.opsForValue().get(redisKey);
         if (rawList == null || rawList.isEmpty()){
             List<CourseComponentDto> courseComponentDtoList = new ArrayList<>();
-            Course course = courseRepository.findByUuid(courseId).orElseThrow();
+            Course course = courseRepository.findByUuid(normalizedId)
+                    .orElseThrow(() -> new CourseNotFoundException(CourseMessages.COURSE_NOT_FOUND));
             course.getCourseComponents().forEach(
                     courseComponent -> {
                         Long spotId = courseComponent.getTourSpot().getTourspotId();
                         LocalDateTime time = courseComponent.getTourspotTm();
 
-                        Optional<TourSpotFutureCongestion> congestion =
-                                futureCongestionRepository.findByTourSpotIdAndFcstTime(spotId, time.format(formatter));
-
-                        CongestionLevel level = congestion.get().getCongestionLvl();
+                        CongestionLevel level = futureCongestionRepository
+                                .findByTourSpotIdAndFcstTime(spotId, time.format(formatter))
+                                .map(TourSpotFutureCongestion::getCongestionLvl)
+                                .orElseThrow(() -> new IllegalStateException("Future congestion not found: spotId="
+                                        + spotId + ", time=" + time.format(formatter)));
                         courseComponentDtoList.add(CourseComponentDto.from(courseComponent, level));
 
                     }
             );
-            return new CourseResponse(courseId, courseComponentDtoList);
+            return new CourseResponse(normalizedId, courseComponentDtoList);
         }
         List<CourseComponentDto> tempCourse = objectMapper.convertValue(
                 rawList,
                 new TypeReference<List<CourseComponentDto>>() {}
         );
-        return new CourseResponse(courseId, tempCourse);
+        return new CourseResponse(normalizedId, tempCourse);
     }

추가 권장(일관성 확립):

  • recommendCourses에서 응답에 담는 courseId는 "UUID만"을 사용하고, Redis 키만 "tempCourse:" + UUID로 관리하면 혼선을 근본적으로 제거할 수 있습니다. 아래 별도 코멘트에 보강 제안을 첨부합니다.
  • 메시지 상수(CourseMessages.COURSE_NOT_FOUND)는 실제 사용중인 상수명에 맞춰 조정해 주세요.
📝 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 CourseResponse fetchCourseDetail(String courseId){
ObjectMapper objectMapper = new ObjectMapper();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
List<?> rawList = (List<?>) redisTemplate.opsForValue().get("tempCourse:" + courseId);
if (rawList == null || rawList.isEmpty()){
List<CourseComponentDto> courseComponentDtoList = new ArrayList<>();
Course course = courseRepository.findByUuid(courseId).orElseThrow();
course.getCourseComponents().forEach(
courseComponent -> {
Long spotId = courseComponent.getTourSpot().getTourspotId();
LocalDateTime time = courseComponent.getTourspotTm();
Optional<TourSpotFutureCongestion> congestion =
futureCongestionRepository.findByTourSpotIdAndFcstTime(spotId, time.format(formatter));
CongestionLevel level = congestion.get().getCongestionLvl();
courseComponentDtoList.add(CourseComponentDto.from(courseComponent, level));
}
);
return new CourseResponse(courseId, courseComponentDtoList);
}
List<CourseComponentDto> tempCourse = objectMapper.convertValue(
rawList,
new TypeReference<List<CourseComponentDto>>() {}
);
return new CourseResponse(courseId, tempCourse);
}
public CourseResponse fetchCourseDetail(String courseId){
ObjectMapper objectMapper = new ObjectMapper();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
final String PREFIX = "tempCourse:";
final boolean hasPrefix = courseId != null && courseId.startsWith(PREFIX);
final String redisKey = hasPrefix ? courseId : PREFIX + courseId;
final String normalizedId = hasPrefix ? courseId.substring(PREFIX.length()) : courseId;
List<?> rawList = (List<?>) redisTemplate.opsForValue().get(redisKey);
if (rawList == null || rawList.isEmpty()){
List<CourseComponentDto> courseComponentDtoList = new ArrayList<>();
Course course = courseRepository.findByUuid(normalizedId)
.orElseThrow(() -> new CourseNotFoundException(CourseMessages.COURSE_NOT_FOUND));
course.getCourseComponents().forEach(
courseComponent -> {
Long spotId = courseComponent.getTourSpot().getTourspotId();
LocalDateTime time = courseComponent.getTourspotTm();
CongestionLevel level = futureCongestionRepository
.findByTourSpotIdAndFcstTime(spotId, time.format(formatter))
.map(TourSpotFutureCongestion::getCongestionLvl)
.orElseThrow(() -> new IllegalStateException("Future congestion not found: spotId="
spotId + ", time=" + time.format(formatter)));
courseComponentDtoList.add(CourseComponentDto.from(courseComponent, level));
}
);
return new CourseResponse(normalizedId, courseComponentDtoList);
}
List<CourseComponentDto> tempCourse = objectMapper.convertValue(
rawList,
new TypeReference<List<CourseComponentDto>>() {}
);
return new CourseResponse(normalizedId, tempCourse);
}
🤖 Prompt for AI Agents
In src/main/java/com/opendata/domain/course/service/CourseService.java around
lines 249-276, the method builds the Redis key by blindly prefixing
"tempCourse:" to the incoming courseId which may already include that prefix,
causing key duplication and DB lookup failures; fix by normalizing inputs: if
courseId startsWith "tempCourse:" remove that prefix to produce a pure UUID
variable (e.g., uuid), then use redisKey = "tempCourse:" + uuid for Redis
get/convert and use uuid (without prefix) for DB repository.findByUuid(...)
calls; also handle the Optional from futureCongestionRepository safely and
ensure error/exception messages use the correct message constant name.

Comment on lines +261 to +266
Optional<TourSpotFutureCongestion> congestion =
futureCongestionRepository.findByTourSpotIdAndFcstTime(spotId, time.format(formatter));

CongestionLevel level = congestion.get().getCongestionLvl();
courseComponentDtoList.add(CourseComponentDto.from(courseComponent, level));

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Optional.get() 사용으로 인한 NoSuchElementException 위험.

혼잡도 조회가 실패할 경우 NSEE로 500이 발생합니다. 명시적으로 orElseThrow로 전환하거나 기본값을 정의하세요.

위 상위 코멘트의 diff에 포함된 형태처럼 처리하거나, 기본값이 가능하다면 다음과 같이도 가능합니다:

- CongestionLevel level = congestion.get().getCongestionLvl();
+ CongestionLevel level = congestion
+     .map(TourSpotFutureCongestion::getCongestionLvl)
+     .orElse(CongestionLevel.UNKNOWN); // 프로젝트 도메인에 맞는 기본값으로 교체
📝 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
Optional<TourSpotFutureCongestion> congestion =
futureCongestionRepository.findByTourSpotIdAndFcstTime(spotId, time.format(formatter));
CongestionLevel level = congestion.get().getCongestionLvl();
courseComponentDtoList.add(CourseComponentDto.from(courseComponent, level));
Optional<TourSpotFutureCongestion> congestion =
futureCongestionRepository.findByTourSpotIdAndFcstTime(spotId, time.format(formatter));
CongestionLevel level = congestion
.map(TourSpotFutureCongestion::getCongestionLvl)
.orElse(CongestionLevel.UNKNOWN); // 프로젝트 도메인에 맞는 기본값으로 교체
courseComponentDtoList.add(CourseComponentDto.from(courseComponent, level));
🤖 Prompt for AI Agents
In src/main/java/com/opendata/domain/course/service/CourseService.java around
lines 261 to 266, avoid calling Optional.get() which can throw
NoSuchElementException; instead either use orElseThrow with a clear, specific
exception (e.g., EntityNotFoundException with a helpful message including
spotId/time) or supply a sensible default via orElse (and handle default
congestion level appropriately), then pass the resolved CongestionLevel into
CourseComponentDto.from; update any method signatures or error handling to
reflect the chosen approach.

@kamillcream kamillcream merged commit 1da903c into main Aug 19, 2025
2 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Sep 19, 2025
4 tasks
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