Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c655f87
[feat] 관련 에러 코드 추가 (#86)
hd0rable Jul 21, 2025
75d0106
[feat] equals,hashcode 오버라이딩 (#86)
hd0rable Jul 21, 2025
a0d55eb
[feat] 피드 저장 상태 변경 컨트롤러 작성 (#86)
hd0rable Jul 21, 2025
97e9b2a
[feat] FeedIsSavedRequest dto 작성 (#86)
hd0rable Jul 21, 2025
2b1b30a
[feat] FeedIsSavedResponse dto 작성 (#86)
hd0rable Jul 21, 2025
84073a9
[feat] FeedIsSavedResult dto 작성 (#86)
hd0rable Jul 21, 2025
8fe3999
[feat] FeedIsSavedCommand dto 작성 (#86)
hd0rable Jul 21, 2025
559f40b
[feat] FeedSavedService.changeSavedFeed (#86)
hd0rable Jul 21, 2025
a1cf215
[feat] 피드 저장 상태 변경 유즈케이스 작성 (#86)
hd0rable Jul 21, 2025
cc24e32
[feat] 피드 저장 상태 변경 어댑터 코드 추가 (#86)
hd0rable Jul 21, 2025
179ebb2
[feat] 피드 저장 상태 변경 어댑터 코드 추가 (#86)
hd0rable Jul 21, 2025
280eeaf
[feat] SavedFeedJpaRepository 관련 메서드 추가 (#92)
hd0rable Jul 21, 2025
153b85a
[feat] SavedFeeds 사용자가 저장한 피드 일급 컬렉션 작성 (#92)
hd0rable Jul 21, 2025
e7d18a3
[feat] SavedQueryPersistenceAdapter.findSavedFeedsByUserId 작성 (#92)
hd0rable Jul 21, 2025
28a745f
[feat] SavedQueryPort 작성 (#92)
hd0rable Jul 21, 2025
9944eb0
[test] SavedFeed 팩토리 메서드 추가 (#92)
hd0rable Jul 21, 2025
8bab3a5
[test] 피드 저장 상태 변경 통합 테스트 코드 작성 (#92)
hd0rable Jul 21, 2025
4f0b485
[refactor] 피드 영속성 어댑터 조회 함수 리펙토링 (#92)
hd0rable Jul 21, 2025
7e8ecbf
[remove] 더미 파일 삭제 (#92)
hd0rable Jul 21, 2025
b2489cd
[refactor] 배치쿼리로 n+1 문제 리팩토링 (#92)
hd0rable Jul 21, 2025
bd081c3
Merge remote-tracking branch 'origin/feat/#86-feed-update' into feat/…
hd0rable Jul 21, 2025
b81658f
Merge remote-tracking branch 'origin/develop' into feat/#92-feed-save…
hd0rable Jul 22, 2025
e6f9e21
[refactor] 배치쿼리 Projection 기반 조회하도록 리펙토링 (#92)
hd0rable Jul 22, 2025
e8779bc
[refactor] 불필요한 조인하지 않도록 네이티브 쿼리 추가 (#92)
hd0rable Jul 22, 2025
79219b1
Merge remote-tracking branch 'origin/develop' into feat/#92-feed-save…
hd0rable Jul 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public BookIsSavedResult changeSavedBook(String isbn, boolean isSave, Long userI
}

// 유저가 저장한 책 목록 조회
SavedBooks savedBooks = savedQueryPort.findByUserId(userId);
SavedBooks savedBooks = savedQueryPort.findSavedBooksByUserId(userId);

if (isSave) {
// 저장 요청 시 이미 저장되어 있으면 예외 발생
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ public enum ErrorCode implements ResponseCode {
TAG_NOT_FOUND(HttpStatus.NOT_FOUND, 160002, "존재하지 않는 TAG 입니다."),
INVALID_FEED_COMMAND(HttpStatus.BAD_REQUEST, 160003, "유효하지 않은 FEED 생성/수정 요청 입니다."),
FEED_UPDATE_FORBIDDEN(HttpStatus.FORBIDDEN, 160004, "피드 수정 권한이 없습니다."),
DUPLICATED_FEEDS_IN_COLLECTION(HttpStatus.INTERNAL_SERVER_ERROR, 160005, "중복된 피드가 존재합니다."),
FEED_ALREADY_SAVED(HttpStatus.BAD_REQUEST, 160006, "사용자가 이미 저장한 피드입니다."),
FEED_NOT_SAVED_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 160007, "사용자가 저장하지 않은 피드는 저장삭제 할 수 없습니다."),

/**
* 170000 : Image File error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.feed.adapter.in.web.request.FeedCreateRequest;
import konkuk.thip.feed.adapter.in.web.request.FeedIsSavedRequest;
import konkuk.thip.feed.adapter.in.web.request.FeedUpdateRequest;
import konkuk.thip.feed.adapter.in.web.response.FeedIdResponse;
import konkuk.thip.feed.adapter.in.web.response.FeedIsSavedResponse;
import konkuk.thip.feed.application.port.in.FeedCreateUseCase;
import konkuk.thip.feed.application.port.in.FeedSavedUseCase;
import konkuk.thip.feed.application.port.in.FeedUpdateUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -22,6 +25,7 @@ public class FeedCommandController {

private final FeedCreateUseCase feedCreateUseCase;
private final FeedUpdateUseCase feedUpdateUseCase;
private final FeedSavedUseCase feedSavedUseCase;

//피드 작성
@PostMapping("/feeds")
Expand All @@ -40,4 +44,13 @@ public BaseResponse<FeedIdResponse> updateFeed(@RequestBody @Valid final FeedUpd
return BaseResponse.ok(FeedIdResponse.of(feedUpdateUseCase.updateFeed(request.toCommand(userId,feedId))));

}

//피드 저장상태 변경: true -> 저장, false -> 저장해제(삭제)
@PostMapping("/feeds/{feedId}/saved")
public BaseResponse<FeedIsSavedResponse> changeSavedFeed(@RequestBody final FeedIsSavedRequest request,
@PathVariable("feedId") final Long feedId,
@UserId final Long userId) {
return BaseResponse.ok(FeedIsSavedResponse.of(feedSavedUseCase.changeSavedFeed(FeedIsSavedRequest.toCommand(userId,feedId,request.type()))));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package konkuk.thip.feed.adapter.in.web.request;

import jakarta.validation.constraints.NotNull;
import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand;

public record FeedIsSavedRequest(
@NotNull(message = "type은 필수입니다.")
boolean type
) {
public static FeedIsSavedCommand toCommand(Long userId, Long feedId, Boolean type) {
return new FeedIsSavedCommand(userId, feedId, type);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package konkuk.thip.feed.adapter.in.web.response;

import konkuk.thip.feed.application.port.in.dto.FeedIsSavedResult;

public record FeedIsSavedResponse(
Long feedId,
boolean isSaved
) {
public static FeedIsSavedResponse of(FeedIsSavedResult feedIsSavedResult) {
return new FeedIsSavedResponse(feedIsSavedResult.feedId(), feedIsSavedResult.isSaved());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

import static konkuk.thip.common.exception.code.ErrorCode.*;

Expand All @@ -36,16 +37,17 @@ public class FeedCommandPersistenceAdapter implements FeedCommandPort {
private final FeedMapper feedMapper;
private final ContentMapper contentMapper;

@Override
public Feed findById(Long id) {
FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));

List<TagJpaEntity> tagJpaEntityList = tagJpaRepository.findAllByFeedId(feedJpaEntity.getPostId());

return feedMapper.toDomainEntity(feedJpaEntity, tagJpaEntityList);
@Override
public Optional<Feed> findById(Long id) {
return feedJpaRepository.findById(id)
.map(feedJpaEntity -> {
List<TagJpaEntity> tagJpaEntityList = tagJpaRepository.findAllByFeedId(feedJpaEntity.getPostId());
return feedMapper.toDomainEntity(feedJpaEntity, tagJpaEntityList);
});
}


@Override
public Long save(Feed feed) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@

import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity;
import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity;
import konkuk.thip.saved.application.port.out.dto.FeedIdAndTagProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface FeedTagJpaRepository extends JpaRepository<FeedTagJpaEntity, Long>{

@Modifying
@Query("DELETE FROM FeedTagJpaEntity ft WHERE ft.feedJpaEntity = :feedJpaEntity")
void deleteAllByFeedJpaEntity(@Param("feedJpaEntity") FeedJpaEntity feedJpaEntity);

@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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package konkuk.thip.feed.application.port.in;

import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand;
import konkuk.thip.feed.application.port.in.dto.FeedIsSavedResult;

public interface FeedSavedUseCase {
FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand feedIsSavedCommand);
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package konkuk.thip.feed.application.port.in.dto;

public record FeedIsSavedCommand(

Long userId,

Long feedId,

Boolean isSaved
)
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package konkuk.thip.feed.application.port.in.dto;

public record FeedIsSavedResult(
Long feedId,
boolean isSaved
)
{
public static FeedIsSavedResult of(Long feedId, boolean isSaved) {
return new FeedIsSavedResult(feedId, isSaved);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package konkuk.thip.feed.application.port.out;


import konkuk.thip.common.exception.EntityNotFoundException;
import konkuk.thip.feed.domain.Feed;

import java.util.Optional;

import static konkuk.thip.common.exception.code.ErrorCode.FEED_NOT_FOUND;

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

Choose a reason for hiding this comment

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

LGTM

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package konkuk.thip.feed.application.service;

import jakarta.transaction.Transactional;
import konkuk.thip.feed.application.port.in.FeedSavedUseCase;
import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand;
import konkuk.thip.feed.application.port.in.dto.FeedIsSavedResult;
import konkuk.thip.feed.application.port.out.FeedCommandPort;
import konkuk.thip.feed.domain.Feed;
import konkuk.thip.feed.domain.SavedFeeds;
import konkuk.thip.saved.application.port.out.SavedCommandPort;
import konkuk.thip.saved.application.port.out.SavedQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FeedSavedService implements FeedSavedUseCase {

private final FeedCommandPort feedCommandPort;
private final SavedCommandPort savedCommandPort;
private final SavedQueryPort savedQueryPort;

@Override
@Transactional
public FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand feedIsSavedCommand) {

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

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

Choose a reason for hiding this comment

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

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


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());
}
Comment on lines +23 to +44
Copy link
Contributor

Choose a reason for hiding this comment

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

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

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public Long updateFeed(FeedUpdateCommand command) {
Feed.validateImageCount(command.remainImageUrls() != null ? command.remainImageUrls().size() : 0);

// 2. 피드 조회
Feed feed = feedCommandPort.findById(command.feedId());
Feed feed = feedCommandPort.getByIdOrThrow(command.feedId());

// 3. 도메인 내에서 내부 상태 변경 및 검증
applyPartialFeedUpdate(feed, command);
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/konkuk/thip/feed/domain/Feed.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -41,6 +42,19 @@ public class Feed extends BaseDomainEntity {
@Builder.Default
private List<Content> contentList = new ArrayList<>();

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Feed feed = (Feed) o;
return Objects.equals(id, feed.id);
}

@Override
public int hashCode() {
return Objects.hash(id);
}

public static Feed withoutId(String content, Long creatorId, Boolean isPublic, Long targetBookId,
List<String> tagValues, List<String> imageUrls) {

Expand Down
42 changes: 42 additions & 0 deletions src/main/java/konkuk/thip/feed/domain/SavedFeeds.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package konkuk.thip.feed.domain;

import konkuk.thip.common.exception.InvalidStateException;
import lombok.Getter;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import static konkuk.thip.common.exception.code.ErrorCode.*;

@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);
}
}

}
Comment on lines +13 to +40
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 로 해주셔서 '저장한 피드들' 이라는 의미가 훨씬 잘 드러나는 것 같습니다!



Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import konkuk.thip.book.adapter.out.jpa.BookJpaEntity;
import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository;
import konkuk.thip.common.exception.EntityNotFoundException;
import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity;
import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository;
import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity;
import konkuk.thip.saved.adapter.out.jpa.SavedFeedJpaEntity;
import konkuk.thip.saved.adapter.out.mapper.SavedBookMapper;
import konkuk.thip.saved.adapter.out.mapper.SavedFeedMapper;
import konkuk.thip.saved.adapter.out.persistence.repository.SavedBookJpaRepository;
Expand All @@ -22,6 +25,7 @@ public class SavedCommandPersistenceAdapter implements SavedCommandPort {

private final UserJpaRepository userJpaRepository;
private final BookJpaRepository bookJpaRepository;
private final FeedJpaRepository feedJpaRepository;
private final SavedBookJpaRepository savedBookJpaRepository;
private final SavedFeedJpaRepository savedFeedJpaRepository;
private final SavedBookMapper savedBookMapper;
Expand All @@ -40,10 +44,28 @@ public void saveBook(Long userId, Long bookId) {
savedBookJpaRepository.save(entity);
}


//삭제 전략 도입 전
@Override
public void deleteBook(Long userId, Long bookId) {
savedBookJpaRepository.deleteByUserJpaEntity_UserIdAndBookJpaEntity_BookId(userId, bookId);
}

@Override
public void saveFeed(Long userId, Long feedId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));
FeedJpaEntity feed = feedJpaRepository.findById(feedId)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
SavedFeedJpaEntity entity = SavedFeedJpaEntity.builder()
.userJpaEntity(user)
.feedJpaEntity(feed)
.build();
savedFeedJpaRepository.save(entity);
}

@Override
public void deleteFeed(Long userId, Long feedId) {
savedFeedJpaRepository.deleteByUserIdAndFeedId(userId, feedId);
}


}
Loading