연관관계 매핑이 필요한 이유를 알아보자.
‘객체 지향 설계의 목표는 자율적인 객체들이 협력 공동체를 만드는 것이다.’ _ 객체지향의 사실과 오해
기본적으로 객체의 구조와 테이블의 구조가 다르기 때문에 연관관계 매핑이 필요하다. 그 예시를 보자.
문제점
- 회원과 팀이 있고, 회원은 하나의 팀에만 소속될 수 있다.
- 회원과 팀은 다대일 관계이다.
위와 같은 사항을 지키는 테이블 설계를 해보자.
객체를 테이블에 맞춘 모델링
객체에 테이블의 외래키 값인 teamId가 객체의 속성에 추가된다. 참조 대신 외래키를 그대로 사용하게 되는 것이다.
이렇게 되면 이전 포스트에서 다루었던 문제가 발생하는 것이다.
1
2
3
4
5
6
7
8
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId()); //이 부분이 문제이다.
em.persist(member);
멤버를 저장하는데 팀에서 id를 가져와서 저장해야 한다. 조회할때도 마찬가지다. 연관관계가 있는 객체의 id를 따로 조회해야 하는 과정을 거쳐야 하는 것이다.
- 테이블은 외래 키로 조인을 사용해 연관된 테이블을 찾는다.
- 반면 객체는 참조를 사용해 연관된 객체를 찾는다.
- 이러한 테이블과 객체의 차이때문에 문제가 발생한다.
단방향 연관관계
이번엔 객체지향적인 모델링을 보자.
테이블에서는 team_id를 외래 키로 연관된 테이블을 찾고, Member 객체에서는 참조를 사용하도록 했다.
코드로 한번 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
//@Column(name = "TEAM_ID")
//private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
//...
}
애노테이션을 통해 Team과 외래 키를 매핑할 수 있는 것이다.
이러면 저장하는 코드가 객체를 객체스럽게 사용하듯 바뀌게 된다.
1
2
3
4
5
6
7
8
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeam(team); //team == TeamA
em.persist(member);
조회도 마찬가지가 되는 것이다. getTeam() 메서드를 사용해 Team 객체를 바로 조회할 수 있다.
양방향 연관관계
양방향 연관관계를 봐보자. 테이블에서는 바뀐 것이 없고, 객체를 보면 Team에서도 Member를 참조할 수 있도록 하는 것이다.
테이블의 입장에서는 원래 외래 키 하나로 양방향 매핑이 되는 것이다. 하지만 객체 입장에서는 members라는 List를 세팅해주지 않으면 불가능하다.
이런 부분이 객체 참조와 테이블의 차이인 것이다.
코드로 보자.
- Member
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
//@Column(name = "TEAM_ID")
//private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
//...
}
기존의 단방향 연관관계와 똑같다.
- Team
1
2
3
4
5
6
7
8
9
10
11
12
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<Member>();
//...
}
1
2
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size();
양방향 매핑을 해두면 반대 방향으로 객체 그래프를 탐색할 수 있게 되는 것이다.
mappedBy (연관관계의 주인)
이 부분이 어려운 부분이다. 객체와 테이블간에 연관관계를 맺는 것에 대한 근본적인 차이가 발생하는데 그 부분을 이해해야 한다.
- 객체 연관관계 = 2개
- 회원 -> 팀 연관관계 1개 (단방향)
- 팀 -> 회원 연관관계 1개 (단방향)
- 테이블 연관관계 = 1개
- 회원 < - > 팀 연관관계 1개 (양방향)
객체의 양방향 관계는 사실상 양방향 관계가 아닌 서로 다른 단방향 관계 2개인 것이고, 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리하며 양방향 연관관계를 가지는 차이가 있다.
여기서 딜레마가 발생한다.
만약 내가 Member를 수정하고 싶다. (어떤 멤버의 팀을 바꾸고 싶다.)
그렇다면, Member에 있는 Team을 수정해야 할지, 아니면 Team에 있는 members List를 수정해야 할 지 헷갈린다. 사실상 둘 다 바꾸는게 맞는 것 같기도 하다.
하지만 테이블 입장에서는 외래 키인 team_id만 업데이트 되면 된다.
그래서 어떤 하나의 룰이 생기게 되었다.
=> 둘 중 하나로 외래 키를 관리해야 한다.
이 부분이 바로 연관관계의 주인이라는 용어가 나온 이유이다.
연관관계의 주인
양방향 연관관계에서 나오는 용어이다.
- 객체의 두 관계 중 하나를 연관관계의 주인으로 지정한다.
- 연관관계의 주인만이 외래 키를 관리한다. (등록 및 수정)
- 주인이 아닌쪽은 읽기만 가능하다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아니라면 mappedBy 속성으로 주인을 지정해주어야 한다.
그래서 누구를 주인으로 지정해야 할까?
! 외래키가 있는 곳을 주인으로 정한다.
=> 즉 위의 예시에서는 Member.team이 연관관계의 주인이다.
외래 키가 있는 곳은 N:1 관계에서 N에 속한다. N쪽이 연관관계의 주인이 되는 것이다. (ManyToOne)
이유로는 성능 이슈도 있고, 일례로 팀 객체에 어떤 작업을 했는데 다수에 속하는 Member 테이블이 우수수 업데이트 되는 등의 문제가 있다.
그래서 결론적으로 기준을 외래 키가 있는 곳을 연관관계의 주인, 즉 업데이트 권한이 있는 쪽이라고 잡는 것이다.
가장 많이 하는 실수
1
2
3
4
5
6
7
8
9
10
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
team.getMembers().add(member); //주인이 아닌 방향만 연관관계 설정
em.persist(member);
team의 getMembers()를 통한 members는 mappedBy, 즉 읽기 전용인 상태이다. JPA에서 업데이트나 인서트를 할 때 team의 members 컨테이너는 고려하지 않는다는 뜻이다.
이렇게 주인이 아닌 부분만 값을 넣어주게 되면 DB에 정상적으로 반영되지 않는다. 반대로 주인에만 값을 넣어준다면 DB에는 정상적으로 반영된다.
- 다만 순수 객체 상태를 고려해 항상 양쪽에 값을 설정하는 것을 권장한다.
- 만약 em.flush() 전에, 즉 DB에 값이 올라가기 전/영속성 컨텍스트에 단순히 영속되어있는 상태일 때 조회를 한다고 가정해보자.
- flush() 이후 DB에서 데이터를 꺼내오는 것이라면 주인에만 값을 넣어주어도 정상적으로 값을 가져올 수 있을 것이다.
- 하지만 flush() 이전이라면, 1차 캐시(메모리)에 있는 값을 가져올 것이고 정상적인 데이터 값을 가져올 수 없음을 의미한다.
- 주인인 member에만 team값을 세팅했다면, team에서 members를 조회했을 때 member 값은 flush() 전이라 DB에 반영되지 않았으므로 빈 컬렉션이 조회될 것이다.
- 연관관계 편의 메서드를 생성하자.
- 예를 들어 주인인 member의 setTeam() 메서드에 team에도 해당 member를 추가하는 메서드를 넣는 것이다.
- 메서드를 하나만 호출해도 양방향 데이터 세팅이 되도록 하는 것.
- 양방향 매핑 시 무한 루프를 조심하자.
- ex: toString(), lombok, JSON 생성 라이브러리
- 예를 들면 member의 toString()을 보면 team.toString()을 호출하는 구조이고, team의 toString을 보면 member.toString()을 호출하는 무한 루프 구조이다.
- ex: toString(), lombok, JSON 생성 라이브러리
양방향 매핑 정리
- 단방향 매핑만으로도 이미 연관관계 매핑은 완료된 것이다.
- 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색)를 가능하도록 기능을 추가한 것 뿐이다.
- JPQL에서 역방향으로 탐색할 일이 많다.
- 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 된다.
- 테이블에 영향을 주지 않는다. (외래 키는 그대로이기 때문)
작업 전 설계는 단방향 매핑만을 하도록 해놓고, 작업을 하면서 필요 시 양방향 매핑을 추가하면 된다. 단방향 매핑을 해놓으면 양방향 매핑은 필요 시 코드 몇 줄 추가하면 되기 때문이다. 테이블에는 영향이 없다.
양방향 매핑은 사실 필요하지 않다면 오히려 복잡도만 증가시킨다. 연관관계 편의 메서드를 생성해야하고 신경써야하는 부분이 많아진다.
- 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안된다.
- 연관관계의 주인은 외래 키의 위치를 기준으로 선택하는 것을 권장한다. (N:1의 N)
Member - Order - OrderItem - Item 과의 관계를 생각해보자.
Member는 여러 개의 Order를 가질 수 있다.
Order는 여러 개의 OrderItem을 가질 수 있다.
Item은 여러 개의 OrderItem에 속할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
//Order
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;
//OrderItem
@ManyToOne
@JoinColumn(name = "ORDER_ID")
private Order order;
@ManyToOne
@JoinColumn(name = "ITEM_ID")
private Item item;
Many는 해당 클래스, One은 Item과 같은 클래스 멤버 값으로 생각하면 된다.
ex) Order = Many / Member = One => 하나의 Member는 여러 개의 주문을 할 수 있다. 연관관계의 주인은 Order.member 이다.