프로젝트는 게시글 엔티티와 게시글에 딸린 태그 엔티티를 가진다. 게시글1은 A라는 태그를 가질 수 있고 게시글2또한 A라는 태그를 가질 수 있다. 이 논리에 따라 A태그 또한 게시글1, 게시글2를 가진다. 즉 이 관계는 N:M
에 해당하고, Jpa 엔티티 설계시, 이는 보통 중개 엔티티를 두어 풀어낸다.
@ManyToMany
로 엔티티 연관관계를 맺는 것은 기본적으로 지양해야 한다. 엔티티 설계에서 다대다 관계를 1:N - N:1
로 풀어내는 접근법은 기본소양이다.
프로젝트에서는 Post 엔티티와 Tag 엔티티 사이에 PostTag를 두어 풀어낸다.
이때 프로젝트에서 중요한 비즈니스 로직은 Post가 삭제될 때는 이에 딸린 Tag가 삭제되어야 한다는 것이고 보통 1:N
에선 이를 orphanRemoval + cascade
설정으로 Post에서 Tag를 떼어내며 삭제를 자동화 할 수 있다. 하지만 중간에 중개테이블만 존재할 경우 이 자동삭제의 전파는 중개테이블에만 미친다. 즉 반대편에서도 연관관계를 떼어주지 않으면 자식 엔티티는 계속 살아있게 된다.
// Post Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 기본 생성자
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 빌더와 함께 사용할 모든 필드 생성자
public class Post extends BaseEntity {
@Id
@GeneratedValue
@Column(name = "post_id")
private Long id;
private String title;
@Builder.Default
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostTag> postTags = new ArrayList<>();
// 연관관계 편의 메서드
public void addPostTag(PostTag postTag) {
this.postTags.add(postTag);
postTag.updatePost(this); // 연관 관계의 주인(PostTag)에도 설정
}
public void removePostTag(PostTag postTag) {
this.postTags.remove(postTag);
postTag.updatePost(null); // 연관 관계 해제
}
}
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 기본 생성자
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 빌더와 함께 사용할 모든 필드 생성자
public class PostTag extends BaseTimeEntity {
@Id
@GeneratedValue
@Column(name = "post_tag_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
private Tag tag;
public void updateTag(Tag tag) {
this.tag = tag;
}
public void updatePost(Post post) {
this.post = post;
}
}
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 기본 생성자
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 빌더와 함께 사용할 모든 필드 생성자
public class Tag extends BaseTimeEntity {
@Id
@GeneratedValue
@Column(name = "tag_id")
private Long id;
private String name;
//연관관계
@Builder.Default
@OneToMany(mappedBy = "tag", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostTag> postTags = new ArrayList<>();
// 연관 관계 편의 메서드
public void addPostTag(PostTag postTag) {
this.postTags.add(postTag);
postTag.updateTag(this); // 주인 쪽에도 관계 설정
}
public void removePostTag(PostTag postTag) {
this.postTags.remove(postTag);
postTag.updateTag(null); // 주인 쪽 관계 해제
}
}
PostService
는 두 가지 기능을 가진다.
removePost(Long postId)
: 이 기능은 Post 삭제 기능이며, Post 삭제 시 그에 따른 tag의 자동 삭제가 이루어진다.removeTagInPost(Long postId, Long tagId)
: 이 기능은 Post에 딸린 특정한 하나의 태그를 삭제하는 기능이다.
@Transactional
public void removePost(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(() -> new EntityNotFoundException("Post not found"));
// 2. Post에 연결된 PostTag 처리
for (PostTag postTag : new ArrayList<>(post.getPostTags())) {
Tag tag = postTag.getTag();
// PostTag 제거
tag.removePostTag(postTag);
post.removePostTag(postTag);
// 고아 상태가 된 Tag 삭제
if (tag.getPostTags().isEmpty()) {
tagRepository.delete(tag);
}
}
// 3. Post 삭제
postRepository.delete(post);
}
removePostTag는 연관관계 메서드로 Post와 PostTag, Tag와 PostTag간의 연관관계를 끊는다. 그에 따라 orphanremoval + cascadeAll이 작용하여 양쪽의 엔티티에서 연관관계가 끊긴 PostTag엔티티는 영속성 컨텍스트에서 삭제된다.
Post
와 연관된 모든 PostTag의 연결을 연관관계 메서드로 하여금 끊는다. @Transactional
public void removeTagInPost(Long postId, Long tagId) {
Post post = postRepository.findById(postId).orElseThrow(() -> new EntityNotFoundException("Post not found"));
for (PostTag postTag : new ArrayList<>(post.getPostTags())) {
if (postTag.getTag().getId().equals(tagId)) {
// 양방향에서 연관관계 끊기
Tag targetTag = postTag.getTag();
targetTag.removePostTag(postTag);
post.removePostTag(postTag);
if (targetTag.getPostTags().isEmpty()) {
tagRepository.delete(targetTag);
}
return;
}
}
}
TagId
와 일치하는 Tag 발견시 해당 Tag와 PostTag의 연관관계를 끊는다.new ArrayList<>(post.getPostTags())
왜 new ArrayList로 복사하는가?
원래 post.getPostTags()를 직접 순회하면, 루프 중에 컬렉션을 수정(remove)할 경우 ConcurrentModificationException이 발생할 수 있다.(동시성 문제)
new ArrayList<>(...)로 복사하여 이 문제를 방지하고 있다.
@SpringBootTest
@Transactional
public class PostServiceTest {
@Autowired private PostService postService;
@Autowired private PostRepository postRepository;
@Autowired private PostTagRepository postTagRepository;
@Autowired private TagRepository tagRepository;
@Autowired private EntityManager em;
private Post testPost;
private Tag tag1;
private Tag tag2;
private PostTag postTag1;
private PostTag postTag2;
@BeforeEach
void setUp() {
// 게시글 생성 및 저장
// 연관관계 매핑
testPost = Post.builder()
.title("Test Title")
.build();
postRepository.save(testPost);
tag1 = Tag.builder()
.name("Test Tag1").build();
tag2 = Tag.builder()
.name("Test Tag2").build();
postTag1 = PostTag.builder()
.post(testPost)
.build();
postTag2 = PostTag.builder()
.post(testPost)
.build();
}
@Test
@DisplayName("[PostRepository] Post 삭제시 그에 딸린 Tag 자동 삭제(")
void postDeletionRemovesAssociatedTags() {
// given
tag1.addPostTag(postTag1);
tag2.addPostTag(postTag2);
testPost.addPostTag(postTag1);
testPost.addPostTag(postTag2);
// 연관관계 주인 우선 영속화
postTagRepository.saveAll(List.of(postTag1, postTag2));
Post savedPost = postRepository.save(testPost);
tagRepository.saveAll(List.of(tag1, tag2));
// when
// post 삭제
postService.removePost(savedPost.getId());
// then
// 1. Post가 삭제되었는지 확인
Optional<Post> deletedPost = postRepository.findById(savedPost.getId());
assertThat(deletedPost).isEmpty();
// 2. PostTag가 삭제되었는지 확인
List<PostTag> remainingPostTags = postTagRepository.findAll();
assertThat(remainingPostTags).isEmpty();
// 3. 연관된 Tag가 삭제되었는지 확인
List<Tag> remainingTags = tagRepository.findAll();
assertThat(remainingTags).isEmpty();
}
@Test
@DisplayName("[PostRepository] Post에 딸린 Tag 1개삭제")
void removeSpecificTagFromPostTest() {
// given
tag1.addPostTag(postTag1);
tag2.addPostTag(postTag2);
testPost.addPostTag(postTag1);
testPost.addPostTag(postTag2);
// 연관관계 주인 우선 영속화
postTagRepository.saveAll(List.of(postTag1, postTag2));
Post savedPost = postRepository.save(testPost);
tagRepository.saveAll(List.of(tag1, tag2));
// when
postService.removeTagInPost(testPost.getId(), tag1.getId());
// then
// 1. Post 존재 확인
Optional<Post> deletedPost = postRepository.findById(savedPost.getId());
assertThat(deletedPost).isNotEmpty();
// 2. PostTag가 삭제되었는지 확인
List<PostTag> remainingPostTags = postTagRepository.findAll();
assertThat(remainingPostTags.size()).isEqualTo(1);
// 3. 연관된 Tag가 삭제되었는지 확인
List<Tag> remainingTags = tagRepository.findAll();
assertThat(remainingTags.size()).isEqualTo(1);
assertThat(remainingTags.get(0)).isEqualTo(tag2);
}
}