Jpa Repository에서 N:M 연관관계 테스트 하는 법

jkky98·2024년 12월 9일
0

ProjectSpring

목록 보기
3/20

N:M 설계

프로젝트는 게시글 엔티티와 게시글에 딸린 태그 엔티티를 가진다. 게시글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); // 주인 쪽 관계 해제
    }
}

Service

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엔티티는 영속성 컨텍스트에서 삭제된다.

  1. Post와 연관된 모든 PostTag의 연결을 연관관계 메서드로 하여금 끊는다.
  2. 동시에 PostTag에서 Tag를 타고가 Tag와 PostTag와의 연관관계도 끊는다.
  3. 만약 Tag가 어떤 PostTag도 가지지 않는다면 이 태그는 존재의 이유가 없으므로 삭제한다.
  4. 최종적으로 Post를 삭제한다.
    @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;
            }
        }
    }
  1. 우선 클라이언트로 부터 넘어온 Id값으로 Post를 가져온다.
  2. Post에 딸린 모든 PostTag.getTag()로 하여금 Tag를 조사한다.
  3. 클라이언트로 부터 넘어온 TagId와 일치하는 Tag 발견시 해당 Tag와 PostTag의 연관관계를 끊는다.
  4. 동시에 post에서도 PostTag와의 연관관계를 끊는다.
  5. 만약 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);
    }
}
profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보