Spring Model/03. 연관관계 처리

02. 연관 관계 - N:1 양방향 관계 처리

  • -

이번 시간에는 N:1 양방향 관계에 대해 살펴보자.

N:1의 양방향관계 매핑

 

@OneToMany

앞서 살펴봤던 @ManyToOne은 N:1의 단방향 관계만 처리 가능할 뿐 양방향으로는 처리할 수가 없었다. DB 처럼 양방향 처리를 위해서는 어떤것이 추가로 필요할까?

N:1의 반대 방향인 1:N을 설정하기 위해서는 @OneToMany를 사용한다.

@Target({METHOD, FIELD})
public @interface OneToMany {
    String mappedBy() default "";             // 관계를 소유한 객체의 필드
    
    CascadeType[] cascade() default {};       // 연관객체에 대한 동시 처리 설정
    FetchType fetch() default FetchType.LAZY; // 실제 데이터를 가져오는 시점 설정
    boolean orphanRemoval() default false;    // 고아객체의 삭제 여부
}

@OneToMany의 mappedBy 속성은 관계의 소유자인 반대쪽 엔티티에서 관계를 설정한 field의 이름이 설정된다. 나머지 속성들은 나중에 살펴보자.

Reply에서 @ManyToOne을 통해 Post를 참조하고 있는 상태에서 반대로 Post에서 Reply와의 관계 표시를 위해 @OneToMany를 설정해주면 된다. 이때 필요한 속성이 mappedBy인데 여기에 할당되는 값은 Reply에서 Post를 참조하는 필드 이름이 된다.

양방향 관계의 구성

 

엔티티 수정 및 동작 확인

Post 엔티티에 다른 엔티티와의 관계를 추가해보자.

먼저 기존의 String writer를 Member 엔티티와의 관계로 변경해준다. 여러 개의 Post가 한 명의 Member에 의해 작성될 수 있기 때문에 @ManyToOne 으로 설정해주면 된다.

다음으로는 Reply와의 관계를 추가해보자. 하나의 Post에는 여러 개의 Reply가 등록될 수 있으므로 List<Reply> 타입으로 하고 @OneToMany를 추가해준다. 초기 값은 아직 Reply가 없을 수 있기 때문에 new ArrayList<>() 가 적절하다. 이후에 새로 Reply가 등록되면 이 List에 추가되어야 한다.

package com.doding.board.post.model.entity;

import com.doding.board.common.entity.BaseEnttiy;
import com.doding.board.member.model.entity.Member;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;

import java.util.List;

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString(callSuper = true, exclude = {"writer", "replies"})
@SuperBuilder

@Entity
public class Post extends BaseEnttiy {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long pno;
    private String content;
    private String title;
    
    //private String writer;
    @ManyToOne
    @JoinColumn(name="writer")
    private Member writer;
    
    @OneToMany(mappedBy = "post") // Reply가 나를 post로 매핑하고 있어!!
    @Builder.Default              // lombok의 Builder 패턴에서 기본 값 사용 시 필요
    private List<Reply> replies = new ArrayList();
}
lombok의 Builder에서는 기존의 초기값을 지워버린다. 따라서 할당된 초기값을 유지하기 위해서 @Builder.Default를 추가한다.

이때 생성되는 post와 reply의 관계에 대한 DDL은 기존과 다를바가 없다. 어차피 DB입장에서는 처음부터 양방향이었기 때문이다. Reply에 @ManyToOne을 설정했을 때 이미 끝났다. 물론 post와 member는 새로 설정되었으므로 새로운 관계가 맺어질 것이다.

Post의 writer에 대한 정보를 변경했기 때문에 기존의 테스트에도 변화가 필요하다. PersistenceContextTest.java의 saveTest를 다음과 같이 변경해주자.

@Test
public void saveTest(){
  Member member = mrepo.getReferenceById(1L);
  Post p1 = Post.builder().title("t1").content("content1").writer(member).build();
  Post p2 = Post.builder().title("t2").content("content2").writer(member).build(); 
  // when
  prepo.save(p1);
  prepo.save(p2); // managed: 영속 상태로 변경
  prepo.flush();
  // then
  Assertions.assertNotNull(p1.getCreateTime());
}


이제 Post와 Reply에 양방향의 관계를 맺었으므로 상호간의 조회가 가능해질 것이다.

@Test
@DisplayName("N:1의 양방향 관계를 확인하자.")
public void ManyToOneBidirectionTest() {
  // given
  Post post = prepo.findById(1L).get();
  // when
  List<Reply> replies = post.getReplies(); // post를 통한 reply 조회
  // then
  replies.forEach(reply -> Assertions.assertSame(reply.getPost(), post)) ;// reply를 통한 post 조회
}

 

N:1 양방향 관계 설정 시 주의 사항


기존의 Post에 새로운 Reply를 추가해보자. 
테스트 의도는 먼저 Post를 하나 조회하고 여기에 새로운 Reply를 추가했을 때 'Post가 가진 Reply의 개수는 1개 증가하기'를 기대하는 내용이다.

@Test
@DisplayName("댓글을 추가하면 기존 댓글의 개수보다 하나가 더 많아진다.")
public void ManyToOneBidirectionTest2(){
  Post post = prepo.findById(1L).get();                     // 기존 Post 조회
  int replyCnt1 = post.getReplies().size();                 // 초기 댓글의 개수
  
  // Reply를 만들면서 Post에 등록한다.
  Reply reply = Reply.builder().writer(post.getWriter()).content("좋은 글입니다.")
                     .post(post).build();                   //새로운 댓글 생성
  rrepo.saveAndFlush(reply);                                // 쿼리 확인
  
  // 최종 댓글의 개수 조회 및 확인: Post는 이미 P.C에 있으므로 새로 조회하지 않음
  int replyCnt2 = post.getReplies().size();
  log.debug("초기 개수: {}, 최종 개수: {}", replyCnt1, replyCnt2);
  Assertions.assertEquals(replyCnt1+1, replyCnt2);
}

당연히 성공할 것 같지만 막상 테스트를 해보면 결과는 실패이다. Reply의 개수는 증가하지 않았다.

퀴리가 동작하지 않은 것일까?

Hibernate: post의 조회
    select        p1_0.pno,.., w1_0.mno,...
    from          post p1_0 
    left join    (member w1_0 left join paid_member w1_1 on w1_0.mno=w1_1.mno) 
                 on w1_0.mno=p1_0.writer 
    where        p1_0.pno=?

Hibernate: reply 조회??
    select        r1_0.pno,..., w1_0.mno,...
    from          reply r1_0 left join 
                        (member w1_0 left join paid_member w1_1 on w1_0.mno=w1_1.mno) 
                  on w1_0.mno=r1_0.writer 
    where         r1_0.pno=?

Hibernate: 
    insert     into        reply   (content, create_time, pno, writer, rno) 
    values     (?, ?, ?, ?, default)
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (1:VARCHAR) <- [좋은 글입니다.]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (2:TIMESTAMP) <- [2024-10-27T20:57:55.025238]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (3:BIGINT) <- [1]
[TRACE] [o.h.orm.jdbc.bind.logBinding.24] binding parameter (4:BIGINT) <- [1]
[DEBUG] [c.d.b.m.r.NTo1Test.bidirectionTest1.62] 초기 개수: 2, 최종 개수: 2

일단 Post 하나를 조회했는데 왜 Reply가 별도로 조회되었을까? 이 현상이 지연 로딩(Lazy Loading)인가? 라고 떠올릴 수 있으면 지금 상태에서는 충분하다. '왜 지연 로딩이 발생하지?'에 대한 궁금증은 다음시간에 살펴보자. 

지금은 insert 쿼리가 잘 실행되었다는 것에 대해 집중하자. 성공적으로 DB에 insert가 진행되었는데 왜 Post의 Reply 개수는 증가하지 않았을까?

테스트가 실패한 이유는 DB의 상황과 자바의 상황이 다르기 때문이다.

로그에서 살펴봤듯이 DB의 reply 테이블에는 데이터가 추가되었고 이것으로 양방향 처리는 더 이상 할게 없다. 하지만 자바에서는 reply.setPost(post)를 통해서 Reply에 Post를 저장했을 뿐 Post에는 Reply를 추가하지는 않았다. Post는 이미 영속상태이기 때문에 새롭게 조회를 하더라도 1차 캐시 차원에서 처리한다. 따라서 더 이상 조회(select)도 발생하지 않는다. 이처럼 Entity가 DB와의 일관성을 유지하지 못한다면 상당한 혼선이 올 것이다. 

DB와 객체는 다르다.

자바에서는 어떻게 양방향의 관리를 해줘야 할까?

 

연관관계 helper 메서드 작성

자바에서 관계의 양방향 관리를 위해 연관관계 helper 메서드를 작성한다. 이 메서드는 말 그대로 연관관계에 있는 엔티티 간의 관계 관리를 도와주는 메서드이다. 연관관계 helper 메서드는 비지니스 적으로 더 많이 사용되는 쪽, 편한 쪽에 작성해주면 된다.

  • Post에 Reply를 추가한다. : 주 엔티티를 위주로 생각하는 경우로 일반적으로 객체 중심의 코드 전개 방식이다.
  • Reply를 Post에 추가한다. : 보조 엔티티를 위주로 생각하는 경우로 일반적으로 DB 중심의 코드 전개 방식이다. 코드가 복잡해질 수 있다.

우리는 첫 번째 형태로 작성해보자. Post에 Reply를 추가하는 행위는 Reply의 원래 Post에서는 제거하고 새로운 Post에 등록해야 한다.

객체간의 연관관계는 직접 처리해주자.

여기서는 Post에 다음과 같이 Reply를 추가/삭제할 수 있는 메서드를 등록해보자.

@Entity
public class Post extends BaseEnttiy {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long pno;
    . . .

    public void addReply(Reply reply) {
        replies.add(reply);
        reply.setPost(this);
    }
    
    public void removeReply(Reply reply) {
        replies.remove(reply);
        reply.setPost(null);
    }
}

 

이제 단위테스트에서 기존 lombok의 builder 메서드인 post()를 통해서 설정했던 부분을 방금 추가한 setPost()를 통하도록 변경해보자.

//Reply reply = Reply.builder().writer(post.getWriter()).content("좋은 글입니다.").post(post).build();

Reply reply = Reply.builder().content("new Reply").writer(post.getWriter()).build();
post.addReply(reply);

이제 비로써 Post에 하나의 댓글이 추가되었다는 것을 자바 영역에서 확인할 수 있게 되었다.

N:1 관계에서 양 방향의 연관관계를 매핑하기 위해서는 양쪽 방향을 모두 관리해야 함을 잊지 말아야 한다. DB와 객체가 완전히 동일하지는 않다.

 

완성해보기

 

Member와 Reply, Post에 대한 관계 설정

Member에 Reply와 Post에 대한 @OneToMany 관계 설정 및 연관관계 helper 메서드 작성하고 테스트해보자.

더보기

 

@ToString(callSuper = true)
@Entity

public class Member extends BaseEnttiy {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long mno;

    @OneToMany(mappedBy = "writer")
    @Builder.Default
    @ToString.Exclude
    private List<Post> posts = new ArrayList<>();

    @OneToMany(mappedBy = "writer")
    @Builder.Default
    @ToString.Exclude
    private List<Reply> replies = new ArrayList<>();

    public void addReply(Reply reply) {
        replies.add(reply);
        reply.setWriter(this);
    }

    public void removeReply(Reply reply) {
        replies.remove(reply);
        reply.setWriter(null);
    }

    public void addPost(Post post) {
        posts.add(post);
        post.setWriter(this);
    }

    public void removePost(Post post) {
        posts.remove(post);
        post.setWriter(null);
    }
}

위 작업 후 Member의 Post가 성공적으로 추가되고 삭제 되는지 실행되는 SQL 문장과 엔티티의 상태를 통해 살펴보자.

 @Test
@DisplayName("1L에 해당하는 Member의 글을 2L에 해당하는 Member에게 이전해보자.")
public void memberBidirectionTest() {
    // given
    Member member1 = mrepo.findById(1L).get(); // 1L Member 조회
    int size1 = member1.getPosts().size();     // 1L의 post에 대한 lazy loading
    Post post = member1.getPosts().get(0);
    Member member2 = mrepo.findById(2L).get(); // 2L Member 조회
    int size2 = member2.getPosts().size();     // 2L의 post에 대한 lazy loading
    // when
    member1.removePost(post);
    member2.addPost(post);
    prepo.flush();                             // post에 대한 update
    // then
    Assertions.assertEquals(size1-1, member1.getPosts().size());
    Assertions.assertEquals(size2+1, member2.getPosts().size());
}

 

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.