이번 글에서는 <양방향 연관관계와 연관관계의 주인>에 대해 알아보겠습니다.
이 시리즈 글은 김영한 님의 강의, 책을 보고 적은 것임을 알려드립니다. (강추)
오타 및 피드백 환영합니다.

양방향 연관관계


이전 글에서는 회원에서 팀으로만 접근하는 다대일 단방향 매핑을 알아 보았습니다. 이번에는 팀에서 회원으로 접근하는 관계를 접근하는 관계를 추가해서, 양방향 연관관계로 매핑을 해보겠습니다.

image.png

먼저 객체 연관관계를 살펴보겠습니다.
위 그림을 보면, 회원과 팀은 다대일 관계입니다. 반대로 팀에서 회원은 일대다 관계입니다. 일대다 관계는 여러 건과 연관관계를 맺을 수 있으므로 컬렉션을 사용해야 합니다.

객체 연관관계는 다음과 같습니다.

  • 회원 ➡️ 팀 (Member.team)
  • 팀 ➡️ 회원 (Team.members)

image.png

위는 테이블 연관관계입니다.
이 그림을 처음 봤을 때 'TEAM 테이블에 왜 members가 없지...?', '책의 오타인가..?' 라고 생각했습니다.
하지만 강의를 더 듣고나서 위처럼 되는 것이 옳음을 알았습니다. 이제 TEAM 테이블이 위처럼 되는 이유를 살펴봅시다.

내가 소속된 팀 이름을 알고 싶으면 TEAM 테이블의 TEAM_ID로 MEMBER 테이블에 조인하면 됩니다.
팀에 소속된 멤버들을 알고 싶으면 MEMBER의 TEAM_ID로 TEAM 테이블에 join하면 됩니다.
따라서 데이터베이스 테이블은 외래 키 하나로 양방향으로 조회할 수 있습니다.

양방향 연관관계 매핑

이제 코드를 통해 양방향 관계를 매핑하는 것을 알아봅시다.

@Entity
public class Member {

  @Id
  @Column(name = "MEMBER_ID")
  private String id;

  private String username;

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

  // 연관관계 설정
  public void setTeam(Team team) {
    this.team = team;
  }

  // Getter, Setter ...
}
@Entity
public class Team {

  @Id
  @Column(name = "TEAM_ID")
  private String id;

  private String name;

  @OneToMany(mappedBy = "team")
  private List<Member> members = new ArrayList<Member>();

  // Getter, Setter ...

}

팀과 회원은 일대다 관계입니다. 따라서 팀 엔티티에 List<Member> members를 추가했습니다. 그리고 일대다 관계를 매핑하기 위해 @OneToMany 매핑 정보를 사용했습니다. @OneToMany의 mappedBy 속성은 양방향 매핑일 때 사용하는데, 반대쪽 매핑의 필드 이름을 값으로 주면 됩니다. 반대쪽 매핑이 Member.team이므로 team을 값으로 주었습니다. mappedBy는 뒤에서 더 알아보도록 하겠습니다.

일대다 컬렉션 조회

앞에서 배운 내용을 토대로 이제 일대다 방향으로 객체 그래프 탐색을 해보겠습니다.

// member1: (id=1, username="회원1", team="team1")
// member2: (id=2, username="회원2", team="team1")

Team team = em.find(Team.class, "team1");
List<Member> members = team.getMembers(); // (팀 -> 회원) 객체 그래프 탐색

for (Member member : members) {
  System.out.println("member.username = " + member.getUsername());
}

// === 결과 ===
// member.username = 회원1
// member.username = 회원2

연관관계의 주인


이전까지 @ManyToOne, @OneToMany에 대해 알아봤습니다. Team의 @OneToMany에 mappedBy 속성은 왜 있을까요?

엄밀히 이야기하면 객체에는 양방향 연관관계라는 것이 없습니다. 서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶어서 양방향인 것처럼 보이게 할 뿐입니다. 반면에 데이터베이스 테이블은 외래 키 하나로 양쪽이 서로 조인할 수 있습니다. 따라서 테이블은 외래 키 하나만으로 양방향 연관관계를 맺습니다.

객체 연관관계 = 2개

  • 회원 ➡️ 팀 : 연관관계 1개(단방향)
  • 팀 ➡️ 회원 : 연관관계 1개(단방향)
    테이블 연관관계 = 1개
  • 회원 ↔️ 팀 : 연관관계 1개(양방향)

엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나입니다. 따라서 둘 사이에 차이가 발생합니다. 그렇다면 둘 중 어떤 관계를 사용해서 외래 키를 관리해야 할까요?
이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래키를 관리해야 하는데 이것을 연관관계의 주인이라 합니다.

양방향 매핑의 규칙: 연관관계의 주인

양방향 연관관계 매핑 시 지켜야할 규칙이 있는데 두 연관관계 중 하나를 연관관계의 주인으로 정해야 합니다. 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있습니다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있습니다.
어떤 연관관계를 주인으로 정할지는 mappedBy 속성을 사용하면 됩니다.

  • 주인은 mappedBy 속성을 사용하지 않는다.
  • 주인이 아니면 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.

image.png

그렇다면 Member.team, Team.members 둘 중 어떤 것을 연관관계의 주인으로 정해야 할까요?

  • 회원 ➡️ 팀(Member.team) 방향

    class Member {
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    // ...
    }
  • 팀 ➡️ 회원(Team.members) 방향

    class Team {
    
    @OneToMany
    private List<Member> members = new ArrayList<>();
    // ...
    }

    연관관계의 주인을 정한다는 것은 사실 외래 키 관리자를 선택하는 것입니다.
    그림을 보면, 여기서는 회원 테이블에 있는 TEAM_ID 외래 키를 관리할 관리자를 선택해야 합니다. 만약 회원 엔티티에 있는 Member.team을 주인으로 선택하면 자기 테이블에 있는 외래 키를 관리하면 됩니다. 하지만 팀 엔티티에 있는 Team.members를 주인으로 선택하면 물리적으로 전혀 다른 테이블의 외래 키를 관리해야 합니다. 왜냐하면 이 경우 Team.members가 있는 Team 엔티티는 TEAM 테이블에 매핑되어 있는데 관리해야할 외래 키는 MEMBER에 있기 때문입니다.

    연관관계의 주인은 외래 키가 있는 곳

    연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 합니다. 여기서는 회원 테이블이 외래 키를 가지고 있으므로 Member.team이 주인이 됩니다. 주인이 아닌 Team.members에는 mappedBy="team" 속성을 사용해서 주인이 아님을 설정해야 합니다. 여기서 mappedBy의 값으로 사용된 team은 연관관계의 주인인 Member 엔티티의 team 필드를 말합니다.
    image.png

    정리하면, 연관관계의 주인만 데이터베이스 연관관계와 매핑되고 외래 키를 관리할 수 있습니다. 주인이 아닌 반대편은 읽기만 가능하고 외래 키를 변경하지 못합니다.

데이터베이스 테이블의 다대일, 일대다 관계에서는 항상 쪽이 외래 키를 가집니다. 다 쪽인 @ManyToOne은 항상 연관관계의 주인이 되므로 mappedBy를 설정할 수 없습니다. 따라서 @ManyToOne에는 mappedBy 속성이 없습니다.

양방향 연관관계 저장


양방향 연관관계를 사용해서 팀1, 회원1, 회원2를 저장해보겠습니다.

public void save() {

  // 팀1 저장
  Team team1 = new Team("team1", "팀1");
  em.persist(team1);

  // 회원1 저장
  Member member1 = new Member("member1", "회원1");
  member.setTeam(team1);  // 연관관계 설정 team1 -> team1
  em.persist(member1);

  // 회원2 저장
  Member member2 = new Member("member2", "회원2");
  member2.setTeam(team1); // 연관관계 설정 member2 -> team1
  em.persist(member2);
}

위 코드를 보면, 팀1을 저장하고 회원1, 회원2에 연관관계의 주인인 Member.team 필드를 통해서 회원과 팀의 연관관계를 설정하고 저장했습니다.
데이터베이스에서 회원 테이블을 조회해보겠습니다. ( SELECT * FROM MEMBER; )

MEMBER_ID USERNAME TEAM_ID
member1 회원1 team1
member2 회원2 team1

TEAM_ID 외래 키에 팀의 기본 키 값이 저장되어 있습니다.
양방향 연관관계는 연관관계의 주인이 외래 키를 관리합니다. 따라서 주인이 아닌 방향은 값을 설정하지 않아도 데이터베이스에 외래 키 값이 정상 입력됩니다.

team1.getMembers().add(member1); // 무시(연관관계의 주인이 아님)
team1.getMembers().add(member2); // 무시(연관관계의 주인이 아님)
이런 코드가 추가로 있어야 할 것 같지만 Team.members는 연관관계의 주인이 아닙니다. 주인이 아닌 곳에 입력된 값은 외래 키에 영향을 주지 않습니다.

양방향 연관관계의 주의점


Member member1 = new Member("member1", "회원1");
em.persist(member1);

Member member2 = new Member("member2", "회원2");
em.persist(member2);

Team team1 = new Team("team1", "팀1");
team1.getMembers().add(member1);
team1.getMembers().add(member2);

em.persist(team1);

회원1, 회원2를 저장하고 팀의 컬렉션에 담은 후에 팀을 저장했습니다. 과연 위 코드가 정상적으로 저장되었을까요?
위 코드를 실행하고 데이터 베이스에서 회원 테이블을 조회하면 다음과 같은 결과가 나옵니다.

MEMBER_ID USERNAME TEAM_ID
member1 회원1 null
member2 회원2 null

외래 키 TEAM_ID에 team1이 아닌 null 값이 입력되어 있는데, 연관관계의 주인이 아닌 Team.members에만 값을 저장했기 때문입니다. 다시 한번 강조하지만 연관관계의 주인만이 외래 키의 값을 변경할 수 있습니다.
연관관계의 주인인 Member.team에 아무 값도 입력하지 않았기 때문에, TEAM_ID 외래 키의 값도 null이 저장됩니다.

순수한 객체까지 고려한 양방향 연관관계

그렇다면 정말 연관관계의 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않아도 될까요?
사실은 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 안전합니다. 양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 상태에서 심각한 문제가 발생할 수 있습니다.

예를 들어 JPA를 사용하지 않고 엔티티에 대한 테스트 코드를 작성한다고 가정해봅시다. ORM은 객체와 관계형 데이터베이스 둘 다 중요합니다. 데이터베이스뿐만 아니라 객체도 함께 고려해야 합니다.

public void test(){

  Team team1 = new Team("team1", "팀1");
  Member member1 = new Member("member1", "회원1");
  Member member2 = new Member("member2", "회원2");

  member1.setTeam(team1);
  member2.setTeam(team2);

  List<Member> members = team1.getMembers();
  System.out.println("members.size = " + members.size());
}
// 결과: member.size = 0

코드를 보면 Member.team에만 연관관계를 설정하고 반대 방향은 연관관계를 설정하지 않았습니다. 마지막 줄에서 팀에 소속된 회원이 몇 명인지 출력해보면 결과는 0이 나옵니다. 이것은 우리가 기대하는 양방향 연관관계의 결과가 아닙니다.
따라서 양방향은 양쪽다 관계를 설정해야 합니다. 이처럼 회원➡️팀 을 설정하면 다음 코드처럼 반대방향인 팀➡️회원도 설정해야 합니다.
양쪽 모두 관계를 설정한 코드를 보겠습니다.

public void test(){

  Team team1 = new Team("team1", "팀1");
  Member member1 = new Member("member1", "회원1");
  Member member2 = new Member("member2", "회원2");

  member1.setTeam(team1);
  team1.getMembers().add(member1);

  member2.setTeam(team2);
  team1.getMembers().add(member2);

  List<Member> members = team1.getMembers();
  System.out.println("members.size = " + members.size());
}
// 결과: member.size = 2

양쪽 모두 관계를 설정하니 기대했던 결과값 2가 나옵니다.
이제 JPA를 사용해서 완성한 예제를 보겠습니다.

public void test(){

  Team team1 = new Team("team1", "팀1");
  em.persist(team1);

  Member member1 = new Member("member1", "회원1");
  member1.setTeam(team1);
  team1.getMembers().add(member1);
  em.persist(member1);

  Member member2 = new Member("member2", "회원2");
  member2.setTeam(team2);
  team1.getMembers().add(member2);
  em.persist(member2);
}

위와 같이 양쪽에 연관관계를 설정했습니다.
순수한 객체 상태에서도 동작하며, 테이블의 외래 키도 정상 입력됩니다.

연관관계 편의 메서드

양방향 연관관계는 결국 양쪽 다 신경 써야 합니다. member.setTeam(team)과 team.getMembers().add(member)를 각각 호출하다 보면 실수로 둘 중 하나만 호출해서 양방향이 깨질 수 있습니다.

그래서 Member 클래스의 setTeam() 메서드를 수정해서 코드를 리팩토링 해보겠습니다.

public class Member{

  private Team team;

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

setTeam() 메서드 하나로 양방향 관계를 모두 설정하도록 변경했습니다.
이렇게 수정한 메서드를 사용하는 코드를 보겠습니다.

public void test() {

  Team team1 = new Team("team1", "팀1");
  em.persist(team1);

  Member member1 = new Member("member1", "회원1");
  member1.setTeam(team1);
  em.persist(member1);

  Member member2 = new Member("member2", "회원1");
  member2.setTeam(team1);
  em.persist(member2);
}

이렇게 한 번에 양방향 관계를 설정하는 메서드를 연관관계 편의 메서드라 합니다.

연관관계 편의 메서드 작성 시 주의사항

member.setTeam(team1);
member.setTEam(team2);
Member findMember = teamA.getMember(); // member1이 여전히 조회된다.

teamB로 변경할 때 teamA ➡️ member1 관계를 제거하지 않았기 때문에 teamA.getMember() 메서드를 실행했을 때 member1이 남아있습니다. 따라서 연관관계를 변경할 때는 기존 팀이 있으면 기존 팀과 회원의 연관관계를 삭제하는 코드를 추가해야 합니다.
아래는 Member 클래스 setTeam() 메서드입니다.

public void setTeam(Team team){

  if(this.team != null) {    // this.team이 null이 아니면 이 member객체는 team이 있음을 의미
    this.team.getMembers().remove(this);        // 해당 팀의 멤버에서 삭제
  }
  this.team = team;
  team.getMembers().add(this);
}