Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ad142e8
[feat] 삭제용 댓글 댓글좋아요 연관관계 매핑 추가 (#160)
hd0rable Aug 8, 2025
db6bbd7
[feat] 피드 삭제 컨트롤러 작성 (#160)
hd0rable Aug 8, 2025
23532af
[feat] FeedCommandPersistenceAdapter.delete (#160)
hd0rable Aug 8, 2025
7a0c157
[feat] FeedCommandPort.delete (#160)
hd0rable Aug 8, 2025
21c6349
[feat] Feed 도메인 삭제 권한 검증 메서드 추가 (#160)
hd0rable Aug 8, 2025
00e3554
[feat] FeedDeleteUseCase.deleteFeed 유즈케이스 작성 (#160)
hd0rable Aug 8, 2025
fbf6677
[feat] feed 엔티티 삭제용 양방향 매핑 관계 추가 및 상속관계 joined전략시 하위 자식 삭제 어노테이션 추가 (…
hd0rable Aug 8, 2025
c2005a0
[feat] FeedJpaRepository.findByPostIdAndStatus 작성 (#160)
hd0rable Aug 8, 2025
bba7132
[feat] post 엔티티 하드 딜리트 어노테이션 추가 (#160)
hd0rable Aug 8, 2025
5bc02f6
[feat] 피드삭제 유즈케이스 구현체 FeedDeleteService 작성 (#160)
hd0rable Aug 8, 2025
2253804
[feat] 피드삭제 관련 스웨거 에러코드 추가 (#160)
hd0rable Aug 8, 2025
4974b39
[test] 피드 도메인 단위 테스트 코드 추가 (#160)
hd0rable Aug 8, 2025
67bd0b0
[test] 피드 삭제 통합 테스트 코드 작성 (#160)
hd0rable Aug 8, 2025
1f62822
[test] 댓글 삭제 통합 테스트 코드 수정 (#160)
hd0rable Aug 8, 2025
8af91a9
[test] post 소프트 딜리트 전략 추가시 테스트 코드 수정(#160)
hd0rable Aug 8, 2025
2329047
[feat] 자식 delete 어노테이션 추가 (#160)
hd0rable Aug 8, 2025
5bfeab9
[test] 테스트 코드 수정 (#160)
hd0rable Aug 8, 2025
56a466a
[chore] 주석 수정 (#160)
hd0rable Aug 8, 2025
3ce0ae0
[feat] 관련 에러코드 추가 (#160)
hd0rable Aug 10, 2025
c394231
Merge remote-tracking branch 'origin/develop' into feat/#160-feed-delete
hd0rable Aug 10, 2025
f1338dc
[refactor] @OnDelete 어노테이션 삭제 (#160)
hd0rable Aug 10, 2025
163ec4f
[refactor] @SQLDelete 어노테이션 삭제 및 명시적 status업데이트로 소프트 딜리트 전략 수정 (#160)
hd0rable Aug 10, 2025
f0c11af
[feat] 게시물id로 댓글,댓글좋아요 일괄 삭제 함수 추가작성 (#160)
hd0rable Aug 10, 2025
120d2d8
[feat] 게시물id로 콘텐츠,피드 태그,피드 저장 일괄 삭제 함수 추가작성 (#160)
hd0rable Aug 10, 2025
6498788
[feat] 게시물id로 게시물좋아요 일괄 삭제 함수 추가작성 (#160)
hd0rable Aug 10, 2025
40cc963
[refactor] 피드 삭제 명시적으로 로직 수정 (#160)
hd0rable Aug 10, 2025
e69a8f2
[refactor] 테스트 코드 수정 (#160)
hd0rable Aug 10, 2025
a888a83
[refactor] 피드 태그 삭제 함수 일관성 통일 (#160)
hd0rable Aug 10, 2025
67812e5
Merge branch 'develop' into feat/#160-feed-delete
hd0rable Aug 11, 2025
2319004
[teat] 테스트 코드 수정 (#160)
hd0rable Aug 11, 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 @@ -10,6 +10,9 @@
import lombok.*;
import org.hibernate.annotations.SQLDelete;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "comments")
@Getter
Expand Down Expand Up @@ -52,6 +55,10 @@ public class CommentJpaEntity extends BaseJpaEntity {
@JoinColumn(name = "parent_id")
private CommentJpaEntity parent;

// 삭제용 댓글 좋아요 양방향 매핑 관계
@OneToMany(mappedBy = "commentJpaEntity", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<CommentLikeJpaEntity> commentLikeList = new ArrayList<>();

public CommentJpaEntity updateFrom(Comment comment) {
this.reportCount = comment.getReportCount();
this.likeCount = comment.getLikeCount();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity;
import konkuk.thip.comment.adapter.out.mapper.CommentMapper;
import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository;
import konkuk.thip.comment.adapter.out.persistence.repository.CommentLikeJpaRepository;
import konkuk.thip.comment.application.port.out.CommentCommandPort;
import konkuk.thip.comment.domain.Comment;
import konkuk.thip.common.exception.EntityNotFoundException;
Expand Down Expand Up @@ -30,6 +31,8 @@ public class CommentCommandPersistenceAdapter implements CommentCommandPort {
private final RecordJpaRepository recordJpaRepository;
private final VoteJpaRepository voteJpaRepository;
private final UserJpaRepository userJpaRepository;
private final CommentLikeJpaRepository commentLikeJpaRepository;

private final CommentMapper commentMapper;

@Override
Expand Down Expand Up @@ -89,4 +92,10 @@ public void delete(Comment comment) {
commentJpaRepository.delete(commentJpaEntity);
}

@Override
public void softDeleteAllByPostId(Long postId) {
commentLikeJpaRepository.deleteAllByPostId(postId);
commentJpaRepository.softDeleteAllByPostId(postId);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@
import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity;
import konkuk.thip.common.entity.StatusType;
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.Optional;

public interface CommentJpaRepository extends JpaRepository<CommentJpaEntity, Long>, CommentQueryRepository {
Optional<CommentJpaEntity> findByCommentIdAndStatus(Long commentId, StatusType status);

@Modifying
@Query("UPDATE CommentJpaEntity c SET c.status = 'INACTIVE' WHERE c.postJpaEntity.postId = :postId")
void softDeleteAllByPostId(@Param("postId") Long postId);
Comment on lines +15 to +17
Copy link
Contributor

Choose a reason for hiding this comment

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

오호 이러면 단일 Comment 삭제시에는 그냥 delete로 SQLDelete 어노테이션을 사용하고 다른 엔티티에 연관된 Comment 삭제시에는 위 쿼리를 사용해서 벌크 삭제를 하는 식이겠네요! 좋습니다!!


}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,14 @@ public interface CommentLikeJpaRepository extends JpaRepository<CommentLikeJpaEn

@Query("SELECT c.commentJpaEntity.commentId FROM CommentLikeJpaEntity c WHERE c.userJpaEntity.userId = :userId AND c.commentJpaEntity.commentId IN :commentIds")
Set<Long> findCommentIdsLikedByUser(@Param("commentIds") Set<Long> commentIds, @Param("userId") Long userId);

@Modifying
@Query("""
DELETE FROM CommentLikeJpaEntity cl
WHERE cl.commentJpaEntity.commentId IN (
SELECT c.commentId FROM CommentJpaEntity c
WHERE c.postJpaEntity.postId = :postId
)
""")
void deleteAllByPostId(@Param("postId") Long postId);
Comment on lines +34 to +42
Copy link
Contributor

Choose a reason for hiding this comment

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

오호 서브 쿼리 굿굿입니다!!

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ default Comment getByIdOrThrow(Long id) {

void delete(Comment comment);

void softDeleteAllByPostId(Long postId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ public enum ErrorCode implements ResponseCode {
* 180000 : Post error
*/
POST_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, 180000, "일치하는 게시물 타입 이름이 없습니다. [FEED, RECORD, VOTE] 중 하나여야 합니다."),
POST_ALREADY_DELETED(HttpStatus.BAD_REQUEST, 180001, "이미 삭제된 게시물 입니다."),

/**
* 185000 : PostLike error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ public enum SwaggerResponseDescription {
POST_LIKE_COUNT_UNDERFLOW,
FEED_ACCESS_FORBIDDEN
))),
FEED_DELETE(new LinkedHashSet<>(Set.of(
FEED_NOT_FOUND,
FEED_ACCESS_FORBIDDEN
))),

// Comment
COMMENT_CREATE(new LinkedHashSet<>(Set.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import konkuk.thip.feed.adapter.in.web.response.FeedIsLikeResponse;
import konkuk.thip.feed.adapter.in.web.response.FeedIsSavedResponse;
import konkuk.thip.feed.application.port.in.FeedCreateUseCase;
import konkuk.thip.feed.application.port.in.FeedDeleteUseCase;
import konkuk.thip.feed.application.port.in.FeedSavedUseCase;
import konkuk.thip.feed.application.port.in.FeedUpdateUseCase;
import konkuk.thip.post.application.port.in.PostLikeUseCase;
Expand All @@ -35,6 +36,7 @@ public class FeedCommandController {
private final FeedUpdateUseCase feedUpdateUseCase;
private final FeedSavedUseCase feedSavedUseCase;
private final PostLikeUseCase postLikeUseCase;
private final FeedDeleteUseCase feedDeleteUseCase;

@Operation(
summary = "피드 작성",
Expand Down Expand Up @@ -91,4 +93,17 @@ public BaseResponse<FeedIsLikeResponse> likeFeed(
return BaseResponse.ok(FeedIsLikeResponse.of(postLikeUseCase.changeLikeStatusPost(request.toCommand(userId, feedId))));
}

@Operation(
summary = "피드 삭제",
description = "사용자가 피드를 삭제합니다."
)
@ExceptionDescription(FEED_DELETE)
@DeleteMapping("/feeds/{feedId}")
public BaseResponse<Void> deleteFeed(
@Parameter(description = "삭제하려는 피드 ID", example = "1") @PathVariable("feedId") final Long feedId,
@Parameter(hidden = true) @UserId final Long userId) {
feedDeleteUseCase.deleteFeed(feedId, userId);
return BaseResponse.ok(null);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Entity
Expand All @@ -34,6 +35,14 @@ public class FeedJpaEntity extends PostJpaEntity {
@OneToMany(mappedBy = "postJpaEntity", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ContentJpaEntity> contentList;

// 삭제용 피드 저장 양방향 매핑 관계
@OneToMany(mappedBy = "feedJpaEntity", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<SavedFeedJpaEntity> savedFeeds = new ArrayList<>();

// 삭제용 피드 태그 양방향 매핑 관계
@OneToMany(mappedBy = "feedJpaEntity", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<FeedTagJpaEntity> feedTags = new ArrayList<>();
Comment on lines +38 to +44
Copy link
Collaborator

Choose a reason for hiding this comment

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

LGTM 확인했습니다

Copy link
Collaborator

Choose a reason for hiding this comment

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

이러면 피드 삭제시에 이와 1:n 으로 연관된 친구들은 실제로 삭제가 되고, feed에 해당하는 post 는 soft delete 가 되는 구조겠네요. 굳굳

Copy link
Contributor

Choose a reason for hiding this comment

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

OneToMany는 소프트 딜리트 전파가 된다고 하셨던 것 같은데 맞나요?? @hd0rable

Copy link
Member Author

Choose a reason for hiding this comment

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

엇 아니용 전파안되고 그 엔티티에 sqlDelete 어노테이션 적용안되어있으면 하드딜리트되는 걸 확인했습니당

Copy link
Member Author

Choose a reason for hiding this comment

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

그래서 SavedFeed,FeedTag는 엔티티자체에 소프트 딜리트 어노테이션이 적용되지않기때문에 하드딜리트됩니닷

Copy link
Contributor

Choose a reason for hiding this comment

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

엇 저는 연쇄 삭제가 일어날때 SQLDelete가 달려있는 엔티티는 소프트 딜리트가 되는게 맞는지 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

맞습니다~~

Copy link
Contributor

Choose a reason for hiding this comment

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

화긴이요~~


@Builder
public FeedJpaEntity(String content, Integer likeCount, Integer commentCount, UserJpaEntity userJpaEntity, Boolean isPublic, int reportCount, BookJpaEntity bookJpaEntity, List<ContentJpaEntity> contentList) {
super(content, likeCount, commentCount, userJpaEntity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import konkuk.thip.feed.adapter.out.jpa.*;
import konkuk.thip.feed.adapter.out.mapper.ContentMapper;
import konkuk.thip.feed.adapter.out.mapper.FeedMapper;
import konkuk.thip.feed.adapter.out.persistence.repository.Content.ContentJpaRepository;
import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository;
import konkuk.thip.feed.adapter.out.persistence.repository.FeedTag.FeedTagJpaRepository;
import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository;
Expand All @@ -21,6 +22,7 @@
import java.util.List;
import java.util.Optional;

import static konkuk.thip.common.entity.StatusType.ACTIVE;
import static konkuk.thip.common.exception.code.ErrorCode.*;

@Repository
Expand All @@ -32,14 +34,16 @@ public class FeedCommandPersistenceAdapter implements FeedCommandPort {
private final BookJpaRepository bookJpaRepository;
private final TagJpaRepository tagJpaRepository;
private final FeedTagJpaRepository feedTagJpaRepository;
private final ContentJpaRepository contentJpaRepository;
private final SavedFeedJpaRepository savedFeedJpaRepository;

private final FeedMapper feedMapper;
private final ContentMapper contentMapper;


@Override
public Optional<Feed> findById(Long id) {
return feedJpaRepository.findById(id)
return feedJpaRepository.findByPostIdAndStatus(id,ACTIVE)
.map(feedJpaEntity -> {
List<TagJpaEntity> tagJpaEntityList = tagJpaRepository.findAllByFeedId(feedJpaEntity.getPostId());
return feedMapper.toDomainEntity(feedJpaEntity, tagJpaEntityList);
Expand Down Expand Up @@ -78,7 +82,7 @@ public Long update(Feed feed) {
feedJpaEntity.getContentList().clear(); // 피드 수정시 기존 영속성 컨텍스트 내 엔티티 연결 제거
applyFeedContents(feed, feedJpaEntity);

feedTagJpaRepository.deleteAllByFeedJpaEntity(feedJpaEntity); // 피드 수정시 기존 피드의 모든 FeedTag 매핑 row 삭제
feedTagJpaRepository.deleteAllByFeedId(feedJpaEntity.getPostId()); // 피드 수정시 기존 피드의 모든 FeedTag 매핑 row 삭제
applyFeedTags(feed, feedJpaEntity);

return feedJpaEntity.getPostId();
Expand Down Expand Up @@ -125,4 +129,16 @@ public void deleteSavedFeed(Long userId, Long feedId) {
savedFeedJpaRepository.deleteByUserIdAndFeedId(userId, feedId);
}

@Override
public void delete(Feed feed) {
FeedJpaEntity feedJpaEntity = feedJpaRepository.findById(feed.getId())
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));

feedTagJpaRepository.deleteAllByFeedId(feedJpaEntity.getPostId());
contentJpaRepository.deleteAllByFeedId(feedJpaEntity.getPostId());
savedFeedJpaRepository.deleteAllByFeedId(feedJpaEntity.getPostId());

feedJpaEntity.softDelete();
feedJpaRepository.save(feedJpaEntity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

import konkuk.thip.feed.adapter.out.jpa.ContentJpaEntity;
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;

public interface ContentJpaRepository extends JpaRepository<ContentJpaEntity, Long>{

@Modifying
@Query("DELETE FROM ContentJpaEntity c WHERE c.postJpaEntity.postId = :feedId")
void deleteAllByFeedId(@Param("feedId") Long feedId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface FeedJpaRepository extends JpaRepository<FeedJpaEntity, Long>, FeedQueryRepository {

@Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId AND f.status = :status")
long countAllFeedsByUserId(@Param("userId") Long userId, @Param("status") StatusType status);

@Query("SELECT COUNT(f) FROM FeedJpaEntity f WHERE f.userJpaEntity.userId = :userId AND f.isPublic = TRUE AND f.status = :status")
long countPublicFeedsByUserId(@Param("userId") Long userId, @Param("status") StatusType status);

Optional<FeedJpaEntity> findByPostIdAndStatus(Long postId, StatusType status);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package konkuk.thip.feed.adapter.out.persistence.repository.FeedTag;

import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity;
import konkuk.thip.feed.adapter.out.jpa.FeedTagJpaEntity;
import konkuk.thip.feed.application.port.out.dto.FeedIdAndTagProjection;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -12,14 +11,14 @@

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

@Modifying
@Query("DELETE FROM FeedTagJpaEntity ft WHERE ft.feedJpaEntity.postId = :feedId")
void deleteAllByFeedId(@Param("feedId") Long feedId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@

public interface SavedFeedJpaRepository extends JpaRepository<SavedFeedJpaEntity, Long> {
@Modifying
@Query(value = "DELETE FROM saved_feeds WHERE user_id = :userId AND post_id = :feedId", nativeQuery = true)
@Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.userJpaEntity.userId = :userId AND sf.feedJpaEntity.postId = :feedId")
void deleteByUserIdAndFeedId(@Param("userId") Long userId, @Param("feedId") Long feedId);

@Query(value = "SELECT * FROM saved_feeds WHERE user_id = :userId", nativeQuery = true)
@Query("SELECT sf FROM SavedFeedJpaEntity sf WHERE sf.userJpaEntity.userId = :userId")
List<SavedFeedJpaEntity> findAllByUserId(@Param("userId") Long userId);

@Query("SELECT s.feedJpaEntity.postId FROM SavedFeedJpaEntity s WHERE s.userJpaEntity.userId = :userId AND s.feedJpaEntity.postId IN :feedIds")
Set<Long> findSavedFeedIdsByUserIdAndFeedIds(@Param("userId") Long userId, @Param("feedIds") Set<Long> feedIds);

@Modifying
@Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.feedJpaEntity.postId = :feedId")
int deleteAllByFeedId(@Param("feedId") Long feedId);
Comment on lines +23 to +25
Copy link

@coderabbitai coderabbitai bot Aug 10, 2025

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

신규 bulk delete: clearAutomatically 추가 권장 + 설계 일관성 확인

  • bulk 연산 후 1차 캐시를 자동 정리하도록 @Modifying(clearAutomatically = true)를 권장합니다.
  • 반환 타입을 int로 둔 점 좋습니다(삭제 건수 확인 가능).
  • 의도: Feed는 soft delete, SavedFeed는 하드 삭제로 보입니다. 도메인 의도라면 일관됩니다. 향후 SavedFeed도 soft delete 대상이라면 DELETE 대신 soft 플래그 업데이트로 전환되어야 합니다.

권장 변경안:

-    @Modifying
+    @Modifying(clearAutomatically = true)
     @Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.feedJpaEntity.postId = :feedId")
     int deleteAllByFeedId(@Param("feedId") Long feedId);

추가 확인 포인트:

  • 경로 sf.userJpaEntity.userId / sf.feedJpaEntity.postId가 실제 엔티티 필드와 일치하는지.
  • 호출부가 항상 트랜잭션 내부인지(서비스/어댑터 @Transactional) — bulk delete 특성상 중요.

🏁 Script executed:

#!/bin/bash
# 1) 엔티티 필드 경로 검증(간이): 관련 엔티티의 필드명을 확인
rg -n --hidden --ignore-case "class SavedFeedJpaEntity|userJpaEntity|feedJpaEntity" -g "**/*.java"

# 2) soft delete 적용 대상 확인: SavedFeed/Content/FeedTag/Feed의 @SQLDelete/@Where 존재 여부
rg -n "@SQLDelete|@Where" -g "**/*JpaEntity.java"

# 3) 본 메서드 호출부 확인(일관된 사용/트랜잭션 경계 확인)
rg -n "deleteAllByFeedId\\(" -g "**/*.java"

# 4) Feed 삭제 유스케이스/어댑터 트랜잭션 확인
rg -n "@Transactional|FeedDeleteService|FeedCommandPersistenceAdapter" -g "src/**/*.java"

Length of output: 164629


bulk 삭제 후 1차 캐시 자동 정리 적용 권장

  • 대상 메서드:
    src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java
    deleteAllByFeedId(@param("feedId") Long feedId)
  • 쿼리 경로(sf.feedJpaEntity.postId, sf.userJpaEntity.userId)와 반환 타입(int)은 모두 정확합니다.
  • 이 메서드는 FeedDeleteService(@transactional) 내부에서 호출되어 트랜잭션 경계가 보장됩니다.
  • SavedFeedJpaEntity에는 @SQLDelete/@where가 적용되어 있지 않아 하드 삭제가 의도된 설계이며, 도메인 의도에도 부합합니다.
  • 향후 SavedFeed에도 soft delete를 적용해야 할 경우 DELETE 대신 상태 플래그 업데이트로 전환하세요.

권장 변경안:

-    @Modifying
+    @Modifying(clearAutomatically = true)
     @Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.feedJpaEntity.postId = :feedId")
     int deleteAllByFeedId(@Param("feedId") Long feedId);
📝 Committable suggestion

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

Suggested change
@Modifying
@Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.feedJpaEntity.postId = :feedId")
int deleteAllByFeedId(@Param("feedId") Long feedId);
@Modifying(clearAutomatically = true)
@Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.feedJpaEntity.postId = :feedId")
int deleteAllByFeedId(@Param("feedId") Long feedId);
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/SavedFeedJpaRepository.java
around lines 23 to 25, the deleteAllByFeedId method performs a bulk delete but
does not clear the first-level cache automatically. To fix this, add the
clearAutomatically = true attribute to the @Modifying annotation to ensure the
persistence context is cleared after the bulk operation, preventing stale
entities in the cache.

Copy link
Member Author

Choose a reason for hiding this comment

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

@Modifying(clearAutomatically = true) 이 어노테이션이 무슨 역할을 하는데?

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Member Author

Choose a reason for hiding this comment

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

왜 안알려줘

Copy link
Contributor

Choose a reason for hiding this comment

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

@coderabbitai @Modifying(clearAutomatically = true)는 무슨 역할을 하는 어노테이션이야?

Copy link

Choose a reason for hiding this comment

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

@buzz0331, @Modifying(clearAutomatically = true) 어노테이션은 Spring Data JPA에서 다음과 같은 역할을 합니다:

주요 기능

1. 영속성 컨텍스트 자동 정리

  • Bulk 연산(DELETE, UPDATE) 실행 후 1차 캐시를 자동으로 비웁니다
  • JPA의 영속성 컨텍스트에 남아있는 엔티티들을 clear() 처리합니다

왜 필요한가?

캐시 불일치 문제 방지:

// 예시: clearAutomatically = false (기본값)인 경우
SavedFeedJpaEntity entity = repository.findById(1L); // 1차 캐시에 저장됨
repository.deleteAllByFeedId(feedId); // DB에서는 삭제되지만 캐시는 그대로
SavedFeedJpaEntity stale = repository.findById(1L); // 캐시에서 가져옴 (삭제된 데이터!)

clearAutomatically = true 설정 시:

SavedFeedJpaEntity entity = repository.findById(1L); 
repository.deleteAllByFeedId(feedId); // DB 삭제 + 캐시 자동 정리
SavedFeedJpaEntity fresh = repository.findById(1L); // DB에서 새로 조회 (null 반환)

현재 코드에 적용이 필요한 이유

deleteAllByFeedId 메서드는 bulk DELETE 연산이므로:

  • 여러 SavedFeed 엔티티를 한 번에 삭제합니다
  • 이 엔티티들이 이미 영속성 컨텍스트에 로드되어 있을 수 있습니다
  • clearAutomatically = true 없이는 삭제된 엔티티가 캐시에 남아 데이터 불일치를 일으킬 수 있습니다

특히 FeedDeleteService처럼 하나의 트랜잭션 내에서 여러 연관 엔티티를 다룰 때 이런 설정이 중요합니다.

Copy link
Contributor

Choose a reason for hiding this comment

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

@coderabbitai 그렇다면 @Modifying 어노테이션의 default는 뭘로 설정되어 있는데 다 false로 설정되어 있나?

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for @Modifying annotation definition in Spring Data JPA
# First, let's look for any @Modifying usage in the codebase to understand the pattern
rg "@Modifying" -A 1 -B 1 --type java

# Count how many @Modifying annotations are used without explicit parameters
echo "---"
echo "Modifying annotations without explicit parameters:"
rg "@Modifying\s*$" --type java | wc -l

# Count how many have clearAutomatically set
echo "Modifying annotations with clearAutomatically:"
rg "@Modifying.*clearAutomatically" --type java | wc -l

Length of output: 5436


🌐 Web query:

Spring Data JPA @Modifying annotation default values clearAutomatically flushAutomatically

💡 Result:

The default values for the Spring Data JPA @Modifying annotation are clearAutomatically = false and flushAutomatically = false, meaning neither automatic clearing nor flushing of the persistence context is performed unless configured explicitly[6][7][8].

Context and Supporting Details:

  • @Modifying annotation marks a query as a modifying query (for INSERT, UPDATE, DELETE, or DDL) rather than a read-only query[6].
  • clearAutomatically:
    • Default: false
    • If set to true, the persistence context is cleared automatically after executing the modifying query. This is useful to prevent any out-of-sync issues between your context and the actual database, especially after bulk operations[2][6][9].
  • flushAutomatically:
    • Default: false
    • If set to true, it causes the persistence context to be flushed before executing the modifying query, ensuring that all pending changes are synchronized with the database before performing the operation[6][7].
Attribute Default Value Purpose
clearAutomatically false Clears the persistence context after the query
flushAutomatically false Flushes the persistence context before the query

Why change defaults:

  • Setting clearAutomatically = true is recommended for bulk updates/deletes to avoid unexpected results due to dirty entities remaining in memory[2][4].
  • Setting flushAutomatically = true can prevent pending changes from being lost if the persistence context is cleared after the operation, which can occur when using both options together[4].

Clarification of Ambiguity:

  • Some older or informal sources may suggest different defaults, but the official Spring Data JPA documentation lists both as false by default in all major versions[6][7][8].
  • Both attributes are only meaningful when using @Modifying in combination with a custom @query on a repository method[6].

If further clarification is needed on a specific version or scenario, please specify.

Citations:


@buzz0331, 네, @Modifying 어노테이션의 기본값은 모두 false로 설정되어 있습니다:

기본값 정리

속성 기본값 설명
clearAutomatically false 쿼리 실행 후 영속성 컨텍스트를 자동으로 비우지 않음
flushAutomatically false 쿼리 실행 전 영속성 컨텍스트를 자동으로 flush하지 않음

현재 코드베이스 상황

코드베이스를 확인한 결과:

  • 12개@Modifying 어노테이션이 파라미터 없이 사용 중
  • 0개clearAutomatically를 명시적으로 설정
  • 즉, 모든 bulk 연산이 기본값(false)을 사용 중

영향받는 메서드들:

  • SavedFeedJpaRepository: deleteByUserIdAndFeedId, deleteAllByFeedId
  • PostLikeJpaRepository: deleteByUserIdAndPostId, deleteAllByPostId
  • CommentJpaRepository: softDeleteAllByPostId
  • CommentLikeJpaRepository: deleteByUserIdAndCommentId, deleteAllByCommentId, deleteAllByPostId
  • FeedTagJpaRepository: deleteAllByFeedId
  • ContentJpaRepository: deleteAllByFeedId
  • 등등...

잠재적 문제

기본값 사용 시 다음과 같은 상황이 발생할 수 있습니다:

// 예시: FeedDeleteService 내부
Feed feed = feedRepository.findById(feedId);  // 1차 캐시에 로드
savedFeedRepository.deleteAllByFeedId(feedId); // DB에서만 삭제, 캐시는 유지
// feed와 연관된 SavedFeed들이 캐시에 남아있어 불일치 발생 가능

특히 피드 삭제처럼 여러 연관 엔티티를 한 트랜잭션에서 처리하는 경우, 캐시 불일치로 인한 예기치 않은 동작이 발생할 수 있습니다.

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


public interface FeedDeleteUseCase {
void deleteFeed(Long feedId, Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ default Feed getByIdOrThrow(Long id) {
return findById(id)
.orElseThrow(() -> new EntityNotFoundException(FEED_NOT_FOUND));
}

void delete(Feed feed);
void saveSavedFeed(Long userId, Long feedId);
void deleteSavedFeed(Long userId, Long feedId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package konkuk.thip.feed.application.service;

import jakarta.transaction.Transactional;
import konkuk.thip.comment.application.port.out.CommentCommandPort;
import konkuk.thip.feed.application.port.in.FeedDeleteUseCase;
import konkuk.thip.feed.application.port.out.FeedCommandPort;
import konkuk.thip.feed.domain.Feed;
import konkuk.thip.post.application.port.out.PostLikeCommandPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FeedDeleteService implements FeedDeleteUseCase {

private final FeedCommandPort feedCommandPort;
private final CommentCommandPort commentCommandPort;
private final PostLikeCommandPort postLikeCommandPort;

@Override
@Transactional
public void deleteFeed(Long feedId, Long userId) {

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

// 2. 피드 삭제 권한 검증
feed.validateDeletable(userId);

// TODO S3 이미지 삭제 이벤트 기반 처리 or 배치 삭제
Copy link
Collaborator

Choose a reason for hiding this comment

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

굳 이건 동기적으로 삭제하는게 아니라 일단 TODO로 정의하고 리펙단계에서 일괄적으로 처리하시죠

// 3. 피드 삭제
commentCommandPort.softDeleteAllByPostId(feedId);
postLikeCommandPort.deleteAllByPostId(feedId);
feedCommandPort.delete(feed);
}
}
6 changes: 5 additions & 1 deletion src/main/java/konkuk/thip/feed/domain/Feed.java
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,14 @@ public void validateLike(Long userId){

private void validateCreator(Long userId) {
if (!this.creatorId.equals(userId)) {
throw new InvalidStateException(FEED_ACCESS_FORBIDDEN, new IllegalArgumentException("피드 작성자만 피드를 수정할 수 있습니다."));
throw new InvalidStateException(FEED_ACCESS_FORBIDDEN, new IllegalArgumentException("피드 작성자만 피드를 수정/삭제할 수 있습니다."));
}
}

public void validateDeletable(Long userId) {
validateCreator(userId);
Copy link
Collaborator

Choose a reason for hiding this comment

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

LGTM 이 메서드 굉장히 유용하게 쓰이네요 굳굳

}

public void updateContent(Long userId, String newContent) {
validateCreator(userId);
this.content = newContent;
Expand Down
Loading