정확히 말하면 객체에는 양방향 연관관계라는 것이 없다. 서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 묶어서 양방향인 것처럼 보이게 할 뿐이다.
회원 -> 팀 연관관계 1개 (단방향)
팀 -> 회원 연관관계 1개 (단방향)
반면 데이터베이스 테이블은 외래 키 하나로 양쪽에서 서로 조인할 수 있다. 즉 테이블은 외래 키 하나로 두 테이블은 양방향 연관관계를 맺는다.
테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다. 따라서 엔티티를 단방향으로 매핑하면 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다. 그런데 엔티티를 양방향으로 매핑하면 '회원 -> 팀', '팀 -> 회원' 두 곳에서 서로를 참조한다. 즉 테이블의 외래 키는 하나인데, 객체의 연관관계를 관리하는 포인트는 2곳인 차이가 발생한다.
이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이를 연관관계의 주인이라 한다. 따라서 양방향 연관관계 매핑 시에는 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다. 그리고 연관관계의 주인만이 데이터베이스의 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 반면, 주인이 아닌 쪽은 읽기만 할 수 있다. 한마디로 연관관계 주인을 정한다는 것은 외래 키 관리자를 선택하는 것이라고 할 수 있다.
어떤 연관관계를 주인으로 정할지는 mappedBy 속성을 사용하면 된다.
주인은 mappedBy 속성을 사용하지 않는다.
주인이 아니면 mappedBy 속성을 사용해서, 속성 값으로 연관관계 주인을 지정해야 한다.
회원과 팀의 관계를 보면, 한 명의 회원은 하나의 팀에 소속되어야 하지만, 하나의 팀에는 여러 명의 회원이 소속될 수 있다. 이 경우 회원 테이블이 데이터베이스의 연관관계를 관리하는 외래 키를 갖게 된다.
그렇다면 객체 연관관계에서는 어느 쪽을 연관관계의 주인으로 정해야 할까? 만약 회원 엔티티에 있는 Member.team
을 주인으로 정하면, 자기 테이블에 있는 외래 키를 관리하면 된다. 반면 팀 엔티티에 있는 Team.members
를 주인으로 정하면, 물리적으로 전혀 다른 테이블의 외래 키를 관리해야 한다. 즉 Team.members
를 이용해서 엔티티의 속성을 변경하면 전혀 다른 테이블에 쿼리문이 나가게 되어 혼란을 주고 유지보수가 어려워진다.
따라서 연관관계의 주인은 테이블에 외래 키를 가지고 있는 곳(Member.team
)으로 정해야 한다. 그리고 주인이 아닌 쪽에는 mappedBy 속성을 사용해서 주인이 아님을 설정해야 한다.
class Member {
@ManyToOne
@JoinColumn(name = "team_id") //@JoinColumn은 외래 키를 매핑한다.
private Team team;
}
calss Team {
@OneToMany (mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
참고
데이터베이스 테이블의 다대일, 일대다 관계에서는 항상 다 쪽이 외래 키를 가진다. 따라서 다 쪽인 @ManyToOne은 항상 연관관계의 주인이 되고 mappedBy 속성을 설정할 필요가 없다. 따라서 @ManyToOne에는 mappedBy 속성이 없다.
결과적으로 연관관계 주인인 Member.team만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있다. 그리고 주인이 아닌 반대편은 읽기만 가능하고 외래 키를 변경하지는 못한다.
Team team1 = new Team("team1", "팀1");
em.persist(team1);
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1);
em.persist(member1);
위 코드 결과로 회원 테이블의 team_id
외래 키에 'team1'이라는 team1 객체의 기본 키 값이 정상적으로 저장된다.
team1.getMembers().add(member1);
위와 같은 코드가 추가로 있어야 할 것 같지만 Team.members
는 연관관계의 주인이 아니므로 위의 코드는 데이터베이스에 저장할 때 무시된다. 따라서 연관관계의 주인이 외래 키를 관리하고 주인이 아닌 쪽은 값을 설정하지 않아도 데이터베이스에 외래 키 값이 정상적으로 입력된다.
member1.setTeam(team1);
: 연관관계 설정 (연관관계의 주인 O)
team1.getMembers().add(member1);
: 무시 (연관관계의 주인 X)
양방향 연관관계를 설정하고 흔히 하는 실수는 연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하는 것이다. 연관관계의 주인만이 외래 키의 값을 변경할 수 있기 때문에 반드시 연관관계의 주인에 값을 입력해야 데이터베이스에 정상적으로 반영된다.
그렇다면 연관관계의 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않아도 될까? 사실 객체 관점에서는 양쪽 모두 값을 입력해주는 것이 안전하다. ORM은 객체와 관계형 데이터베이스 둘 다 중요하다. 데이터베이스 뿐만 아니라 객체도 함께 고려해야 한다. 만약 양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 상태(ex, JPA를 사용하지 않고 엔티티에 대한 테스트 코드를 작성하는 상황)에서 문제가 발생할 수 있다.
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
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
JPA를 사용하지 않는 상황을 고려하여 위와 같이 양쪽 다 관계를 맺어주는 것이 안전하다.
JPA를 사용해서 완성한 코드는 다음과 같다.
Team team1 = new Team("team1", "팀1");
em.persist(team1);
Member member1 = new Member("member1", "회원1");
member1.setTeam(team1); //연관관계 설정 (member1 -> team1)
team1.getMembers().add(member1); //연관관계 설정 (team1 -> member1)
em.persist(member1);
Member member2 = new Member("member2", "회원2");
member2.setTeam(team1); //연관관계 설정 (member2 -> team1)
team1.getMembers().add(member2); //연관관계 설정 (team1 -> member2)
em.persist(member1);
위와 같이 양쪽에 연관관계를 설정하면 순수한 객체 상태에서도 정상적으로 동작하고 테이블의 외래 키도 정상 입력된다. 물론 Team.members
는 연관관계의 주인이 아니기 때문에 데이터베이스에 저장할 때 무시된다.
결론적으로 양방향 연관관계는 양쪽 모두 관계를 맺어주어야 한다. 그런데 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);
}
}
public void test() {
Team team1 = new Team("team1", "팀1");
em.persist(team1);
Member member1 = new Member("member1", "회원1");
member1.saveTeam(team1); //연관관계 설정 (member1 <-> team1)
em.persist(member1);
Member member2 = new Member("member2", "회원2");
member2.saveTeam(team1); //연관관계 설정 (member2 <-> team1)
em.persist(member1);
}
위 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(); //팀 -> 회원 (양방향 매핑으로 추가된 기능, 단순 조회 기능(객체 그래프 탐색)만 가능하다.)
내용을 정리하면 다음과 같다.
단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 이미 완료되었다.
단방향을 양방향으로 만들면 반대방향으로 객체 그래프 탐색 기능이 추가된다.
양방향 연관관계를 매핑하려면 객체에서 양쪽 방향을 모두 관리해야 한다.
지금까지 알아본 것과 같이 양방향 매핑은 복잡하기 때문에, 우선 단방향 매핑을 사용하고 반대 방향으로 객체 그래프 탐색 기능이 필요할 때 양방향으로 코드를 추가하는 것이 좋다.