이번 섹션에선 객체와 테이블의 연관관계 차이를 이해한 뒤, 객체의 참조와 테이블의 외래 키를 매핑하는 방법에 대해 학습한다. 아래에 있는 핵심 용어만 잘 이해해 두면 된다.
방향(Direction): 단방향, 양방향
다중성(Multiplicity): 다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
💫연관관계의 주인(Owner): 객체 양방향 연관관계는 관리 주인이 필요함
1. 연관관계의 필요성
객체지향스러운 설계를 이해하기 위해 아래 책(객체지향의 사실과 오해)과 <오브젝트>를 참고하면 좋다.
객체지향 설계의 목표는
자율적인 객체들의 협력 공동체를 만드는 것이다.
- 조영호(객체지향의 사실과 오해) -
객체의 참조와 테이블의 외래 키
아래와 같은 예제 시나리오를 생각해 보자.
1. 회원과 팀이 존재한다.
2. 회원은 하나의 팀에만 소속될 수 있다.
3. 회원과 팀은 다대일(N:1) 관계다.
객체를 테이블에 맞춰 모델링하면 아래 사진처럼 표현할 수 있다. 다대일 관계에서 외래 키는 '다' 쪽인 테이블에 존재한다. 객체 참조 대신 테이블에 있는 외래 키를 그대로 사용해 연관관계를 맺기 때문에 코드로 표현하면 외래 키를 Long teamId처럼 만들어야 한다.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
@Column(name = "TEAM_ID")
private Long teamId;
...
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
...
}
따라서 회원 객체에서 외래 키 식별자를 직접 다뤄야 한다. 조회할 때도 식별자를 사용해 조회한다. 이런 방식은 객체 지향적이라고 하기 어렵다.
// 팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
// 회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId()); // 외래 키 식별자 직접 다루기 <-> member.setTeam(team);
em.persist(member);
// 조회
Member findMember = em.find(Member.class, member.getId());
// 연관관계가 없음
Long findTeamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, findTeamId);
객체를 테이블에 맞춰 데이터 중심으로 모델링하면 협력 관계를 만들 수 없다. JPA는 이런 테이블과 객체의 간극을 줄여주는 역할을 한다.
- 테이블 = 외래 키 조인을 통해 연관된 테이블을 찾음
- 객체 = 참조를 사용해 연관된 객체를 찾는다.
@JoinColumn
외래 키를 매핑할 때 사용한다. 속성은 아래와 같다.
속성 | 설명 | 기본값 |
name | 매핑할 외래 키 이름 | 필드명 + _ + 참조하는 테이블의 기본 키 컬럼명 |
referencedColumnName | 외래 키가 참조하는 대상 테이블의 컬럼명 | 참조하는 테이블의 기본 키 컬럼명 |
foreignKey (DDL) | 외래 키 제약조건을 직접 지정할 수 있다. 이 속성은 테이블을 생성할 때만 사용한다. |
|
unique, nullable, insertable, updatable, columnDefinition, table |
@Column의 속성과 같다. |
2. 단방향 연관관계
객체의 참조를 사용해 연관관계를 맺으면 아래와 같이 나타낼 수 있다.
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
// @Column(name = "TEAM_ID")
// private Long teamId;
private int age;
@ManyToOne // 다대일 단방향 연관관계
@JoinColumn(name = "TEAM_ID") // 객체의 참조와 테이블의 외래 키를 매핑
private Team team // 객체의 참조로 연관관계를 맺음
...
}
Member 객체의 Team team과 MEMBER 테이블의 외래 키인 TEAM_ID를 단방향 연관관계로 매핑했다. 따라서 회원에서 팀으론 접근할 수 있지만, 그 반대는 불가능하다.
연관관계를 저장할 때도 식별자 대신 객체 그 자체를 저장할 수 있고, 조회 시에도 참조로 연관관계를 맺어뒀기 때문에 객체 그래프 탐색으로 편리하게 조회할 수 있다. 연관관계를 수정할 때도 자바 코드 한 줄이면 된다.
// 팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
// 회원 저장
Member member new Member();
member.setName("member1");
member.setTeam(team); // 단방향 연관관계 설정 및 참조 저장
em.persist(member);
// 조회
Member findMember = em.find(Member.class, member.getId());
// 참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();
// 새로운 팀 추가
Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);
// member1에 새로운 팀B 설정
member.setTeam(teamB);
3. 💫양방향 연관관계와 연관관계의 주인
양방향 연관관계
위에선 회원에서 팀에 접근하는 것(∵단방향 연관관계)만 가능했다. 양방향 연관관계를 맺으면 회원에서 팀에, 팀에서 회원에 접근할 수 있다.
- 테이블 연관관계는 외래 키 하나로 양방향 연관관계가 맺어진다.
- 객체 연관관계는 서로 참조할 수 있도록 필드를 추가해서 양방향으로 직접 맺어줘야 한다.
회원에서 팀으로의 단방향 연관관계는 맺어뒀으므로 팀에서 회원에 접근할 수 있도록 만들어 보자.
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 일대다 양방향 연관관계
List<Member> members = new ArrayList<Member>(); // 컬렉션 필드 초기화
...
}
양방향 연관관계로 바꿨기 때문에 반대 방향으로 객체 그래프를 탐색할 수 있게 됐다.
// 조회
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size(); // 역방향 조회
참
객체는 가급적이면 단방향 연관관계를 맺도록 설계하는 것이 좋다. 양방향 연관관계로 설정해 두면 생각할 게 너무 많아진다.
💫연관관계의 주인과 mappedBy
이 부분이 C언어의 포인터와 맞먹는 어려운 부분이다. 쉽게 이해하기 위해선 객체와 테이블 간에 연관관계를 맺는 방법의 차이를 이해해야 한다.
a. 객체와 테이블 간에 연관관계를 맺는 방법의 차이
- 객체는 참조를 사용하기 때문에 2개의 단방향 연관관계를 갖는다.
- 회원 → 팀 (단방향 연관관계 1개)
- 팀 → 회원 (단방향 연관관계 1개)
- 테이블은 외래 키를 사용하기 때문에 1개의 양방향 연관관계를 갖는다.
- 회원 ↔ 팀 (양방향 연관관계 1개)
객체의 양방향 관계
- 객체의 양방향 관계는 사실 서로 다른 단방향 관계 2개로 볼 수 있다.
- 따라서 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.
class A {
B b; // A -> B == a.getB()
}
class B {
A a; // B -> A == b.getA()
}
테이블의 양방향 관계
- 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.
- MEMBER.TEAM_ID 외래 키 하나로 양방향 연관관계를 갖는다.
SELECT *
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID;
SELECT *
FROM TEAM T
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID;
b. 연관관계의 주인(Owner)
객체는 양방향 연관관계 1개(단방향 연관관계 2개)를 만들기 위해 각각 엔티티에 필드를 만들어 둬야 한다. 이때 두 엔티티 중 어떤 엔티티의 참조 필드와 테이블의 외래 키의 연관관계를 매핑해야 할지 결정해야 한다. 즉, 필드 둘 중 하나로 외래 키를 관리해야 한다.
양방향 매핑 규칙에서 나온 게 연관관계의 주인이라는 용어다. 객체의 두 관계 중 하나(ex. team or members)를 연관관계의 주인으로 지정하고, 연관관계의 주인만이 외래 키를 관리(등록, 수정 등)하도록 한다.
- 주인이 아닌 쪽은 읽기만 가능하다.
- 주인은 mappedBy 속성을 사용하지 않는다.
- 주인이 아닌 쪽은 mappedBy 속성으로 주인을 지정해야 한다.
여기서 누구를 연관관계의 주인으로 정할지 생각해봐야 한다. 사실 답은 정해져 있다.
외래 키가 있는 곳을 연관관계의 주인으로 정해라.
- 아래 예시에선 Member.team이 연관관계의 주인이다. Team.members는 읽기만 가능한 주인의 반대편이라고 보면 된다.
- 따라서 Member.team에 값을 등록하거나 수정해야 DB에 반영된다. 반면 Team.members에 값을 등록하거나 수정해도 DB엔 아무런 변화가 없다.
- 다대일 관계에서 테이블은 무조건 '다' 쪽이 외래 키를 갖는다. 따라서 '다' 쪽인 테이블이 연관관계의 주인이 되므로, 테이블에 해당하는 엔티티도 연관관계의 주인이 돼야 한다고 생각하면 이해하기 쉽다.
- 비즈니스적으로 중요한 테이블이 연관관계의 주인이 돼야 할 필요는 없다.
- ex. 자동차에서 비즈니스적으론 차가 더 중요하지만 연관관계의 주인은 바퀴로 설정해야 한다.
주의점
양방향 매핑 시 가장 많이 하는 실수는 연관관계의 주인에 값을 입력하지 않는 것이다. 아래 코드를 보면서 이해해 보자. 연관관계의 주인이 아닌 쪽에만 값을 넣으면, 연관관계의 주인에 값이 들어가지 않는다.
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 team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
// 양쪽에 값 입력
team.getMembers().add(member);
member.setTeam(team); // 연관관계의 주인에 값 입력
em.persist(member);
양방향 연관관계를 설정할 땐,
- 순수 객체 상태를 고려해서 항상 양쪽 모두 값을 설정하자.
- 이 역할을 해주는 연관관계 편의 메서드를 생성해 두면 편리하게 설정할 수 있다. set보단 다른 이름을 사용하는 게 좋다.
- 연관관계의 주인인 엔티티에 생성해도 되고, 그 반대도 가능하다. 물론 둘 중 한 곳에서만 해야 한다.
public void setTeam(Team team) { // setTeam -> changeTeam
this.team = team;
team.getMembers().add(this);
}
- 무한 루프를 조심하자. (StackOverflowError가 발생한다.)
- ex. toString(), lombok, JSON 생성 라이브러리
- toString()은 되도록이면 사용하지 말자.
- Controller 단에서 절대 엔티티 자체를 반환하지 말고 DTO로 반환하자. 엔티티를 자동으로 JSON으로 바꾸다가 무한 루프에 빠지거나 엔티티의 spec이 바뀌는 문제가 생길 수도 있다.
public class Team {
@Override
public String toString() {
return "Team{" +
"id=" + id +
", name=" + name + '\'' +
", members=" + members + // 여기서 members에 있는 member마다 member.toString() 호출
"}";
}
}
public class Member {
@Override
public String toString() {
return "Team{" +
"id=" + id +
", username=" + username + '\'' +
", team=" + team + // 여기서 team.toString() 호출
"}";
}
}
- 연관관계 메서드를 양쪽에 만들어두면 발생하기도 한다. 한쪽에만 만들자.
정리
a. 단방향 매핑을 잘해두고, 양방향은 필요할 때 추가하자.
사실 단방향 매핑만으로도 이미 연관관계 매핑은 완료된다. 실무에서 설계할 땐 테이블과 객체를 설계할 때 처음에는 무조건 단방향 설계만으로 매핑을 끝낸다.
- 양방향 매핑은 반대 방향으로의 조회(객체 그래프 탐색) 기능을 추가한 것뿐이다. 실무에서는 어차피 JPQL을 사용해 역방향으로 탐색할 일이 자주 생긴다. 따라서 단방향 매핑으로 끝까지 잘해두면 양방향은 필요할 때만 추가해도 된다.
b. 연관관계의 주인을 정하는 기준
비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안 된다.
- 연관관계의 주인은 테이블에서 외래 키의 위치를 기준으로 정하자.
- 값을 입력할 땐 연관관계 편의 메서드를 통해 항상 양쪽에 값을 모두 입력하자.