Backend/JPA

양방향 연관관계 매핑

olsohee 2024. 1. 9. 14:52

객체와 테이블의 차이

객체의 양방향 연관관계: 정확히 말하자면 객체에는 양방향 연관관계가 없다. 서로 다른 단방향 연관관계 2개가 양방향처럼 보일 뿐이다. 

  • 회원 -> 팀
  • 팀 -> 회원

테이블의 양방향 연관관계: 반면 데이터베이스 테이블은 외래 키 하나로 양쪽에서 서로 조인할 수 있다. 즉 외래 키 하나로 두 테이블은 양방향 연관관계를 맺는다.

  • 회원 <-> 팀

테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 따라서 엔티티를 단방향으로 매핑하면(단방향 연관관계), 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다. 그런데 엔티티를 양방향으로 매핑하면(양방향 연관관계), '회원 -> 팀', ' -> 회원' 두 곳에서 서로를 참조한다. 즉 테이블의 외래 키는 하나인데, 객체에서 연관관계를 관리하는 포인트는 2곳인 것이다.

연관관계의 주인

이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이를 연관관계의 주인이라 한다. 따라서 양방향 연관관계 매핑 시에는 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다. 그리고 연관관계의 주인만이 데이터베이스의 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 반면, 주인이 아닌 쪽은 읽기만 할 수 있다. 한마디로 연관관계 주인을 정한다는 것은 외래 키 관리자를 선택하는 것이라고 할 수 있다.

mappedBy

어떤 연관관계를 주인으로 정할지는 mappedBy 속성을 사용하면 된다.

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

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

회원과 팀의 관계를 보면, 한 명의 회원은 하나의 팀에 소속되어야 하지만, 하나의 팀에는 여러 명의 회원이 소속될 수 있다. 이 경우 회원 테이블이 데이터베이스의 연관관계를 관리하는 외래 키를 갖게 된다.

 

그렇다면 객체 연관관계에서는 어느 쪽을 연관관계의 주인으로 정해야 할까? 만약 회원 엔티티에 있는 Member.team을 주인으로 정하면, 자기 테이블에 있는 외래 키를 관리하면 된다. 반면 팀 엔티티에 있는 Team.members를 주인으로 정하면, 물리적으로 전혀 다른 테이블의 외래 키를 관리해야 한다. 즉 Team.members를 이용해서 엔티티의 속성을 변경하면 전혀 다른 테이블에 쿼리문이 나가게 되어 혼란을 주고 유지보수가 어려워진다. 따라서 연관관계의 주인은 테이블에 외래 키를 가지고 있는 곳(Member.team)으로 정해야 한다. 그리고 주인이 아닌 쪽에는 mappedBy 속성을 사용해서 주인이 아님을 설정해야 한다.

class Member {
    @ManyToOne
    @JoinColumn(name = "team_id") //@JoinColumn은 외래 키를 매핑한다.
    private Team team;
}
class Team {
    @OneToMany (mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

참고로 데이터베이스 테이블의 다대일, 일대다 관계에서는 항상 다 쪽이 외래 키를 가진다. 따라서 다 쪽인 @ManyToOne은 항상 연관관계의 주인이 되고 mappedBy 속성을 설정할 필요가 없기 때문에 @ManyToOne에는 mappedBy 속성이 없다.

연관관계의 주인이 아닌 쪽은 읽기만 가능


결과적으로 연관관계 주인인 Member.team만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 그리고 주인이 아닌 반대편은 읽기만 가능하고 외래 키를 변경하지는 못한다. 아래 코드의 결과로 회원 테이블의 team_id 외래 키에 'team1'이라는 team1 객체의 기본 키 값이 정상적으로 저장된다.

Team team1 = new Team("team1", "팀1"); // 기본 키 값 = "team1"
em.persist(team1);

Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); // member 테이블의 team_id 외래 키에 "team1"이라는 team1 객체의 기본 키 값이 저장된다.
em.persist(member1);

다음과 같은 코드가 추가로 있어야 할 것 같지만 Team.members는 연관관계의 주인이 아니므로 위의 코드는 데이터베이스에 저장할 때 무시된다. 따라서 연관관계의 주인이 외래 키를 관리하고 주인이 아닌 쪽은 값을 설정하지 않아도 데이터베이스에 외래 키 값이 정상적으로 입력된다.

team1.getMembers().add(member1);
  • member1.setTeam(team1): 연관관계 설정 (연관관계의 주인 O)
  • team1.getMembers().add(member1): 무시 (연관관계의 주인 X)

mappedBy를 지정하지 않으면

만약 mappedBy를 지정하지 않으면 일대다 관계의 경우에는 중간 테이블이 생성되고, 일대일 관계의 경우에는 각각의 테이블에 서로를 참조하는 FK가 설정된다.

양방향 연관관계의 주의점

연관관계의 주인에 값을 입력하지 않는 실수

양방향 연관관계를 설정하고 흔히 하는 실수는 연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하는 것이다. 연관관계의 주인만이 외래 키의 값을 변경할 수 있기 때문에 반드시 연관관계의 주인에 값을 입력해야 데이터베이스에 정상적으로 반영된다.

순수한 객체까지 고려한 양방향 연관관계(연관관계 편의 메소드)

그렇다면 연관관계의 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않아도 될까? 객체 관점에서는 양쪽 모두 값을 입력해주는 것이 안전하다. 만약 양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 상태(ex, JPA를 사용하지 않고 엔티티에 대한 테스트 코드를 작성하는 상황)에서 문제가 발생할 수 있다.

ex, 연관관계의 주인에만 값을 설정한 경우

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

//연관관계 설정
member1.setTeam(team1);
member2.setTeam(team1);

//조회
List<Member> members = team1.getMembers();
System.out.println(members.size()); //0

ex, 양쪽 모두 값을 설정한 경우

JPA를 사용하지 않는 상황을 고려하여 다음과 같이 양쪽 다 관계를 맺어주는 것이 안전하다.

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(team1);
team1.getMembers().add(member2);

//조회
List<Member> members = team1.getMembers();
System.out.println(members.size()); //2

 

결론적으로 양방향 연관관계는 양쪽 모두 관계를 맺어주어야 한다. 그런데 member.setTeam(team)과 team.getMembers().add(member)를 각각 호출하다 보면 실수로 둘 중 하나만 호출해서 양방향이 깨질 수 있다. 두 코드는 하나인 것처럼 사용해야 안전하다. 따라서 다음과 같이 메소드 하나로 양방향 관계를 모두 설정할 수 있도록 연관관계 편의 메소드를 사용하면 된다.

public class Member {

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
    
    public void saveTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

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

위 saveTeam() 메소드에는 문제가 있다.

member.saveTeam(team1);
member.saveTeam(team2);
Member findMember = team1.getMembers(); //member가 여전히 조회된다.

위와 같이 회원의 팀을 team1에서 team2로 변경하는 경우, member.getTeam()으로 조회하면 team2가, team2.getMembers()로 조회하면 member1이 정상적으로 조회된다. 그러나 team1.getMembers()로 조회하면 여전히 member가 조회된다. 즉 member의 팀을 team2로 변경할 때 'team1 -> member' 관계가 제거되지 않았다.

  • member -> team1 관계가 제거되고, member -> team2 관계 생성
  • team1 -> member 관계가 제거되지 않음

따라서 연관관계를 변경할 때는 기존 팀이 있으면 기존 팀과의 관계를 삭제하는 코드를 추가해야 한다.

public class Member {

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
    
    public void saveTeam(Team team) {
    
    	//기존 연관관계 제거
    	if(this.team != null) {
        	this.team.getMembers().remove(this);
        }
        
    	this.team = team;
        team.getMembers().add(this);
    }
}

정리

단방향 매핑과 비교해서 양방향 매핑은 복잡하다. 연관관계의 주인도 정해야 하고, 두 개의 단방향 연관관계를 양방향으로 만들기 위해 로직도 견고하게 작성해야 한다. 중요한 점은 연관관계가 하나인 단방향 매핑은 언제나 연관관계의 주인이라는 점이다. 그리고 양방향은 여기에 주인이 아닌 연관관계를 하나 추가했을 뿐이다. 즉 단방향과 비교해서 양방향의 장점은 반대방향으로 객체 그래프 탐색 기능이 추가된 것뿐이다.

member.getTeam(); // 회원 -> 팀 
team.getMembers(); // 팀 -> 회원 (양방향 매핑으로 추가된 기능, 단순 조회 기능(객체 그래프 탐색)만 가능하다.)

내용을 정리하면 다음과 같다.

  • 단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 이미 완료되었다.
  • 단방향을 양방향으로 만들면 반대방향으로 객체 그래프 탐색 기능이 추가된다.
  • 양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.

지금까지 알아본 것과 같이 양방향 매핑은 복잡하기 때문에, 우선 단방향 매핑을 사용하고 반대 방향으로 객체 그래프 탐색 기능이 필요할 때 양방향으로 코드를 추가하는 것이 좋다.


Reference

'Backend > JPA' 카테고리의 다른 글

JPQL의 소개와 기본 문법  (1) 2024.01.10
영속성 전이(cascade)와 고아 객체  (0) 2024.01.09
프록시와 지연 로딩  (1) 2024.01.09
기본 키 매핑  (1) 2024.01.09
엔티티 매니저와 영속성 컨텍스트  (0) 2024.01.05