티스토리 뷰

JPA

[JPA] 연관관계의 주인

Jaime.Lee 2022. 6. 30. 10:30

소스 코드는 여기 있습니다. (commit hash: e336ad7)

> git clone https://github.com/lcalmsky/jpa
> git checkout e336ad7

Overview

양방향 연관관계를 맺을 때 관리의 주체인 연관관계 주인에 대해 알아봅니다.

연관관계의 주인(Owner)

양방향 연관관계를 맺을 때 규칙은 다음과 같습니다.

  • 객체의 두 관계중 하나를 연관관계의 주인으로 지정
    • ex) Member 객체가 가진 Team이 주인이 될지 Team 객체가 가진 Members가 주인이 될지 정함
  • 연관관계의 주인만 FK를 관리(등록, 수정 등)
  • 주인이 아닌 쪽은 읽기만 가능
  • 주인이 아닌 쪽에서 mappedBy 속성으로 주인 지정

그렇다면 두 관계 중 어떤 것을 주인으로 지정하는 게 타당할까요?

결론부터 이야기하면 FK를 가진쪽이 주인이 되어야 합니다. (꼭 그런 것은 아니고 그렇게 해야 이해하기 쉽습니다.)

더 쉽게 이야기하면 @OneToOne, @ManyToOne 이런식으로 ~ToOne으로 끝나는 애너테이션이 붙는 쪽이 연관관계의 주인이 됩니다.

MemberTeam을 예로 들면, Member가 가진 Team이 주인이 됩니다.

이 말은 곧 Member에서 Team을 조회하여 조작(등록, 수정 등)할 수 있지만, 반대로 Team에서 Members를 가져와 값을 수정해도 아무일도 벌어지지 않고 단순 조회만 가능합니다.

관련해서 어떻게 동작하는지 테스트를 통해 확인해보겠습니다.

테스트

/src/test/java/com/tistory/jaimenote/jpa/infra/repository/TeamRepositoryTest.java

package com.tistory.jaimenote.jpa.infra.repository;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;

import com.tistory.jaimenote.jpa.domain.entity.Member;
import com.tistory.jaimenote.jpa.domain.entity.Team;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.annotation.Rollback;

@DataJpaTest
class TeamRepositoryTest {

  @Autowired
  TeamRepository teamRepository;
  @Autowired
  MemberRepository memberRepository;

  @Test
  @DisplayName("연관관계의 주인이 아닌 곳에서 업데이트를 해도 아무 변화 없음")
  @Rollback(false)
  void test() {
    Team team = Team.withName("토트넘"); // (1)
    teamRepository.save(team);
    Member member = Member.withName("손흥민"); // (2)
    team.getMembers().add(member); // (3)
    memberRepository.save(member); // (4)
    assertNull(member.getTeam());
    assertFalse(team.getMembers().isEmpty()); 
  }
}

(1) Team을 생성하고 저장합니다.
(2) Member를 생성합니다.
(3) TeamMember를 추가합니다.
(4) Member를 저장합니다.

결과는 다음과 같습니다.

테스트에 성공하였다는 것은 member.getTeamnull이라는 것이고 team.getMembers()는 비어있지 않다는 뜻인데요, Team에는 Member가 추가되었지만 Member에는 Team이 추가되지 않았습니다.

Hibernate: 
    insert 
    into
        team
        (name, id) 
    values
        (?, ?)
2022-06-27 22:12:31.957 TRACE 31174 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [토트넘]
2022-06-27 22:12:31.958 TRACE 31174 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
Hibernate: 
    insert 
    into
        member
        (name, team_id, id) 
    values
        (?, ?, ?)
2022-06-27 22:12:31.960 TRACE 31174 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [손흥민]
2022-06-27 22:12:31.961 TRACE 31174 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [null]
2022-06-27 22:12:31.961 TRACE 31174 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [2]

로그에도 team 부분은 null로 처리된 것을 확인할 수 있습니다.

객체입장에선 너무 당연한 내용이기도 합니다.

그렇다면 반대로 Member에는 Team을 설정해주고 Team에는 Member를 추가하지 않았을 때는 어떻게 될까요?

DB 기준으로 봤을 때는 매우 정상적인 데이터가 들어가게 됩니다.

team 테이블에는 애초에 members라는 컬럼을 가지고있지 않기 때문인데요, 이 부분은 나중에 다양한 join 전략을 이용해서 컬럼을 추가할 수도 있고 매핑 테이블을 추가할 수도 있지만 지금 단계에서는 생략하도록 하겠습니다.

해결하는 방법도 너무 당연하고 쉽습니다. 바로 연관관계의 주인에도 동일하게 값을 추가해주어야 합니다.

// 생략
@DataJpaTest
class TeamRepositoryTest {

  @Autowired
  TeamRepository teamRepository;
  @Autowired
  MemberRepository memberRepository;
  // 생략
  @Test
  @DisplayName("연관관계의 주인에도 동일하게 값을 추가해 줘야함")
  @Rollback(false)
  void test2() {
    Team team = Team.withName("토트넘");
    teamRepository.save(team);
    Member member = Member.withName("손흥민");
    team.getMembers().add(member);
    member.join(team); // member에 team을 설정합니다.
    memberRepository.save(member);
    assertNotNull(member.getTeam());
    assertFalse(team.getMembers().isEmpty());
  }
}
TeamRepositoryTest.java 전체 보기
package com.tistory.jaimenote.jpa.infra.repository;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import com.tistory.jaimenote.jpa.domain.entity.Member;
import com.tistory.jaimenote.jpa.domain.entity.Team;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.annotation.Rollback;

@DataJpaTest
class TeamRepositoryTest {

  @Autowired
  TeamRepository teamRepository;
  @Autowired
  MemberRepository memberRepository;

  @Test
  @DisplayName("연관관계의 주인이 아닌 곳에서 업데이트를 해도 아무 변화 없음")
  @Rollback(false)
  void test() {
    Team team = Team.withName("토트넘");
    teamRepository.save(team);
    Member member = Member.withName("손흥민");
    team.getMembers().add(member);
    memberRepository.save(member);
    assertNull(member.getTeam());
    assertFalse(team.getMembers().isEmpty());
  }

  @Test
  @DisplayName("연관관계의 주인에도 동일하게 값을 추가해 줘야함")
  @Rollback(false)
  void test2() {
    Team team = Team.withName("토트넘");
    teamRepository.save(team);
    Member member = Member.withName("손흥민");
    team.getMembers().add(member);
    member.join(team);
    memberRepository.save(member);
    assertNotNull(member.getTeam());
    assertFalse(team.getMembers().isEmpty());
  }
}
Hibernate: 
    insert 
    into
        team
        (name, id) 
    values
        (?, ?)
2022-06-27 22:17:59.442 TRACE 42145 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [토트넘]
2022-06-27 22:17:59.443 TRACE 42145 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
Hibernate: 
    insert 
    into
        member
        (name, team_id, id) 
    values
        (?, ?, ?)
2022-06-27 22:17:59.446 TRACE 42145 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [손흥민]
2022-06-27 22:17:59.446 TRACE 42145 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [1]
2022-06-27 22:17:59.446 TRACE 42145 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [2]

Team에 해당하는 ID가 파라미터로 전달된 것을 확인할 수 있습니다.

주의할 점 및 개선 방법

방금 위에서 설명한 것처럼 양방향 관계에 있어서 데이터 조작이 발생할 때는 반드시 양쪽을 모두 수정해주어야 합니다.

보통 편의를 위해 메서드를 새로 작성하는데, 아래 처럼 수정할 수 있습니다.

/src/main/java/com/tistory/jaimenote/jpa/domain/entity/Member.java

// 생략
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
public class Member {
  // 생략
  public void join(Team team) {
    this.team = team;
    team.getMembers().add(this);
  }
}

MemberTeam을 설정할 때 Teammembers에도 동일하게 추가되도록 메서드를 구현합니다.

그리고 연관관계의 주인인 Member에서만 Team 관련 사항을 조작하면 됩니다.

Member.java 전체 보기
package com.tistory.jaimenote.jpa.domain.entity;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
public class Member {

  @Id
  @GeneratedValue
  private Long id;

  private String name;

  @ManyToOne
  @JoinColumn(name = "team_id")
  private Team team;

  private Member(String name) {
    this.name = name;
  }

  public static Member withName(String name) {
    return new Member(name);
  }

  public void join(Team team) {
    this.team = team;
    team.getMembers().add(this);
  }
}

이전 테스트도 아래 처럼 수정될 수 있습니다.

  @Test
  @DisplayName("연관관계의 주인에도 동일하게 값을 추가해 줘야함")
  @Rollback(false)
  void test2() {
    Team team = Team.withName("토트넘");
    teamRepository.save(team);
    Member member = Member.withName("손흥민");
    member.join(team);
    memberRepository.save(member);
    assertNotNull(member.getTeam());
    assertFalse(team.getMembers().isEmpty());
  }

추가로 주의해야 할 점 중에 순환 참조가 발생할 수 있다는 점인데요, lombok을 사용하게되면 @ToString과 같은 애너테이션을 습관적으로 붙여서 사용할 수 있는데, 그럼 서로가 참조하면서 계속 toString이 호출될 수 있습니다.

비슷하게 JSON 관련 매핑하는 기능을 가진 애너테이션을 사용할 때도 주의해야합니다.

정리

단방향 매핑만으로도 이미 연관관계 매핑은 완료되었다고 할 수 있습니다.

단지 반대 방향으로 조회가 필요할 경우(객체 그래프 탐색)에만 양방향 관계를 설정합니다.

실수를 줄이기 위해서는 단방향 매핑을 우선적으로 설정한 뒤, 필요할 경우에만 역방향도 설정하여 양방향 관계를 만드는 게 일반적입니다.

'JPA' 카테고리의 다른 글

[JPA] 다양한 연관관계 매핑  (0) 2022.07.04
[JPA] 연관관계 매핑 예제  (0) 2022.07.03
[JPA] 양방향 연관관계  (0) 2022.06.29
[JPA] 단방향 연관관계  (0) 2022.06.28
스프링 부트 JPA 예약어 컬럼명 사용하기  (0) 2022.06.27
댓글