Notice(공지사항 게시물)에서 Comment(댓글)을 1:N으로 구현하였다. 글은 대댓글도 쓸 수 있는 상황이다.
기존 엔티티를 먼저 살펴보자.
Notice (부모)
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Notice extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "notice_id")
private Long id;
/**
* 단방향 매핑 (단뱡향일 때는 Cascade 작동 X, User 삭제 시 Notice 삭제 후 User 삭제 해야 함)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="writer")
private User writer;
@Column(name = "notice_title", length = 50, nullable = false)
private String title;
@Column(name = "notice_content", length = 4000, nullable = false)
private String content;
@Column(name = "notice_hit")
@ColumnDefault("0")
private Long hit;
/**
* 이미지와 양방향 매핑
*/
@OneToMany(mappedBy = "notice", cascade = CascadeType.ALL)
private List<File> files = new ArrayList<>();
/**
* 양방향
*/
@OneToMany(mappedBy = "notice", cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();
...(생략)
}
Comment (자식)
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Comment extends CreatedDateEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
//내용
@Column(nullable = false, columnDefinition = "TEXT")
@Lob
private String content;
//삭제된 상태
@Enumerated(value = EnumType.STRING)
private DeleteStatus isDeleted;
/**
* 양방향
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notice_id")
private Notice notice;
/**
* 양방향
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "talent_id")
private Talent talent;
//작성자
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "writer")
private User writer;
//부모 댓글
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;
//자식 댓글 리스트
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Comment> children = new ArrayList<>();
...(생략)
}
Notice 게시물을 하나 삭제할 때 Repository에서 deleteById를 사용하여 Comment도 함께 삭제한다.
테스트 케이스로 하나의 Notice 공지사항 게시물에 10개의 Comment 댓글을 달았다고 가정하여 먼저 생성하고, 만든 게시물을 삭제해보자.
@SpringBootTest
public class NoticeAndCommentDeleteTest {
@Autowired
private NoticeRepository noticeRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private CommentRepository commentRepository;
@BeforeEach
public void 더미_공지사항_댓글_생성() {
User user = userRepository.findById("admin").get();
Notice notice = Notice.builder()
.title("필독! 새로운 이벤트가 시작됩니다!")
.content("참여하고 풍성한 보상을 받아가세요! ")
.writer(user)
.hit(0L)
.build();
noticeRepository.save(notice);
IntStream.rangeClosed(1, 10).forEach(i -> {
Comment comment = Comment.builder()
.content("댓글 내용"+i)
.isDeleted(DeleteStatus.N)
.writer(user)
.notice(notice)
.build();
commentRepository.save(comment);
});
}
@Test
void 공지사항_삭제() {
noticeRepository.deleteById(241L);
}
}
10개의 Comment 댓글을 삭제한 뒤, Notice 게시물이 삭제된다.
보다 많은 댓글이 달려있다면 delete도 그만큼 더 많이 발생할 것이다.
이를 해결하기 위해 연관관계 객체 데이터를 함께 삭제하는 경우엔 벌크성 삭제를 이용하여 JPQL로 직접 작성해 삭제해보자.
앞서 말했듯이 댓글은 대댓글 구현도 함께 진행하여 Comment 엔티티는 셀프로 1:N 관계를 설정해 부모 댓글과 자식 댓글 리스트 필드를 만들었다.
따라서 자식 댓글이 있는데, 부모 댓글이 삭제된 경우에는 is_deleted
컬럼이 N -> Y로 변경되고 삭제된 댓글이라고 클라이언트에게 응답 데이터를 준다.
deleteById
를 사용하지 않고 직접 JPQL을 작성하여 삭제하는 경우엔 Comment를 먼저 삭제하고 Notice를 삭제해야 한다.
마찬가지로 Comment도 부모 댓글과 자식 댓글의 연관관계를 끊어야 되기 때문에 부모 필드를 NULL로 업데이트 해주고, 삭제가 진행되어야 한다.
@Modifying
@Query("UPDATE Comment c SET c.parent = NULL WHERE c.parent.id IN (SELECT c2.id FROM Comment c2 WHERE c2.notice.id = :noticeId)")
void removeParentRelationForChildCommentsByNoticeId(@Param("noticeId") Long noticeId);
@Modifying
@Query("DELETE FROM Comment c WHERE c.notice.id = :noticeId")
void deleteParentCommentsByNoticeId(@Param("noticeId") Long noticeId);
@Modifying
@Query("delete from File f where f.notice.id = :noticeId")
void deleteByNoticeId(@Param("noticeId") Long noticeId);
Spring Data JPA에선 @Modifying
을 설정해 JPQL을 벌크성으로 변환시켜준다. (데이터를 대량으로 수정하거나 삭제)
그리고 한 가지 더 중요한 작업이 남아있다. 엔티티를 수정해줘야 한다.
위 Notice 엔티티를 살펴보면 Cascade 설정을 ALL로 해주었다.
@OneToMany(mappedBy = "notice", cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();
Cascade
와 orphanRemoval
은 JPA에서 엔티티 간의 관계를 다룰 때 사용되는 중요한 속성이다.
이 두 속성은 부모 엔티티와 자식 엔티티 간의 연산을 어떻게 전파할 것인지를 결정한다.
Cascade: 부모 엔티티에서 수행한 연산을 자식 엔티티에 전파하는 속성이다.
PERSIST: 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장한다.
MERGE: 부모 엔티티를 병합할 때 자식 엔티티도 함께 병합한다.
REFRESH: 부모 엔티티를 새로고침할 때 자식 엔티티도 함께 새로고침한다.
REMOVE: 부모 엔티티를 삭제할 때 자식 엔티티도 함께 삭제한다.
ALL: 모든 연산을 자식 엔티티에 전파한다.
orphanRemoval: 고아 객체 제거 기능을 제공하는 속성이다.
부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제할 수 있다.
orphanRemoval 속성을 true로 설정하면, 부모 엔티티에서 자식 엔티티의 참조를 제거하거나 부모 엔티티가 삭제될 때 자식 엔티티도 함께 삭제된다.
나는 orphanRemoval 속성은 사용하지 않고, Cascade를 사용하였다.
여기서 Cascade를 ALL로 설정하였으니 REMOVE 속성까지 선언해버린 것이다.
Remove는 부모 클래스를 삭제하면 자식 클래스도 삭제하는 SQL문이 연쇄적으로 일어나기 때문에 PERSIST 속성만 쓰는 것으로 바꾸자.
Notice (부모)
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Notice extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "notice_id")
private Long id;
/**
* 단방향 매핑 (단뱡향일 때는 Cascade 작동 X, User 삭제 시 Notice 삭제 후 User 삭제 해야 함)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="writer")
private User writer;
@Column(name = "notice_title", length = 50, nullable = false)
private String title;
@Column(name = "notice_content", length = 4000, nullable = false)
private String content;
@Column(name = "notice_hit")
@ColumnDefault("0")
private Long hit;
/**
* 이미지와 양방향 매핑
*/
@OneToMany(mappedBy = "notice", cascade = CascadeType.PERSIST)
private List<File> files = new ArrayList<>();
/**
* 양방향
*/
@OneToMany(mappedBy = "notice", cascade = CascadeType.PERSIST)
private List<Comment> comments = new ArrayList<>();
...(생략)
}
@SpringBootTest
public class NoticeAndCommentDeleteTest {
@Autowired
private NoticeRepository noticeRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private CommentRepository commentRepository;
@BeforeEach
public void 더미_공지사항_댓글_생성() {
User user = userRepository.findById("admin").get();
Notice notice = Notice.builder()
.title("필독! 새로운 이벤트가 시작됩니다!")
.content("참여하고 풍성한 보상을 받아가세요! ")
.writer(user)
.hit(0L)
.build();
noticeRepository.save(notice);
IntStream.rangeClosed(1, 10).forEach(i -> {
Comment comment = Comment.builder()
.content("댓글 내용"+i)
.isDeleted(DeleteStatus.N)
.writer(user)
.notice(notice)
.build();
commentRepository.save(comment);
});
}
@Test
@Commit
@Transactional
void 공지사항_삭제() {
commentRepository.removeParentRelationForChildCommentsByNoticeId(265L);
commentRepository.deleteParentCommentsByNoticeId(265L);
noticeRepository.deleteById(265L);
}
}
Repository에서 만든 JQPL을 호출하고 로그를 확인해보자.
Comment의 부모 필드를 NULL로 변경해주고, Notice_id를 이용해 Comment를 삭제한 뒤 Notice 게시물도 정상적으로 삭제되는 것을 확인할 수 있다.