Skip to content

Conversation

@hd0rable
Copy link
Member

@hd0rable hd0rable commented Jul 21, 2025

#️⃣ 연관된 이슈

closes #92

📝 작업 내용

  • 피드 저장 상태 변경 api를 개발했습니다.
  • 피드 저장 상태 변경의 흐름은 다음과 같습니다.
  1. Controller에서 Request의 Command 변환뒤 서비스로 요청이들어옴
  2. 피드 조회, 사용자가 저장한 피드 일급 컬렉션 조회
  3. 저장 요청일 경우 -> 사용자의 피드 저장테이블(saveFeed)에 피드 저장
  4. 삭제 요청일 경우 -> 사용자의 피드 저장테이블(saveFeed)에서 피드 저장 삭제
  • 각 저장,삭제 요청일 시 이미 저장된 피드이라면 중복 저장 예외처리, 이미 삭제된 피드라면(사용자와 피드가 관계가 없다면) 삭제 예외처리가 되어있습니다.
  • 이전 피드 수정 브랜치에서 해당 작업을 하여서 커밋 내역이 섞여있습니다. 피드 수정 브랜치 머지되면 바로 pull받아 반영하겠습니다
  • 저희의 어댑터에서의 엔티티 조회 전략을 피드 도메인에 적용했습니다.
  • 관련 통합 테스트 코드도 작성했습니다.
  • 책 저장 상태 변경과 DB에 실제 책을 저장하는 로직을 제외하고는 동일한 로직입니다.

📸 스크린샷

image

💬 리뷰 요구사항

📌 PR 진행 시 이러한 점들을 참고해 주세요

* P1 : 꼭 반영해 주세요 (Request Changes) - 이슈가 발생하거나 취약점이 발견되는 케이스 등
* P2 : 반영을 적극적으로 고려해 주시면 좋을 것 같아요 (Comment)
* P3 : 이런 방법도 있을 것 같아요~ 등의 사소한 의견입니다 (Chore)

Summary by CodeRabbit

  • 신규 기능

    • 피드 저장 및 저장 해제(북마크) 기능이 추가되었습니다.
    • 피드 저장/해제 상태를 확인하고 변경할 수 있는 API가 추가되었습니다.
    • 피드 저장/해제 시 상세한 오류 메시지 및 코드가 제공됩니다.
  • 버그 수정

    • 피드 저장/해제 과정에서 중복 저장, 미저장 피드 삭제 등 예외 상황에 대한 처리가 강화되었습니다.
  • 테스트

    • 피드 저장/해제 API에 대한 통합 테스트가 추가되었습니다.
  • 기타

    • 피드 업데이트 기능이 추가되어 일부 정보(내용, 공개여부, 태그, 이미지)만 선택적으로 수정할 수 있습니다.
    • 여러 도메인 객체 및 인터페이스가 개선되어 피드 저장/해제, 업데이트, 조회 관련 기능이 확장되었습니다.

@coderabbitai
Copy link

coderabbitai bot commented Jul 21, 2025

Walkthrough

이 변경은 피드의 저장 상태를 변경하는 API와 관련된 주요 도메인, 어댑터, 서비스, 예외 코드, 테스트를 추가 및 확장합니다. 피드 저장/해제 기능을 위한 서비스 로직, 컨트롤러, 포트, 도메인 객체, 예외 처리, 영속성 계층, 테스트 코드가 새로 도입되거나 보완되었습니다.

Changes

파일/경로 요약 변경 내용 요약
src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java
src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java
src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsSavedResponse.java
피드 저장 상태 변경 API 엔드포인트 및 DTO 추가, 기존 생성 응답 변경
src/main/java/konkuk/thip/feed/application/port/in/FeedSavedUseCase.java
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedCommand.java
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedResult.java
피드 저장 상태 변경 유스케이스 및 커맨드/결과 DTO 신설
src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java
src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java
피드 저장/해제 서비스, 피드 수정 서비스 구현
src/main/java/konkuk/thip/feed/domain/Feed.java
src/main/java/konkuk/thip/feed/domain/SavedFeeds.java
피드 도메인에 저장 관련 검증 및 업데이트 메서드, 저장 피드 컬렉션 도메인 추가
src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java
src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java
피드 조회/수정/태그 조회 기능 확장 및 JPQL Projection 적용
src/main/java/konkuk/thip/common/exception/code/ErrorCode.java 피드 저장/해제/중복 등 예외 코드 추가 및 메시지/상수명 변경
src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java
src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java
src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java
피드 저장/해제 영속성 로직 및 저장 피드 쿼리, 배치 태그 조회, 저장 피드 삭제/조회 메서드 추가
src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java
src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java
src/main/java/konkuk/thip/saved/application/port/out/dto/FeedIdAndTagProjection.java
저장 포트에 피드 저장/해제/조회 관련 메서드, 태그 Projection 인터페이스 추가
src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java 피드 수정/조회/예외 처리용 메서드 추가
src/main/java/konkuk/thip/feed/application/port/in/dto/DummyResult.java 불필요한 DummyResult DTO 삭제
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java 피드 저장/해제 API 통합 테스트 신설(성공, 중복, 삭제, 예외 등 시나리오)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java 테스트용 피드/태그/저장피드 엔티티 생성 메서드 추가

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as FeedCommandController
    participant Service as FeedSavedService
    participant SavedQuery as SavedQueryPort
    participant SavedCommand as SavedCommandPort
    participant FeedPort as FeedCommandPort

    Client->>Controller: POST /feeds/{feedId}/saved (isSaved)
    Controller->>Service: changeSavedFeed(command)
    Service->>FeedPort: getByIdOrThrow(feedId)
    Service->>SavedQuery: findSavedFeedsByUserId(userId)
    alt isSaved = true
        Service->>SavedFeeds: validateNotAlreadySaved(feed)
        Service->>SavedCommand: saveFeed(userId, feedId)
    else isSaved = false
        Service->>SavedFeeds: validateCanDelete(feed)
        Service->>SavedCommand: deleteFeed(userId, feedId)
    end
    Service-->>Controller: FeedIsSavedResult
    Controller-->>Client: FeedIsSavedResponse
Loading

Estimated code review effort

4 (~80분)

Possibly related PRs

Suggested reviewers

  • seongjunnoh

Poem

토끼가 퐁퐁 뛰어가며
피드 저장, 해제도 척척!
중복 저장 막아주고
삭제 예외도 콕콕!
테스트도 빼곡히,
오늘도 서버는 평화롭다네.
🐰✨

✨ Finishing Touches
  • 📝 Generate Docstrings

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.
    • Explain this complex logic.
    • 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. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • 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 src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

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

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

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

Documentation and Community

  • 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: 5

🧹 Nitpick comments (9)
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedCommand.java (1)

1-12: DTO 구조는 적절하지만 포맷팅 개선이 필요합니다.

Record 패턴을 사용한 불변 DTO 구현이 적절합니다. 다만 불필요한 빈 줄들을 제거하여 코드를 더 간결하게 만들 수 있습니다.

 public record FeedIsSavedCommand(
-
         Long userId,
-
         Long feedId,
-
         Boolean isSaved
-)
-{
-}
+        Long userId,
+        Long feedId,
+        Boolean isSaved
+) {
+}

또한 필수 필드에 대한 validation annotation 추가를 고려해보세요:

public record FeedIsSavedCommand(
        @NotNull Long userId,
        @NotNull Long feedId,
        @NotNull Boolean isSaved
) {
}
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedResult.java (1)

8-10: 정적 팩토리 메서드의 필요성 검토

Record 클래스에서 단순히 생성자를 호출하는 정적 팩토리 메서드는 불필요할 수 있습니다. Record의 기본 생성자가 이미 충분히 명확하므로 제거를 고려해보세요.

-    public static FeedIsSavedResult of(Long feedId, boolean isSaved) {
-        return new FeedIsSavedResult(feedId, isSaved);
-    }
src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIdResponse.java (1)

4-6: 정적 팩토리 메서드의 필요성 검토

단순한 record 클래스에서 생성자를 호출하는 정적 팩토리 메서드는 불필요할 수 있습니다. Record의 기본 생성자로도 충분히 명확합니다.

-    public static FeedIdResponse of(Long feedId) {
-        return new FeedIdResponse(feedId);
-    }
src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java (1)

3-3: Spring의 @transactional 사용을 고려해보세요.

Jakarta의 @Transactional 대신 Spring의 @Transactional을 사용하는 것을 권장합니다. Spring Boot 환경에서는 @org.springframework.transaction.annotation.Transactional이 더 일반적이고 많은 기능을 제공합니다.

-import jakarta.transaction.Transactional;
+import org.springframework.transaction.annotation.Transactional;
src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedUpdateRequest.java (1)

7-16: Record 필드 문서화 및 유효성 검증 고려

피드 업데이트 요청의 모든 필드가 nullable하여 부분 업데이트를 지원하는 것으로 보입니다. 다음 사항들을 고려해보세요:

  1. 각 필드가 null일 때의 동작에 대한 JavaDoc 추가
  2. remainImageUrls 필드명이 명확한지 검토 (기존 이미지 중 유지할 이미지 URL 목록인지 확인)
src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java (1)

58-71: 사용되지 않는 메서드가 존재합니다

handleFeedImageDelete 메서드가 정의되어 있지만 실제로 호출되지 않습니다. TODO 주석에 따르면 추후 이벤트 기반 또는 배치 삭제 방식으로 리팩토링 예정인 것으로 보입니다.

이미지 삭제 로직을 구현하거나 이슈로 등록하는 것을 도와드릴까요?

src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateAPITest.java (1)

175-178: 불필요한 collect() 호출이 있습니다

toList()는 이미 List를 반환하므로 추가적인 collect(Collectors.toList())는 필요하지 않습니다.

-        List<FeedTagJpaEntity> tagMappings = existingTags.stream()
-                .map(tag -> TestEntityFactory.createFeedTagMapping(feed, tag))
-                .collect(Collectors.toList());
+        List<FeedTagJpaEntity> tagMappings = existingTags.stream()
+                .map(tag -> TestEntityFactory.createFeedTagMapping(feed, tag))
+                .toList();
src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java (1)

31-31: TODO 주석을 해결해야 합니다.

이미 업로드된 이미지 삭제 방식에 대한 논의가 필요하다는 TODO가 있습니다. 예외 발생 시 리소스 정리 전략을 명확히 하는 것이 중요합니다.

S3 이미지 삭제 전략에 대한 구현을 도와드릴까요?

src/main/java/konkuk/thip/feed/domain/Feed.java (1)

75-81: convertToContentList에서 mutable list 반환 주의

convertToContentList 메소드가 null인 경우 mutable한 빈 ArrayList를 반환합니다. 이는 외부에서 의도치 않게 리스트를 수정할 수 있는 위험이 있습니다.

불변성을 보장하기 위해 다음과 같은 방법을 고려해보세요:

private static List<Content> convertToContentList(List<String> imageUrls) {
-    if (imageUrls == null) return new ArrayList<>();
+    if (imageUrls == null) return Collections.emptyList();
    return imageUrls.stream()
            .filter(url -> url != null && !url.isBlank())
            .map(url -> Content.builder().contentUrl(url).build())
            .collect(Collectors.toList());
}

또는 Feed 클래스 내부에서만 수정이 필요하다면, 현재 구현을 유지하되 주석으로 의도를 명시하는 것도 좋습니다.

📜 Review details

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

📥 Commits

Reviewing files that changed from the base of the PR and between cb2f1a4 and 7e8ecbf.

📒 Files selected for processing (40)
  • src/main/java/konkuk/thip/book/application/service/BookSavedService.java (1 hunks)
  • src/main/java/konkuk/thip/common/exception/code/ErrorCode.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java (2 hunks)
  • src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java (0 hunks)
  • src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedUpdateRequest.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedCreateResponse.java (0 hunks)
  • src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIdResponse.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsSavedResponse.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java (2 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/mapper/FeedMapper.java (2 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java (4 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/port/in/FeedSavedUseCase.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/port/in/FeedUpdateUseCase.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/port/in/dto/DummyResult.java (0 hunks)
  • src/main/java/konkuk/thip/feed/application/port/in/dto/FeedCreateCommand.java (0 hunks)
  • src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedCommand.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedResult.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/port/in/dto/FeedUpdateCommand.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java (2 hunks)
  • src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java (1 hunks)
  • src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java (1 hunks)
  • src/main/java/konkuk/thip/feed/domain/Feed.java (3 hunks)
  • src/main/java/konkuk/thip/feed/domain/SavedFeeds.java (1 hunks)
  • src/main/java/konkuk/thip/post/adapter/out/jpa/PostJpaEntity.java (1 hunks)
  • src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java (0 hunks)
  • src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java (0 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java (3 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java (3 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java (1 hunks)
  • src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java (1 hunks)
  • src/test/java/konkuk/thip/common/util/TestEntityFactory.java (2 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java (4 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateAPITest.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java (1 hunks)
💤 Files with no reviewable changes (6)
  • src/main/java/konkuk/thip/feed/application/port/in/dto/FeedCreateCommand.java
  • src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java
  • src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedCreateResponse.java
  • src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java
  • src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java
  • src/main/java/konkuk/thip/feed/application/port/in/dto/DummyResult.java
🧰 Additional context used
🧠 Learnings (7)
src/main/java/konkuk/thip/feed/application/port/in/FeedUpdateUseCase.java (1)

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java (2)

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

Learnt from: seongjunnoh
PR: #36
File: src/main/java/konkuk/thip/user/adapter/out/persistence/UserJpaRepository.java:7-7
Timestamp: 2025-06-29T09:47:31.299Z
Learning: Spring Data JPA에서 findBy{FieldName} 패턴의 메서드는 명시적 선언 없이 자동으로 생성되며, Optional 반환 타입을 사용하는 것이 null 안전성을 위해 권장됩니다.

src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java (1)

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java (1)

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (1)

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java (1)

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java (1)

Learnt from: seongjunnoh
PR: #36
File: src/main/java/konkuk/thip/user/adapter/out/persistence/UserJpaRepository.java:7-7
Timestamp: 2025-06-29T09:47:31.299Z
Learning: Spring Data JPA에서 findBy{FieldName} 패턴의 메서드는 명시적 선언 없이 자동으로 생성되며, Optional 반환 타입을 사용하는 것이 null 안전성을 위해 권장됩니다.

🧬 Code Graph Analysis (4)
src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (1)
src/main/java/konkuk/thip/common/exception/EntityNotFoundException.java (1)
  • EntityNotFoundException (5-10)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateAPITest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (27-232)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (27-232)
src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java (1)
src/test/java/konkuk/thip/common/util/TestEntityFactory.java (1)
  • TestEntityFactory (27-232)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (60)
src/main/java/konkuk/thip/book/application/service/BookSavedService.java (1)

64-64: 메서드 명 변경이 적절합니다.

findSavedBooksByUserId로 변경하여 저장된 책 조회와 저장된 피드 조회를 명확히 구분했습니다. 코드의 가독성과 일관성이 향상되었습니다.

src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java (2)

6-6: 도메인 클래스 import 추가가 적절합니다.

Feed 도메인 클래스를 import하여 updateFrom 메서드에서 사용할 수 있도록 했습니다.


45-51: updateFrom 메서드 구현이 올바릅니다.

피드 업데이트 기능을 위한 JPA 엔티티 업데이트 메서드가 적절히 구현되었습니다. 변경 가능한 모든 필드(content, isPublic, reportCount, likeCount, commentCount)를 도메인 객체로부터 업데이트하고 있습니다.

src/main/java/konkuk/thip/feed/application/port/in/FeedUpdateUseCase.java (1)

1-7: UseCase 인터페이스 설계가 적절합니다.

단일 책임 원칙을 따르는 깔끔한 인터페이스입니다. THIP 프로젝트의 CQRS Port 분리 컨벤션에 맞게 구현되었고, 메서드 시그니처도 명확합니다.

src/main/java/konkuk/thip/feed/application/port/in/FeedSavedUseCase.java (1)

1-8: UseCase 인터페이스가 기존 패턴과 일관성 있게 구현되었습니다.

기존 BookSavedUseCase와 유사한 패턴으로 구현되어 일관성이 좋습니다. 메서드명 changeSavedFeed가 기능을 명확히 표현하고 있고, 입출력 타입도 적절합니다.

src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java (1)

7-8: 피드 저장/삭제 메서드 추가가 적절합니다

기존 북 저장 기능과 일관된 패턴으로 피드 저장/삭제 메서드가 추가되었습니다. 메서드 시그니처와 네이밍 컨벤션이 적절하며, CQRS 패턴을 올바르게 따르고 있습니다.

src/main/java/konkuk/thip/feed/application/port/in/dto/FeedIsSavedResult.java (1)

3-6: Record 클래스 설계가 적절합니다

피드 저장 상태 변경 결과를 캡슐화하는 record 클래스가 적절하게 설계되었습니다. 필드명과 타입이 명확하며 불변 데이터 구조로 안전합니다.

src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java (2)

3-3: 필요한 import 추가가 적절합니다

피드 수정 기능을 위해 필요한 import들이 추가되었습니다.

Also applies to: 6-8


12-14: 피드 태그 삭제 메서드 구현이 적절합니다

피드 수정 시 기존 태그들을 삭제하기 위한 메서드가 적절하게 구현되었습니다. @Modifying 어노테이션과 JPQL 쿼리 사용이 올바르며, 파라미터 바인딩도 정확합니다.

트랜잭션 처리 확인 권장

@Modifying 쿼리는 트랜잭션 내에서 실행되어야 합니다. 이 메서드를 사용하는 서비스 레이어에서 @Transactional 어노테이션이 적절히 적용되어 있는지 확인해주세요.

src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIdResponse.java (1)

3-3: 간단하고 명확한 Response DTO

피드 ID만을 반환하는 간단하고 명확한 Response DTO가 적절하게 설계되었습니다.

src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java (2)

5-6: 필요한 import 추가가 적절합니다

새로운 쿼리 메서드를 위한 필요한 import들이 추가되었습니다.

Also applies to: 8-8


14-15: JPQL 필드명 검증 완료: postId가 올바르게 매핑됨

PostJpaEntity에서 ID 필드가

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id", nullable = false)
private Long postId;

로 선언된 것을 확인했습니다. 따라서 TagJpaRepository의 JPQL에서 ft.feedJpaEntity.postId를 참조하는 부분이 적절합니다.

  • 파일: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/Tag/TagJpaRepository.java
    메서드: findAllByFeedId (Lines 14-15)
src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java (2)

4-4: Import 추가가 적절합니다.

SavedFeeds 도메인 객체 import가 새로운 메서드를 위해 적절히 추가되었습니다.


8-9: 메서드명 개선 및 일관성 확보가 잘 되었습니다.

메서드명을 findByUserId에서 findSavedBooksByUserId로 변경한 것은 명확성을 크게 향상시켰으며, 새로 추가된 findSavedFeedsByUserId와 일관된 네이밍 패턴을 유지하고 있습니다. CQRS 컨벤션에도 잘 부합합니다.

src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedIsSavedResponse.java (1)

5-12: 깔끔하고 효과적인 응답 객체입니다.

Record 사용과 정적 팩토리 메서드를 통한 변환 로직이 잘 구현되었습니다. 불변성과 간결성을 모두 확보했으며, API 응답에 필요한 최소한의 정보만 포함하여 적절합니다.

src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java (1)

25-44: 잘 구조화된 비즈니스 로직입니다.

단계별 주석과 명확한 로직 흐름이 훌륭합니다. 피드 검증 → 저장된 피드 조회 → 조건부 저장/삭제의 흐름이 명확하고, 도메인 객체를 통한 validation 처리도 적절합니다. 포트 분리를 통한 역할 분담도 잘 되어 있습니다.

src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java (1)

10-12: 정적 팩토리 메서드가 잘 구현되었습니다.

파라미터를 받아서 Command 객체로 변환하는 로직이 명확하고 적절합니다. 다만 위에서 제안한 필드명 변경 시 이 부분도 함께 수정이 필요합니다.

src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedUpdateRequest.java (1)

17-26: toCommand 메서드 구현 확인

웹 계층 DTO를 애플리케이션 계층 커맨드로 변환하는 로직이 단순하고 명확합니다. 매개변수 순서와 FeedUpdateCommand 생성자가 일치하는지 확인이 필요합니다.

src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (2)

10-10: 조회 메서드 구현 확인

사용자별 저장된 피드 목록을 조회하는 메서드입니다. 메서드명과 반환 타입이 적절합니다.


9-9: Derived 삭제 쿼리에 @Modifying 어노테이션 불필요

Spring Data JPA의 네이밍 기반 쿼리 메서드(deleteBy…)는 별도의 @Modifying 없이도 자동으로 삭제 쿼리로 처리됩니다.
실제 코드베이스에서도 SavedBookJpaRepository 등 여러 곳에서 void deleteBy… 메서드를 @Modifying 없이 사용하고 있어 일관성을 해치지 않습니다.

  • SavedFeedJpaRepositoryvoid deleteByUserJpaEntity_UserIdAndFeedJpaEntity_PostId(...) 메서드는 그대로 두셔도 됩니다.
  • Spring Data JPA가 deleteBy 키워드를 인식해 내부적으로 수정 쿼리로 처리합니다.

따라서 추가적인 수정은 필요하지 않습니다.

src/main/java/konkuk/thip/feed/application/port/out/FeedCommandPort.java (3)

13-14: 피드 업데이트 메서드 추가 승인

피드 업데이트 기능을 위한 메서드가 적절히 추가되었습니다. 반환 타입이 Long인 것은 업데이트된 엔티티의 ID를 반환하는 것으로 보입니다.


14-14: CQRS 컨벤션 준수 확인

retrieved_learnings에 따르면 THIP 프로젝트에서는 CommandPort에 findByXXX 메서드를 추가하는 컨벤션을 따릅니다. findById 메서드 추가가 이 컨벤션에 부합합니다.


15-18: getByIdOrThrow default 메서드 구현 확인

EntityNotFoundException과 FEED_NOT_FOUND 에러 코드를 사용한 예외 처리가 적절합니다. 코드 재사용성을 높이는 좋은 접근입니다.

src/main/java/konkuk/thip/feed/adapter/out/mapper/FeedMapper.java (2)

9-9: Lombok 어노테이션 추가 및 의존성 주입 개선

@requiredargsconstructor 어노테이션을 통해 ContentMapper 의존성이 깔끔하게 주입됩니다. 생성자 기반 의존성 주입으로 불변성도 보장됩니다.

Also applies to: 16-16, 19-19


48-50: contentList null 안전성 검토 필요
FeedJpaEntity.getContentList()가 null을 반환할 가능성이 있는지 수동으로 확인하고, 필요 시 빈 리스트 반환 처리를 추가하세요.

  • 검토 대상:
    • src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java
      • contentList 필드 초기화 여부
      • getContentList() 구현부에서 null 반환 방지 로직
src/test/java/konkuk/thip/feed/adapter/in/web/FeedCreateControllerTest.java (2)

21-21: 에러 코드 변경 승인

INVALID_FEED_CREATE에서 INVALID_FEED_COMMAND로 변경하여 더 일반적이고 재사용 가능한 에러 코드로 개선되었습니다. 피드 생성뿐만 아니라 다른 피드 명령에도 사용할 수 있는 좋은 변경입니다.

Also applies to: 58-58, 158-158


105-106: 테스트 클래스명 개선

CategoryTagValidation에서 TagValidation으로 변경하여 카테고리 관련 로직 제거에 맞춰 더 명확한 테스트 구조가 되었습니다.

src/main/java/konkuk/thip/feed/domain/SavedFeeds.java (2)

18-24: 생성자 구현이 적절합니다

중복 검증 로직과 불변성 보장을 위한 unmodifiableSet 사용이 적절합니다.


26-38: 검증 메서드 구현이 명확하고 적절합니다

저장/삭제 가능 여부를 검증하는 로직이 비즈니스 규칙을 잘 반영하고 있습니다.

src/main/java/konkuk/thip/feed/application/service/FeedUpdateService.java (2)

22-39: 피드 업데이트 로직이 체계적으로 구현되었습니다

검증 → 조회 → 상태 변경 → 업데이트의 흐름이 명확하고 적절합니다.


41-56: 부분 업데이트 패턴이 올바르게 구현되었습니다

null 체크를 통한 선택적 업데이트 구현이 적절합니다.

src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedCommandPersistenceAdapter.java (4)

41-48: 피드 조회 메서드가 적절히 구현되었습니다

JPA 엔티티를 도메인 엔티티로 매핑하는 로직이 명확합니다.


73-82: 업데이트 로직이 명확하게 구현되었습니다

기존 엔티티를 조회하여 업데이트하는 패턴이 적절합니다.


96-99: 컨텐츠 업데이트 로직이 올바릅니다

clear()를 통한 기존 연관관계 제거 후 새로운 컨텐츠 추가 방식이 JPA의 orphan removal과 잘 동작합니다.


118-121: 태그 업데이트 전략이 적절합니다

다대다 관계에서 전체 삭제 후 재생성 방식은 복잡한 병합 로직보다 명확하고 안전합니다.

src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateAPITest.java (3)

73-84: 테스트 데이터 설정이 체계적입니다

TestEntityFactory를 활용한 테스트 데이터 생성이 일관성 있고 재사용 가능합니다.


87-119: 태그 업데이트 테스트가 명확합니다

3개에서 2개로 태그 수정 시나리오를 잘 검증하고 있습니다.


122-157: 이미지 선택적 유지 테스트가 철저합니다

엔티티 레벨과 리포지토리 레벨 모두에서 이미지 유지를 검증하는 점이 좋습니다.

src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java (2)

52-63: 피드 저장 메서드가 일관성 있게 구현되었습니다

기존 saveBook 메서드와 동일한 패턴을 따라 구현되어 코드의 일관성이 유지됩니다.


65-68: 피드 삭제 메서드가 적절히 구현되었습니다

Spring Data JPA 명명 규칙을 따라 복합 키로 삭제하는 구현이 올바릅니다.

src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java (6)

62-73: 테스트 데이터 설정이 잘 구성되어 있습니다.

setUp 메서드에서 필요한 엔티티들을 체계적으로 생성하고 있어 테스트 실행에 필요한 기본 데이터가 올바르게 준비되고 있습니다.


75-98: 첫 번째 저장 테스트가 올바르게 구현되었습니다.

API 호출, 응답 검증, 데이터베이스 상태 확인까지 포함하는 포괄적인 테스트입니다. 실제 저장 여부를 repository를 통해 검증하는 것이 좋습니다.


100-116: 중복 저장 시 예외 처리 테스트가 적절합니다.

이미 저장된 피드를 다시 저장하려 할 때 400 에러와 적절한 에러 코드를 반환하는지 검증하고 있습니다.


118-141: 저장된 피드 삭제 테스트가 잘 구현되었습니다.

삭제 후 데이터베이스에서 실제로 제거되었는지까지 확인하는 완전한 테스트입니다.


143-158: 저장하지 않은 피드 삭제 시 예외 처리가 적절합니다.

비즈니스 로직에 맞게 저장하지 않은 피드를 삭제하려 할 때 적절한 에러를 반환하는지 검증합니다.


160-177: 존재하지 않는 피드에 대한 예외 처리가 적절합니다.

404 에러와 FEED_NOT_FOUND 에러 코드를 검증하는 것이 올바른 접근입니다.

src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java (2)

35-36: 카테고리 검증 제거가 의도된 변경입니다.

AI 요약에 따르면 카테고리 관련 로직이 제거되었고, 현재는 태그와 이미지 개수만 검증하고 있습니다. 이는 도메인 단순화의 일환으로 보입니다.


42-64: 이미지 업로드 예외 처리가 개선되었습니다.

imageUrls 변수를 try 블록 외부에서 초기화하고, 예외 발생 시 이미 업로드된 이미지를 정리하는 로직이 추가되어 리소스 누수를 방지합니다.

src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java (3)

31-36: 피드 생성 엔드포인트가 적절히 구현되었습니다.

MultipartFile을 통한 이미지 업로드와 JSON 요청을 분리하여 처리하는 것이 올바른 접근입니다. @Valid 어노테이션으로 요청 검증도 적용되어 있습니다.


38-46: 피드 수정 엔드포인트가 적절합니다.

PATCH 메서드 사용과 경로 변수를 통한 feedId 전달이 REST API 설계 원칙에 맞습니다.


48-54: 피드 저장 상태 변경 엔드포인트가 잘 설계되었습니다.

POST 메서드를 사용하여 상태 변경을 처리하는 것이 적절하고, 요청 구조도 명확합니다.

src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java (1)

45-57: 메서드명 변경이 명확성을 향상시켰습니다.

findByUserId에서 findSavedBooksByUserId로 변경하여 메서드의 목적이 더 명확해졌습니다.

src/test/java/konkuk/thip/common/util/TestEntityFactory.java (4)

179-191: 기본 피드 엔티티 팩토리 메서드가 잘 구현되었습니다.

기본값들이 적절히 설정되어 있고, 필요한 연관관계(user, book)를 매개변수로 받아 유연성을 제공합니다.


193-198: 피드-태그 매핑 팩토리 메서드가 적절합니다.

연관관계 엔티티 생성을 위한 간단하고 명확한 팩토리 메서드입니다.


201-223: 이미지 포함 피드 팩토리 메서드가 잘 설계되었습니다.

이미지 URL 리스트를 받아 ContentJpaEntity 리스트를 생성하고 피드에 연결하는 로직이 올바르게 구현되어 있습니다.


226-231: 저장된 피드 팩토리 메서드가 적절합니다.

사용자와 피드 간의 저장 관계를 나타내는 엔티티를 생성하는 간단한 팩토리 메서드입니다.

src/main/java/konkuk/thip/common/exception/code/ErrorCode.java (1)

133-137: 에러 코드 추가 및 변경사항이 적절합니다

피드 관련 기능 확장에 맞춰 에러 코드가 잘 정리되었습니다:

  • INVALID_FEED_COMMAND로 이름 변경은 생성/수정 모두를 포함하므로 적절합니다
  • 새로운 에러 코드들이 HTTP 상태 코드와 잘 매핑되어 있습니다
  • 에러 메시지가 명확하고 일관성 있게 작성되었습니다
src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java (1)

1-204: 테스트 구조와 커버리지가 우수합니다

피드 수정 API의 다양한 검증 시나리오를 체계적으로 테스트하고 있습니다:

  • 권한 검증 (403 Forbidden)
  • 존재하지 않는 피드 (404 Not Found)
  • 태그 유효성 검증
  • 이미지 개수 및 URL 검증

테스트 구조가 @nested 클래스로 잘 정리되어 있어 가독성이 좋습니다.

src/main/java/konkuk/thip/feed/domain/Feed.java (2)

45-56: equals/hashCode 구현이 적절합니다

도메인 엔티티의 동등성 비교를 id 기반으로 구현한 것이 적절합니다. JPA 환경에서 엔티티의 동일성을 보장하는 표준적인 방법입니다.


106-138: 피드 수정 관련 메소드들이 잘 구현되었습니다

새로 추가된 메소드들이 명확한 책임을 가지고 있습니다:

  • validateCreator: 권한 검증
  • updateContent, updateVisibility, updateTags, updateImages: 개별 필드 업데이트
  • validateOwnsImages: 이미지 소유권 검증

특히 validateOwnsImages의 구현이 안전하게 잘 되어있습니다.

Copy link
Contributor

@buzz0331 buzz0331 left a comment

Choose a reason for hiding this comment

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

수고하셨습니다~ 리뷰 몇가지 남겼는데 확인 부탁드립니다!

Comment on lines 60 to 91
@Override
public SavedFeeds findSavedFeedsByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));

List<SavedFeedJpaEntity> savedFeedEntities =
savedFeedJpaRepository.findByUserJpaEntity_UserId(user.getUserId());

List<Long> feedIds = savedFeedEntities.stream()
.map(entity -> entity.getFeedJpaEntity().getPostId())
.toList();

// 한 번의 쿼리로 Feed ID에 대한 Tag 전체 조회
List<Object[]> results = feedTagJpaRepository.findFeedIdAndTagsByFeedIds(feedIds);

// 결과 데이터를 feedId → List<Tag> 형태로 그룹핑
Map<Long, List<TagJpaEntity>> feedTagsMap = results.stream()
.collect(Collectors.groupingBy(
row -> (Long) row[0],
Collectors.mapping(row -> (TagJpaEntity) row[1], Collectors.toList())
));

List<Feed> feeds = savedFeedEntities.stream()
.map(entity -> {
FeedJpaEntity feedJpa = entity.getFeedJpaEntity();
List<TagJpaEntity> tags = feedTagsMap.getOrDefault(feedJpa.getPostId(), List.of());
return feedMapper.toDomainEntity(feedJpa, tags);
})
.toList();

return new SavedFeeds(feeds);
}
Copy link
Contributor

@buzz0331 buzz0331 Jul 22, 2025

Choose a reason for hiding this comment

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

오우 배치쿼리로 한번에 가져오셨네요! 성능적으로 고민하신게 보입니다!
다만, 지금 쿼리에서 List<Object[]>를 반환함으로써 가독성이 많이 떨어지는 것 같습니다.
FeedId와 TagList는 충분히 재사용될만한 반환이라 생각해서 다음과 같이 Projection을 활용해보면 어떨까요?

public interface FeedIdAndTagProjection {
    Long getFeedId();
    TagJpaEntity getTagJpaEntity();
}
@Query("""
    SELECT ft.feedJpaEntity.postId as feedId, ft.tagJpaEntity as tagJpaEntity
    FROM FeedTagJpaEntity ft
    WHERE ft.feedJpaEntity.postId IN :feedIds
""")
List<FeedIdAndTagProjection> findFeedIdAndTagsByFeedIds(@Param("feedIds") List<Long> feedIds);

그룹핑 사용시

Map<Long, List<TagJpaEntity>> feedTagsMap = results.stream()
        .collect(Collectors.groupingBy(
                FeedIdAndTagProjection::getFeedId,
                Collectors.mapping(FeedIdAndTagProjection::getTagJpaEntity, Collectors.toList())
        ));

Copy link
Member Author

Choose a reason for hiding this comment

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

좋은것같습니다!! 수정해보겠습니다!

Comment on lines +23 to +44
@Override
@Transactional
public FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand feedIsSavedCommand) {

// 1. 피드 검증 및 조회
Feed feed = feedCommandPort.getByIdOrThrow(feedIsSavedCommand.feedId());

// 2. 유저가 저장한 피드 목록 조회
SavedFeeds savedFeeds = savedQueryPort.findSavedFeedsByUserId(feedIsSavedCommand.userId());

if (feedIsSavedCommand.isSaved()) {
// 저장 요청 시 이미 저장되어 있으면 예외 발생
savedFeeds.validateNotAlreadySaved(feed);
savedCommandPort.saveFeed(feedIsSavedCommand.userId(), feed.getId());
} else {
// 삭제 요청 시 저장되어 있지 않으면 예외 발생
savedFeeds.validateCanDelete(feed);
savedCommandPort.deleteFeed(feedIsSavedCommand.userId(), feed.getId());
}

return FeedIsSavedResult.of(feed.getId(), feedIsSavedCommand.isSaved());
}
Copy link
Contributor

Choose a reason for hiding this comment

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

LGTM 👍🏻 👍🏻 👍🏻 코드 깔쌈하네여

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

🧹 Nitpick comments (1)
src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (1)

16-17: 페이지네이션 고려 및 스키마 검증이 필요합니다.

네이티브 쿼리 사용이 적절하며 불필요한 조인을 피하는 좋은 접근입니다. 다만 다음 사항들을 고려해주세요:

  1. 사용자가 저장한 피드가 많을 경우 성능 이슈가 있을 수 있으니 향후 페이지네이션 도입을 고려해보세요.
  2. 테이블명과 컬럼명이 실제 스키마와 일치하는지 확인해주세요.
📜 Review details

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

📥 Commits

Reviewing files that changed from the base of the PR and between bd081c3 and e8779bc.

📒 Files selected for processing (6)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java (3 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java (3 hunks)
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (1 hunks)
  • src/main/java/konkuk/thip/saved/application/port/out/dto/FeedIdAndTagProjection.java (1 hunks)
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java (1 hunks)
🧠 Learnings (1)
src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (4)

Learnt from: buzz0331
PR: #78
File: src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java:3-3
Timestamp: 2025-07-14T18:22:56.538Z
Learning: THIP 프로젝트에서는 Query API(조회 API)에 한해서는 application 계층에서 adapter.in.web.response 패키지의 response DTO를 직접 참조하는 것을 허용함. 이는 CQRS 아키텍처에서 읽기 전용 작업의 효율성을 위한 팀 컨벤션임.

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

Learnt from: buzz0331
PR: #75
File: src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java:50-83
Timestamp: 2025-07-14T14:19:38.796Z
Learning: Vote와 VoteItem 엔티티는 자주 함께 사용되므로, N+1 문제를 방지하기 위해 양방향 매핑과 fetch join을 고려하는 것이 좋습니다. 특히 기록장 조회 API 등에서도 함께 사용될 가능성이 높습니다.

Learnt from: seongjunnoh
PR: #36
File: src/main/java/konkuk/thip/user/adapter/out/persistence/UserJpaRepository.java:7-7
Timestamp: 2025-06-29T09:47:31.299Z
Learning: Spring Data JPA에서 findBy{FieldName} 패턴의 메서드는 명시적 선언 없이 자동으로 생성되며, Optional 반환 타입을 사용하는 것이 null 안전성을 위해 권장됩니다.

✅ Files skipped from review due to trivial changes (1)
  • src/main/java/konkuk/thip/saved/application/port/out/dto/FeedIdAndTagProjection.java
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedTag/FeedTagJpaRepository.java
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java
  • src/test/java/konkuk/thip/feed/adapter/in/web/FeedChangeSavedAPITest.java
  • src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java
🧰 Additional context used
🧠 Learnings (1)
src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (4)

Learnt from: buzz0331
PR: #78
File: src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java:3-3
Timestamp: 2025-07-14T18:22:56.538Z
Learning: THIP 프로젝트에서는 Query API(조회 API)에 한해서는 application 계층에서 adapter.in.web.response 패키지의 response DTO를 직접 참조하는 것을 허용함. 이는 CQRS 아키텍처에서 읽기 전용 작업의 효율성을 위한 팀 컨벤션임.

Learnt from: seongjunnoh
PR: #43
File: src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java:0-0
Timestamp: 2025-07-03T03:05:05.031Z
Learning: THIP 프로젝트에서는 CQRS Port 분리 시 다음 컨벤션을 따름: CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가하고, QueryPort에는 조회 API의 response에 해당하는 데이터들을 DB로부터 조회하는 메서드를 추가함.

Learnt from: buzz0331
PR: #75
File: src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java:50-83
Timestamp: 2025-07-14T14:19:38.796Z
Learning: Vote와 VoteItem 엔티티는 자주 함께 사용되므로, N+1 문제를 방지하기 위해 양방향 매핑과 fetch join을 고려하는 것이 좋습니다. 특히 기록장 조회 API 등에서도 함께 사용될 가능성이 높습니다.

Learnt from: seongjunnoh
PR: #36
File: src/main/java/konkuk/thip/user/adapter/out/persistence/UserJpaRepository.java:7-7
Timestamp: 2025-06-29T09:47:31.299Z
Learning: Spring Data JPA에서 findBy{FieldName} 패턴의 메서드는 명시적 선언 없이 자동으로 생성되며, Optional 반환 타입을 사용하는 것이 null 안전성을 위해 권장됩니다.

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (2)
src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (2)

5-9: 필요한 임포트가 올바르게 추가되었습니다.

네이티브 쿼리 메서드 구현을 위한 적절한 어노테이션들이 임포트되었습니다.


12-14: 데이터베이스 엔티티 매핑 수동 확인 필요
네이티브 쿼리의 post_id 컬럼명이 실제 JPA 엔티티의 필드 매핑과 일치하는지 수동으로 검증해주세요.

  • src/main/java/konkuk/thip/saved/adapter/out/jpa/SavedFeedJpaEntity.java 파일을 열어
    • 클래스 레벨의 @Table(name = "saved_feeds") 어노테이션
    • 해당 엔티티 내에 피드 ID를 나타내는 필드(feedId 또는 postId)와
      • @Column(name = "…") 어노테이션이 있다면 post_id로 매핑되어 있는지
      • 별도 어노테이션이 없다면 JPA 기본 naming 전략이 DB 컬럼명과 일치하는지
  • 위 항목 중 하나라도 맞지 않으면 네이티브 쿼리의 컬럼명을 엔티티에 맞춰 수정해주세요.

@hd0rable hd0rable requested a review from buzz0331 July 22, 2025 18:35
Copy link
Contributor

@buzz0331 buzz0331 left a comment

Choose a reason for hiding this comment

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

확인했습니다~ 수고하셨습니다!!

Copy link
Collaborator

@seongjunnoh seongjunnoh left a comment

Choose a reason for hiding this comment

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

고생하셨습니다! 논의하고 싶은 부분이 있어서 리뷰 달아놨습니다. 확인해주시면 감사하겠습니다!

Comment on lines +30 to +31
// 2. 유저가 저장한 피드 목록 조회
SavedFeeds savedFeeds = savedQueryPort.findSavedFeedsByUserId(feedIsSavedCommand.userId());
Copy link
Collaborator

Choose a reason for hiding this comment

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

오 Port 에서 바로 일급컬렉션을 반환하도록 하셨군요!
유저가 저장한 피드들의 목록을 활용한 도메인 로직을 해당 일급컬렉션 내부에서 수행하도록 강제할 수 있으니 좋은 것 같습니다!!

Comment on lines +14 to +18
Optional<Feed> findById(Long id);
default Feed getByIdOrThrow(Long id) {
return findById(id)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

LGTM

Comment on lines +13 to +40
@Getter
public class SavedFeeds {

private final Set<Feed> feeds;

public SavedFeeds(List<Feed> feeds) {
Set<Feed> feedSet = new HashSet<>(feeds);
if (feedSet.size() != feeds.size()) {
throw new InvalidStateException(DUPLICATED_FEEDS_IN_COLLECTION);
}
this.feeds = Collections.unmodifiableSet(feedSet);
}

// 중복 저장 검증
public void validateNotAlreadySaved(Feed feed) {
if (feeds.contains(feed)) {
throw new InvalidStateException(FEED_ALREADY_SAVED);
}
}

// 삭제 가능 여부 검증
public void validateCanDelete(Feed feed) {
if (!feeds.contains(feed)) {
throw new InvalidStateException(FEED_NOT_SAVED_CANNOT_DELETE);
}
}

}
Copy link
Collaborator

Choose a reason for hiding this comment

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

LGTM
일급 컬렉션에서 도메인 로직을 수행하니 서비스가 확실히 가벼워진 것 같아서 좋네요
일급 컬렉션 네이밍도 그냥 Feeds 가 아니라 SavedFeeds 로 해주셔서 '저장한 피드들' 이라는 의미가 훨씬 잘 드러나는 것 같습니다!

Comment on lines +61 to +91
@Override
public SavedFeeds findSavedFeedsByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));

List<SavedFeedJpaEntity> savedFeedEntities =
savedFeedJpaRepository.findAllByUserId(user.getUserId());

List<Long> feedIds = savedFeedEntities.stream()
.map(entity -> entity.getFeedJpaEntity().getPostId())
.toList();

// Projection 기반 조회
List<FeedIdAndTagProjection> results = feedTagJpaRepository.findFeedIdAndTagsByFeedIds(feedIds);

Map<Long, List<TagJpaEntity>> feedTagsMap = results.stream()
.collect(Collectors.groupingBy(
FeedIdAndTagProjection::getFeedId,
Collectors.mapping(FeedIdAndTagProjection::getTagJpaEntity, Collectors.toList())
));

List<Feed> feeds = savedFeedEntities.stream()
.map(entity -> {
FeedJpaEntity feedJpa = entity.getFeedJpaEntity();
List<TagJpaEntity> tags = feedTagsMap.getOrDefault(feedJpa.getPostId(), List.of());
return feedMapper.toDomainEntity(feedJpa, tags);
})
.toList();

return new SavedFeeds(feeds);
}
Copy link
Collaborator

@seongjunnoh seongjunnoh Jul 23, 2025

Choose a reason for hiding this comment

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

현재 findSavedFeedsByUserId 메서드는 아래와 같이 동작한다고 이해하였습니다.

  1. savedFeedJpaRepository.findAllByUserId 메서드를 통해 native query 로 연관된 jpa entity 와의 join 없이 List 만을 조회
    -> 이때 1차캐시에는 SavedFeedJpaEntity 만 올라가고, 이와 연관된 FeedJpaEntiy, UserJpaEntity 는 JPA 프록시 상태로 존재
    -> 프록시는 내부에 해당 연관 엔티티의 ID 만 보관

  2. 이후 List feedIds 를 구성하기 위해 entity.getFeedJpaEntity().getPostId() 에 접근할 때에는 entity와 연관된 프록시의 PK 값에 접근하므로 추가적인 sql 쿼리는 발생하지 X

  3. 하지만

List<Feed> feeds = savedFeedEntities.stream()
                .map(entity -> {
                    FeedJpaEntity feedJpa = entity.getFeedJpaEntity();
                    List<TagJpaEntity> tags = feedTagsMap.getOrDefault(feedJpa.getPostId(), List.of());
                    return feedMapper.toDomainEntity(feedJpa, tags);
                })
                .toList();

위 코드에서 feedMapper.toDomainEntity() 메서드의 동작과정에서 프록시인 feedJpa의 ID가 아닌 다른 필드에 접근하므로 추가적인 n 개의 sql 쿼리 발생

위처럼 FeedJpaEntity 에 접근하는 순간 n + 1 문제가 발생하기는 합니다.

  1. 그래도 SavedFeedJpaRepository 를 통해 SavedFeedJpaEntity 를 조회할 때는 join이 없는게 좋고, 추가적으로 정보가 필요할 경우에 n+1 문제가 발생하는 것은 어쩔 수 없다
  2. SavedFeedJpaRepository에서 SavedFeedJpaEntity를 조회할 때, native query 가 아니라 JPQL + Join Fetch 패턴을 사용하자
@Query("""
  SELECT s
    FROM SavedFeedJpaEntity s
  JOIN FETCH s.feedJpaEntity
   WHERE s.userJpaEntity.userId = :userId
""")
List<SavedFeedJpaEntity> findAllByUserIdWithFeedFetch(Long userId);

-> 위처럼 JPQL 이 제공해주는 JOIN FETCH 를 사용하면 연관된 FeedJpaEntity 까지 모두 1차 캐시에 등록할 수 있다

라는 결론이 있을 것 같습니다! 어떻게 생각하시나요?? @hd0rable @buzz0331

@Transactional
@AutoConfigureMockMvc(addFilters = false)
@DisplayName("[통합] 피드 저장 상태 변경 api 통합 테스트")
class FeedChangeSavedAPITest {
Copy link
Collaborator

Choose a reason for hiding this comment

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

LGTM

@hd0rable hd0rable merged commit cd2ee81 into develop Jul 23, 2025
2 checks passed
@hd0rable hd0rable deleted the feat/#92-feed-saved-state-update branch July 23, 2025 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[THIP2025-134] [feat] 피드 저장 상태 변경 api 개발

4 participants