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와의 일관성을 유지하지 못한다면 상당한 혼선이 올 것이다.
자바에서는 어떻게 양방향의 관리를 해줘야 할까?
연관관계 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());
}
'Spring Model > 03. 연관관계 처리' 카테고리의 다른 글
06. 연관 관계의 관리 - 영속성 전이와 고아객체 관리 (0) | 2020.06.06 |
---|---|
05. 연관 관계 관리 - fetch 속성 (0) | 2020.06.05 |
04. 연관 관계 - M:N 관계 (0) | 2020.06.04 |
03. 연관 관계 - 1:1 관계 (0) | 2020.06.04 |
01. 연관 관계 - N:1 단방향 관계 처리 (0) | 2020.06.03 |
소중한 공감 감사합니다