이전에 JPA를 사용해서 1:N 연관관계 매핑을 할때는
연관관계 주인이 1 보다 N에 두는 것이 좋다고 배워서 관성적으로 N이 연관관계 주인으로, FK를 가지게 했습니다.
그러나 왜 연관관계 주인이 1이 되면 좋지 않은 건지 제대로 알지 못하고 사용하고 있었기 때문에
연관관계 주인이 1쪽이 되었을 때의 어떤 점이 안 좋은지를 살펴보려고 합니다!
예시는 Member와 Post의 1:N 관계로 설명하도록 하겠습니다!
(멤버가 여러 게시글을 작성할 수 있고, 게시물에는 1명의 작성자만 존재한다고 해봅시다!)
🎯 1. 연관관계의 주인이란?
들어가기에 앞서서, 연관관계의 주인이란 무엇을 말하는지 알아봅시다!
우선 '연관관계의 주인' 이라는 용어가 규칙으로 정해진 용어는 아니지만
JPA를 사용할 때 해당 용어로 많이 쓰이기 때문에 거의 관습적으로 굳어진 것 같습니다!
연관관계 주인이라는 용어를 이해하기 위해서는 테이블과 객체의 차이를 알아야 합니다.
간단하게 요약하자면, FK를 관리하는 쪽이 연관관계 주인입니다.
1-1. 테이블에서의 Member - Post 연관관계
위와 같이 테이블이 존재할 때, 테이블은 항상 1:N에서 N쪽이 FK를 가집니다.
따라서, FK를 관리하는 곳은 항상 N쪽이 됩니다.
그렇기 때문에 테이블 관계에서는 FK를 누가 관리하느냐의 선택은 발생하지 않습니다.
1-2. 객체(Entity)에서의 Member - Post 연관관계
엔티티에서 위의 상황을 생각해볼 때, Member와 Post 중에 어떤 엔티티에 FK를 둬야 할까요?
1:N 관계에서 항상 N쪽에서 FK를 관리했던 테이블과는 달리 엔티티에서는 1쪽과 N쪽 둘 다 FK를 관리할 수 있습니다.
따라서, 1과 N 중 누가 FK를 관리할지는 정하기 나름입니다.
이렇듯 엔티티에서는 1쪽과 N쪽 둘 다 FK를 관리할 수 있기 때문에,
JPA에서 FK를 관리하는 엔티티를 '연관관계의 주인' 이라고 많이 부르고 있습니다.
Member - Post에서 1쪽인 Member가 FK를 관리한다면 Member가 연관관계의 주인이고,
N쪽인 Post 엔티티가 FK를 관리한다면 Post가 연관관계의 주인입니다.
🎯 2. JPA 엔티티의 연관관계 주인이 1인 경우의 단점
보통 1:N 관계에서 엔티티의 연관관계 주인을 N으로 많이 설정합니다.
1:N 관계에서 엔티티의 연관관계 주인을 1으로 설정했을 때 어떤 문제가 발생하는지 살펴봅시다.
(방향은 단방향으로 설정하겠습니다!)
먼저 Member, Post 엔티티의 코드는 다음과 같습니다.
Member 엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String nickname;
@OneToMany
@JoinColumn(name = "member_id")
private List<Post> posts = new ArrayList<>();
public Member(final String nickname) {
this.nickname = nickname;
}
// 연관관계 메소드
public void addPost(final Post post) {
posts.add(post);
}
}
@OneToMany와 @JoinColumn을 사용하여 Member가 FK를 관리하도록 했습니다.
Post 엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
private String title;
private String content;
public Post(final String title, final String content) {
this.title = title;
this.content = content;
}
}
여기서 Member와 Post를 영속화하는 테스트를 작성해봅시다.
@Test
@DisplayName("연관관계 주인 1일 때 멤버와 포스트 연관관계 테스트")
void test1() {
Post post = new Post("테스트 제목", "테스트 내용");
Post savedPost = postRepository.save(post);
Member member = new Member("성하");
member.addPost(savedPost);
Member savedMember = memberRepository.save(member);
em.flush();
}
이렇게 테스트를 작성하고 실행해보면 어떤 쿼리가 나갈까요?
save를 2번했으므로 insert 쿼리 2번만 나갈까요?
결과를 살펴보면 다음과 같습니다.
기대했던 insert 쿼리 2개는 잘 나갔지만, 마지막에 update 쿼리가 나가는 것을 확인할 수 있습니다.
update 쿼리가 왜 나가는지는 테이블 기준으로 생각해보면 됩니다!
1:N에서 N쪽이 FK를 가지므로 Post 테이블을 생각해봅시다.
Post post = new Post("테스트 제목", "테스트 내용");
Post savedPost = postRepository.save(post);
테스트 코드의 해당 부분에서 Post를 생성하고 INSERT 쿼리를 날립니다.
이때, Post 엔티티에서는 FK를 관리하지 않으므로 INSERT 쿼리가 나갈 때 FK 데이터를 넣어주지 않습니다.
따라서, Post 테이블의 FK인 member_id는 null로 들어가게 되는 것입니다.
이후에 Member를 영속화할 때 다음과 같은 코드를 작성했습니다.
Member member = new Member("성하");
member.addPost(savedPost);
Member savedMember = memberRepository.save(member);
해당 코드의 member.addPost(savedPost) 부분에서 FK를 설정해주는 것을 알 수 있습니다.
Member를 Insert 할 때 영속화된 Post를 객체에 설정해준 상태로 save합니다.
하지만 이때, 테이블에서는 어떻게 FK를 설정해줘야 할까요?
현재 FK를 가진 Post 테이블의 상태는 FK인 member_id가 null인 상태입니다.
따라서, FK를 설정하려면 Member가 Insert될 때
해당 Member의 member_id를 Post의 member_id에 UPDATE 쿼리로 업데이트해야합니다.
따라서, UPDATE 쿼리가 하나 더 나간 것입니다.
쿼리 분석을 해봤으니, 어떤 점이 단점인지 살펴봅시다.
2-1. 불필요한 UPDATE 쿼리가 1번 더 나간다.
위의 쿼리에서 살펴봤듯이 처음 영속화 시에 FK가 null이 들어가기 때문에,
이후에 1쪽의 엔티티가 영속화될 때 N쪽의 FK를 업데이트 해줘야 합니다.
따라서 UPDATE 쿼리가 1번 더 나간다는 단점이 존재합니다.
이는 FK를 N쪽의 엔티티가 관리할 때, 즉 연관관계의 주인이 N쪽일 때와 비교했을 때 단점입니다.
이후에 살펴보겠지만, N쪽의 엔티티를 연관관계 주인으로 설정하면
위와 같은 코드를 실행시켰을 때 UPDATE 쿼리 없이 INSERT 쿼리만 2번 나가게 됩니다.
따라서, 연관관계 주인을 1쪽으로 설정하면 불필요한 UPDATE 쿼리가 나가서 성능적으로 좋지 않습니다.
2-2. 코드를 실행시킨 엔티티 객체와 쿼리가 나가는 테이블이 다르다.
다시 한번 위에서 본 UPDATE 쿼리가 실행되는 코드를 살펴봅시다.
Member member = new Member("성하");
member.addPost(savedPost);
Member savedMember = memberRepository.save(member);
Member 엔티티에서 영속화된 Post 객체를 추가한 후, 영속화하고 있습니다.
여기서 영속화되는 주체는 memberRepository.save(member)로 Member인 것을 알 수 있습니다.
이때, 실행되는 UPDATE 쿼리를 다시 한 번 살펴봅시다.
테이블 기준에서는 FK가 존재하는 곳이 N쪽인 Post 테이블이므로,
쿼리가 실행되는 곳이 Post 테이블입니다.
이렇게 객체에서는 코드 실행 주체가 Member인데, 나가는 쿼리의 실행 주체는 Post가 됩니다.
현재는 예시이기 때문에 관계가 복잡하지도 않고, 여러 테이블이 존재하지도 않아서
해당 단점이 크지 않다고 생각할 수 있습니다.
하지만 실무나 프로젝트에서 디버깅 시에 SQL 쿼리를 추적하다가 해당 이슈를 맞닥뜨리면
상당히 헷갈릴 수 있다고 생각합니다.
저 같은 경우 해당 상황을 생각해보면 update 쿼리가 Post 테이블에서 실행됐기 때문에
당연히 Post 엔티티를 중점적으로 살펴볼 것 같습니다.
이때, 코드를 실행시킨 것은 Member 엔티티이기 때문에 이러한 부분이 상당히 헷갈릴 것 같습니다.
개인적으로 UPDATE 쿼리가 한 번 더 나가는 단점보다는 이 단점이 훨씬 더 크다고 생각합니다.
🎯 3. JPA 엔티티의 연관관계 주인이 N인 경우
이번에는 연관관계 주인을 N으로 설정했을 때를 살펴보고 글을 마무리하겠습니다.
Member, Post 엔티티의 코드는 다음과 같습니다.
Member 엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String nickname;
public Member(final String nickname) {
this.nickname = nickname;
}
}
Post 엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
private String title;
private String content;
public Post(final String title, final String content) {
this.title = title;
this.content = content;
}
public void setMember(final Member member) {
this.member = member;
}
}
연관관계 주인이 1이었던 이전 코드와는 다르게 N인 Post가 Member 엔티티를 가지고
@ManyToOne, @JoinColumn으로 FK를 관리하고 있습니다.
이제 영속화하는 과정을 똑같이 살펴봅시다.
@Test
@DisplayName("연관관계 주인 N일 때 멤버와 포스트 연관관계 테스트")
void test2() {
Member member = new Member("성하");
Member savedMember = memberRepository.save(member);
Post post = new Post("테스트 제목", "테스트 내용");
post.setMember(savedMember);
Post savedPost = postRepository.save(post);
em.flush();
}
이전 코드와는 다르게 Post를 영속화할 때 영속화된 Member를 설정 후에 영속화해야 합니다.
따라서, Member를 영속화한 후에 post.setMember(savedMember)를 통해 영속화된 Member를 설정 후에
Post를 영속화했습니다.
쿼리를 확인해볼까요?
연관관계 주인이 1일때 나갔던 UPDATE 쿼리가 없고, INSERT 쿼리 2번으로 영속화되는 것을 알 수 있습니다.
UPDATE 쿼리가 안 나가는 이유는, Post를 영속화하는 코드를 살펴보면 됩니다.
Post post = new Post("테스트 제목", "테스트 내용");
post.setMember(savedMember);
Post savedPost = postRepository.save(post);
이전에 연관관계 주인이 1일 때는 Post를 영속화할 때 FK가 null인 상태로 영속화되었습니다.
그래서 이후에 해당 FK를 업데이트하는 UPDATE 쿼리가 나가야했습니다.
하지만 연관관계 주인이 N일 때인 위의 코드를 보면 Post가 영속화될 때
FK 필드인 Member를 설정하고 영속화하기 때문에 INSERT 시에 FK가 설정되어서 등록됩니다.
그렇기 때문에 연관관계 주인이 1일 때의 단점을 모두 해결할 수 있습니다.
🎯 3. 결론
요약해보면, 1:N 관계에서 연관관계의 주인은 1이 아니라 N으로 설정해야 합니다.
이유는, 연관관계 주인을 1로 설정했을 때의 단점이 다음과 같이 있기 때문입니다.
1. 불필요한 UPDATE 쿼리가 1번 더 나간다.
2. 코드를 실행시킨 엔티티 객체와 쿼리가 나가는 테이블이 다르다.
이러한 단점들을 연관관계 주인을 N으로 설정하면 해결할 수 있기 때문에
일반적으로 거의 모든 1:N 연관관계에서는 연관관계 주인을 N으로 설정하는 것이 좋다는 것을 알게 되었습니다!
'Spring > JPA' 카테고리의 다른 글
[JPA] JPA, Spring Data JPA의 내부 동작 원리 알아보기 (0) | 2024.08.05 |
---|---|
테스트 시 @ElementCollection 테이블 동적으로 가져오기(테스트 데이터 초기화) (0) | 2024.05.09 |
[JPA] JPA N+1 문제 및 근본적인 원인에 대한 개인적인 고찰 (6) | 2024.04.05 |
[JPA] @JoinColumn 파헤치기 (feat. JPA 연관관계별 사용) (6) | 2023.07.09 |