연관 관계 매핑
📌 객체의 연관 관계 매핑
관계형 DB는 외래키를 통해 연관 관계를 맺는다. 다음은 관계형 DB에 맞춘 엔티티 설계다.
아래 코드를 통해 연관 관계 엔티티인 Team을 저장하면 ID 값이 저장된다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);
tx.commit();
이처럼 ID 값이 저장되기 때문에 em.find 한 번으로 객체 조회가 완료되지 않는다.
try {
...
Member findMember = em.find(Member.class, member.getId());
Long teamId = findMember.getTeamId();
Team findTeam = em.find(Team.class, teamId);
tx.commit();
}
🔎 엔티티를 객체 중심으로 개발해야 하는 이유
객체는 참조 값을 저장하여 연관 관계 객체를 찾는다.
- em.find 한 번으로 객체 조회가 완료되지 않는다. 이러한 복수 조회 시도는 성능과 가독성을 떨어뜨린다.
- 객체 그래프 탐색에 제한을 받는다.
- 지연/즉시 로딩 또는 영속성 전이 등을 사용할 수 없어 연관 관계 엔티티 사이에 협력 관계를 만들 수 없다.
📌 참조하는 방향
테이블은 조인을 하기 때문에 양방향이라는 개념이 없다. 반면, 객체는 참조값이 있는 객체만 다른 객체를 참조하는 것이 가능하다. 이러한 패러다임 차이를 해소하기 위해 단방향 또는 양방향을 선택해야 한다.
🔎 단방향 연관 관계
두 객체 사이에 하나의 객체만 참조용 필드를 갖고 참조하는 관계
참조값을 어디에 둘지는 비즈니스 로직에 따르면 된다. 예를 들어, 회원과 팀이 있고 두 엔티티가 연관 관계를 맺고 싶다고 하자. 이때, 회원에서 getTeam()이 필요하다면 회원에 참조값이 있어야 하고 회원 엔티티에서 팀 엔티티로 접근할 수 있다.
🔎 양방향 연관 관계
객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 방향이 다른 단방향 관계 2개를 말한다.
✔ 무조건 양방향이 정답은 아니다
양방향을 사용하면 양쪽에서 접근이 가능하므로 코딩이 수월하다. 하지만 연관 관계를 맺는 엔티티마다 양방향을 맺게 되면 엔티티 설계가 매우 복잡해진다.
기본적으로 단방향으로 하되, 나중에 객체 탐색이 필요한 경우 양방향으로 추가하자
📌 연관 관계의 주인
🔎 연관 관계의 주인을 지정해야 하는 이유
1. 영속성 컨텍스트 변경감지 기준
주인이 없으면 양 쪽에서 수정이 가능하므로 영속성 컨텍스트가 너무 복잡해진다. 만약, Team에서 Member를 INSERT하고 Member에서는 UPDATE 하는 경우, 하나의 수정이지만 두 번의 쿼리가 나가는 것이다. 그러면 이를 최적화하기 위한 추가적인 연산이 많아진다.
양쪽 연관 관계가 모순되는 경우 어떻게 처리해야 할지에 대한 규약도 복잡해진다. 예를 들어, Team의 memberList에는 member1이 있는데, member1.setTeam(null)을 하면 어떤 쪽을 따라야 하는가 문제가 된다.
2. 패러다임의 차이
예를 들어 Member에서 다른 Team으로 수정하려고 할 때, Member에서 수정해야 할지, Team에서 해당 Member를 뽑아 수정해야 할지 혼란스러워진다. 그 이유는
객체 패러다임에서는 둘 다 옳지만, DB의 패러다임에서는 그렇지 않기 때문이다. 즉, 연관 관계의 주인이 있어야 객체 패러다임에서의 양방향 관계가 DB 패러다임에서
연관 관계가 하나임을 보장할 수 있게 된다.
🔎 양방향 매핑 규칙
- 객체의 두 관계 중 하나를 연관 관계의 주인으로 지정
- 연관 관계의 주인만이 외래키를 관리한다. 따라서, 수정 및 등록이 가능
- @JoinColumn으로 외래키 설정
- 주인이 아닌 쪽은 읽기(조회)만 가능
- mappedBy 속성으로 주인을 지정
외래키가 있는 쪽을 주인으로 정해라
🔎 데이터 동기화
객체는 데이터베이스처럼 두 연관 관계 엔티티 사이에 변경이 전파되지 않는다. 따라서, 양쪽에서 데이터를 동기화하는 작업을 수행해야 한다.
이를 위해 연관 관계 편의 메서드를 사용할 수 있다.
연관 관계 편의 메서드는 한 쪽에서만 생성하자
📌 다중성
데이터베이스를 기준으로 다중성을 결정한다.
🔎 다대일(N:1)
다음과 같은 요구 사항이 있다고 가정한다.
- 한 회원은 하나의 팀에 소속될 수 있다.
- 한 팀에 여러 회원을 등록할 수 있다.
위 요구 사항을 바탕으로 회원과 팀은 다대일 관계라고 볼 수 있다.
다대일 관계에서 다(N) 쪽에 외래키가 있어야 하며, 양방향을 한다고 해서 테이블에 영향을 주진 않는다.
아래 코드처럼 @JoinColumn을 통해 참조 필드를 생성할 수 있고, 연관 관계의 주인이 된다. 그리고 다대일에서 N 입장이므로 @ManyToOne을 붙이면 된다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
양방향일 경우, 아래와 같이 Team에서 Member에 접근할 수 있도록 참조 필드가 있어야 한다. Member는 N 집합이므로, List를 이용할 수 있고 @OneToMany의 mappedBy 속성으로 Member의 참조값과 연결한다.
@Entity
public class Team {
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
다(N) 쪽에 외래키를 둔다.
🔎 일대다(1:N)
일대다는 다대일의 반대 관계다. 하지만 일대다는 사용을 권장하지 않는다.
위 그림을 보면 외래키가 N(다) 쪽에 있는 점은 다대일 관계와 동일하다. 일대다 관계가 다대일과 다른 점은 일의 입장인 Team이 연관 관계의 주인으로서
등록과 수정을 할 수 있다는 점이다. 문제는 외래키가 여전히 Member 쪽에 있고, 이는 Member가 여전히 N(다) 입장이기 때문이다.
그렇다면 아래 코드처럼 양쪽에서 변경을 수행하면 어떻게 될까
@Entity
public class Team {
@Id
@GeneratedValue
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "TEAM_ID") //일대다 단방향에서 @JoinColumn 필수
private List<Member> members = new ArrayList<>();
}
public class JpaMain {
public static void main(String[] args) {
...
try {
Member member = new Member();
member.setName("member1");
em.persist(member);
Team team = new Team();
team.setName("teamA");
team.getMembers().add(member);
em.persist(team);
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
}
위 결과를 보면 Team 쪽에서 작업을 했는데 Member에서도 변경이 발생한 것을 알 수 있다. 왜냐하면 Team에서 Member의 외래키에 접근할 방법이 없어
조인 및 UPDATE 쿼리를 날려야 하기 때문이다. 이렇게 되면 쿼리 추적이 복잡해지고 운영도 어려워진다. 따라서, 다대일을 기본으로 하되 필요하면 양방향 전략을
취하는 것을 권장한다.
다대일 기본 + 필요하면 양방향
일대다 양방향은 공식적으로 존재하지 않는다.
🔎 일대일(1:1)
주 테이블 또는 대상 테이블에 외래키를 넣을 수 있는 관계
아래와 같이 @OneToOne으로 일대일 관계를 맺을 수 있다.
@Entity
public class Member {
@Id
@GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
@Column(name = "USERNAME")
private String name;
@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}
위 코드를 보면 @JoinColumn이 Member 쪽에 있다. 그래서 Member는 외래키를 관리하는 주 테이블이 되고, Locker는 대상 테이블이 된다. 만약 양방향 관계를
맺고 싶다면 Locker에도 참조 필드를 생성하고 @OneToOne의 mappedBy로 관계를 맺어주면 된다.
@Entity
public class Locker {
@Id @GeneratedValue
@Column(name = "LOCKER_ID")
private Long id;
private String name;
@OneToOne(mappedBy = "locker")
private Member member;
}
일대일 단방향에서 참조값을 가지지 않은 엔티티가 외래키를 가진 경우는 JPA에서 지원하지 않는다.
이 경우, 참조값을 가진 Member에서 Locker에 접근하지 못한다.
✔ 일대일 양방향에서 외래키를 어디에 둘 것인가
만약 일대일 관계였던 Member와 Locker가 일대다 관계가 된다면 외래키가 Locker 쪽에 있는 것이 변경에 유리할 것이다. 또한, 대상 테이블은 주 테이블이 없이 존재할 수 없으므로 null로부터 외래키를 방어할 수 있다. 하지만 앞서 언급한 것처럼 일대일 단방향에서 참조값이 없는 엔티티가 외래키를 가진 경우 주 테이블에서 변경을 할 수 없어 어쩔 수 없이 양방향을 가져가야 한다. 그리고 지연 로딩이 되지 않는다는 치명적인 단점이 있다.
대상 테이블은 주 테이블 없이 생성되는 경우가 거의 없다.
지연 로딩은 원본 엔티티에 대한 proxy 객체를 생성하여 연관된 엔티티를 실제 사용할 때까지 로딩을 미루는 기법이다. 연관 관계의 주인과 상관없이 비즈니스 관점에서
회원이 있어야 사물함이 있는 것이다. 그래서 주 테이블이 회원이라는 사실은 동일하다. 그렇다면 Member 에서 Locker에 접근하는 경우를 생각해보자.
주 테이블인 Member에 외래키가 있다면, Member를 통해 Locker의 데이터 유무를 판단할 수 있다. 그래서 사물함이 배치되지 않은 회원이라면 null 값을
입력해야 한다는 것을 인지한다. 반면, 대상 테이블인 Locker에 외래키가 있다면 Member에서 Locker의 데이터 유무를 판단할 수 없다. 즉, 사물함을 배치받은
회원인지 모르는 것이다. 이것은 Member가 로딩될 때 Locker를 null 값으로 처리해야 할지 proxy 객체를 넣어야 할지 판단할 수 없다는 의미가 된다.
따라서, 즉시 로딩을 통해 proxy 객체를 사용하지 않고 조인해서 데이터 유무를 확인하는 식으로 동작하는 것이다.
💡 외래키를 어디에 둘 지에 대한 절대적인 정답은 없다.
DB 입장에서는 대상 테이블에 외래키가 있으면 외래키에 null이 들어가는 것을 막고, 테이블 변경 가능성이 적어 선호할 것이다. 반면, 객체 입장에서는
불필요한 양방향 관계가 있을 수 있고, 지연 로딩이 막혀 N + 1 문제로부터 자유로울 수 없기 때문에 성능상 이점이 있는 주 테이블에 외래키를 두는 방법을
선호할 것이다.
Reference:
🔎 다대다(N:M)
실무에서 사용하지 않는다.
@ManyToMany를 통해 다대다를 구현할 수는 있지만 외래키만으로 구성된 중간 테이블이 있어 자신도 모르는 복잡한 쿼리가 발생하는 경우가 생길 수 있다. 그 이유는
중간 테이블에 외래키 외에 다른 컬럼이 들어가는 경우가 많아 쿼리를 예측하기 힘들기 때문이다. 그리고 중간 테이블에 외래키 외 일반 컬럼을 넣을 수가 없다.
(추가)다대다의 문제점:
https://goodteacher.tistory.com/466
실제 비즈니스에서 가능한 경우가 적지만 "@ManyToMany = @OneToMany + @ManyToOne"로 풀어내는 방법도 있다. 이를 위해 연결을 위한 중간 테이블을
추가하여 연결 테이블을 엔티티로 승격할 수 있다.
Reference:
https://jeong-pro.tistory.com/231
https://www.inflearn.com/course/ORM-JPA-Basic/dashboard