-
Notifications
You must be signed in to change notification settings - Fork 0
[feat] 피드 저장 상태 변경 api 개발 #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Walkthrough이 변경은 피드의 저장 상태를 변경하는 API와 관련된 주요 도메인, 어댑터, 서비스, 예외 코드, 테스트를 추가 및 확장합니다. 피드 저장/해제 기능을 위한 서비스 로직, 컨트롤러, 포트, 도메인 객체, 예외 처리, 영속성 계층, 테스트 코드가 새로 도입되거나 보완되었습니다. Changes
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
Estimated code review effort4 (~80분) Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed 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)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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하여 부분 업데이트를 지원하는 것으로 보입니다. 다음 사항들을 고려해보세요:
- 각 필드가 null일 때의 동작에 대한 JavaDoc 추가
- 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
📒 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없이 사용하고 있어 일관성을 해치지 않습니다.
SavedFeedJpaRepository의void 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의 구현이 안전하게 잘 되어있습니다.
src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java
Show resolved
Hide resolved
src/main/java/konkuk/thip/feed/application/port/in/dto/FeedUpdateCommand.java
Show resolved
Hide resolved
src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java
Show resolved
Hide resolved
src/test/java/konkuk/thip/feed/adapter/in/web/FeedUpdateControllerTest.java
Show resolved
Hide resolved
buzz0331
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
수고하셨습니다~ 리뷰 몇가지 남겼는데 확인 부탁드립니다!
src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java
Show resolved
Hide resolved
| @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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오우 배치쿼리로 한번에 가져오셨네요! 성능적으로 고민하신게 보입니다!
다만, 지금 쿼리에서 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())
));There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋은것같습니다!! 수정해보겠습니다!
| @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()); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍🏻 👍🏻 👍🏻 코드 깔쌈하네여
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (1)
src/main/java/konkuk/thip/saved/adapter/out/persistence/repository/SavedFeedJpaRepository.java (1)
16-17: 페이지네이션 고려 및 스키마 검증이 필요합니다.네이티브 쿼리 사용이 적절하며 불필요한 조인을 피하는 좋은 접근입니다. 다만 다음 사항들을 고려해주세요:
- 사용자가 저장한 피드가 많을 경우 성능 이슈가 있을 수 있으니 향후 페이지네이션 도입을 고려해보세요.
- 테이블명과 컬럼명이 실제 스키마와 일치하는지 확인해주세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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 컬럼명과 일치하는지
- 위 항목 중 하나라도 맞지 않으면 네이티브 쿼리의 컬럼명을 엔티티에 맞춰 수정해주세요.
buzz0331
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
확인했습니다~ 수고하셨습니다!!
seongjunnoh
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다! 논의하고 싶은 부분이 있어서 리뷰 달아놨습니다. 확인해주시면 감사하겠습니다!
| // 2. 유저가 저장한 피드 목록 조회 | ||
| SavedFeeds savedFeeds = savedQueryPort.findSavedFeedsByUserId(feedIsSavedCommand.userId()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오 Port 에서 바로 일급컬렉션을 반환하도록 하셨군요!
유저가 저장한 피드들의 목록을 활용한 도메인 로직을 해당 일급컬렉션 내부에서 수행하도록 강제할 수 있으니 좋은 것 같습니다!!
| Optional<Feed> findById(Long id); | ||
| default Feed getByIdOrThrow(Long id) { | ||
| return findById(id) | ||
| .orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
| @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); | ||
| } | ||
| } | ||
|
|
||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
일급 컬렉션에서 도메인 로직을 수행하니 서비스가 확실히 가벼워진 것 같아서 좋네요
일급 컬렉션 네이밍도 그냥 Feeds 가 아니라 SavedFeeds 로 해주셔서 '저장한 피드들' 이라는 의미가 훨씬 잘 드러나는 것 같습니다!
| @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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재 findSavedFeedsByUserId 메서드는 아래와 같이 동작한다고 이해하였습니다.
-
savedFeedJpaRepository.findAllByUserId 메서드를 통해 native query 로 연관된 jpa entity 와의 join 없이 List 만을 조회
-> 이때 1차캐시에는 SavedFeedJpaEntity 만 올라가고, 이와 연관된 FeedJpaEntiy, UserJpaEntity 는 JPA 프록시 상태로 존재
-> 프록시는 내부에 해당 연관 엔티티의 ID 만 보관 -
이후 List feedIds 를 구성하기 위해 entity.getFeedJpaEntity().getPostId() 에 접근할 때에는 entity와 연관된 프록시의 PK 값에 접근하므로 추가적인 sql 쿼리는 발생하지 X
-
하지만
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 문제가 발생하기는 합니다.
- 그래도 SavedFeedJpaRepository 를 통해 SavedFeedJpaEntity 를 조회할 때는 join이 없는게 좋고, 추가적으로 정보가 필요할 경우에 n+1 문제가 발생하는 것은 어쩔 수 없다
- 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차 캐시에 등록할 수 있다
| @Transactional | ||
| @AutoConfigureMockMvc(addFilters = false) | ||
| @DisplayName("[통합] 피드 저장 상태 변경 api 통합 테스트") | ||
| class FeedChangeSavedAPITest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
#️⃣ 연관된 이슈
📝 작업 내용
📸 스크린샷
💬 리뷰 요구사항
📌 PR 진행 시 이러한 점들을 참고해 주세요
Summary by CodeRabbit
신규 기능
버그 수정
테스트
기타