[jpa] 다양한 연관관계 매핑

RDB에는 앞서 언급했던 것 보다 더 많은 관계가 존재한다.

  • 1:N(@OneToMany, @ManyToOne)
  • 1:1(@OneToOne)
  • N:M(@ManyToMany)

이를 다중성이라고 한다.
각각의 다중성에서 형성될 수 있는 연관관계들과 그 특징을 나열해보겠다.
모든 다중성은 왼쪽이 연관관계의 주인이라고 가정하겠다(다대일 -> 가 연관관계의 주인)

다대일

N:1의 관계이고, N이 연관관계의 주인인(외래키를 관리하는) 형태이다.
RDB에서 외래키는 항상 N쪽에 존재한다는 특성에 가장 잘 들어맞는다.
그러므로 대부분 이 형태를 사용한다.

다대일 단방향(N:1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
class Member{
@Id
private String id;

private String username;

@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}

@Entity
class Team{
@Id
private String id;

private String name;
}

Member.team 필드로 TEAM 테이블의 TEAM_ID 외래키를 관리한다.
MemberTeam을 참조할 수 있지만 TeamMember를 참조할 수 없다.
이 관계는 선택이 아닌 필수이다.

다대일 양방향(N:1, 1:N)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
class Member{
@Id
private String id;

private String name;

@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;

// 연관관계 편의 메서드
}

@Entity
class Team{
@Id
private String id;

private String name;

@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();

// 연관관계 편의 메서드
}

참조가 양쪽 모두 있으므로 연관관계의 주인을 정해야 한다.
RDB에서 1:N, N:1 관계에서 외래키는 항상 N쪽에 있으므로 Member가 연관관계의 주인이 된다.
보다시피 mappedBy 속성으로 연관관계의 주인이 아님을 명시해주고 있다.
아주 일반적인 구조이다.

양방향 연관관계는 항상 서로 참조해야 하므로, 각각 연관관계 편의 메서드를 작성해주는 것이 좋다.
RDB에서는 외래키 하나만 넣어줘도 양방향 관계가 성립하지만, 객체에서는 그렇지 않기 때문이다.

이 관계는 편의를 위한 선택이다.

일대다

1:N의 관계이고, 1이 연관관계의 주인인 형태이다.
외래키는 당연히 N쪽 테이블에 있지만, 관리를 1쪽에서 하므로 관리에 불편함이 있다.
일반적으로 잘 쓰이지는 않는 방법이다.

일대다 단방향(1:N)

일대다 단방향 관계는 JPA 2.0부터 지원한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
class Team{
@Id
private String id;

private String name;

@OneToMany
@JoinColumn(name = "TEAM_ID")
private List<Member> members = new ArrayList<>();
}

@Entity
class Member{
@Id
private String id;

private String username;
}

외래키는 N쪽 테이블에 있으나, N쪽 엔티티에 외래키를 매핑할 수 있는 참조 필드가 없고, 1쪽에만 참조필드가 있다.
단방향 관계에서는 참조를 가진쪽이 외래키를 관리한다.
즉, 이 상태에서는 1쪽에서 외래키를 관리하는, 조금 특이한(다소 불편한) 형태가 나오게 된다.

외래키를 관리해야하므로 보다시피 @JoinColumn을 꼭 명시해줘야 한다.
이를 명시해주지 않으면 JPA는 @JoinTable 전략을 기본으로 사용해버린다.

이렇게 하면 어떤점이 불편할까?

  • 기본적인 RDB의 1:N 구조에 역행하는 방식이므로 관리가 불편하다.

N 엔티티가 직접 외래키를 컨트롤 할 수 없으므로, 외래키를 변경하려면 무조건 Team을 통해야 한다.

  • 성능상 문제도 발생한다.

N 저장 시 연관관계를 설정하고 저장하는 것이 불가능하다.
insert 후 update로 연관관계를 설정해줘야 한다. 쿼리가 2번 필요하다.

아래는 1:N 단방향 형태에서 연관관계를 설정하는 예시이다.

1
2
3
4
5
6
7
8
9
10
public void save(){
Member member = new Member("member1");
// 외래키는 member가 가지지만 외래키를 설정할 수 없는 상황
em.persist(member);

Team team = new Team("team1");
team.getMembers().add(member); // 1이 연관관계의 주인이라 외래키 컨트롤 가능

em.persist(team);
}

실행되는 쿼리는 아래와 같다.

1
2
3
4
insert into Member (MEMBER_ID, username) values (null, ?)
insert into Team (TEAM_ID, name) values (null, ?)

update Member set TEAM_ID=? where MEMBER_ID=?

보다시피 불필요한 UPDATE 쿼리가 발생한다.
MemberTeam의 존재를 모르기 때문에 바로 외래키를 설정할 수 없기 때문이다.

보면 알겠지만 사용될 일이 많이 없는 형태이다.
항상 부모를 통해 접근하고, 자식이 직접 부모를 참조할 일이 없는 구조의 경우 가끔씩 사용하기도 한다.
(이럴 경우라도 위처럼 1쪽에서 의존관계를 관리할 경우는 거의 없다. 대부분 cascade 전략으로 처리한다)

하지만 위와 같지 않고 일반적인 상황이라면 이 구조보다는 다대일 양방향 매핑을 권장한다.

일대다 양방향

일대다 양방향 매핑은 존재하지 않는다.
양방향 연관관계를 형성하게 되면 N쪽 테이블에 @ManyToOne을 명시하게 되는데,
테이블 상에서 외래키를 가진 애가 객체상에서도 참조를 컨트롤 할수 있게 된 상황에서
굳이 1쪽에서 연관관계를 컨트롤 하도록 할 이유가 없다.
(기능이란건 결국 필요에 의해 만들어지는데, 이 기능은 굳이 지원할 이유가 전혀 없다)
그래서 @ManyToOnemappedBy 속성 자체가 없다.

근데 뭐… 완전히 불가능한 것은 아니고, 설정할 수는 있다.
기본적으로 일대다 단방향으로 설정하고, N쪽의 단방향 매핑을 읽기전용으로 설정하면 된다.

1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Member {
@Id
private Long id;

private String name;

@ManyToOne
@JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
private Team team;
}

이렇게 까지 사용할 일이 있을라나 모르겠다.

일대일(1:1)

양쪽이 서로 하나의 관계만을 가지는 형태이다. 사람과 사물함의 관계와 같다고 보면 된다.
1:1 구조의 특징은 주 테이블, 대상 테이블 중 어느 곳이던 외래키를 가질 수 있다는 것이다.
즉 일대일 관계에서는 누가 외래키를 가질지 선택해야 한다.

주 테이블에 외래키

객체지향 개발자들이 선호하는 방법이다.
외래키를 객체 참조 비슷하게 사용 할 수 있고, 주 테이블만 확인해도 대상 테이블과의 연관관계를 확인 가능하다.

대상 테이블에 외래키

데이터베이스 개발자들이 선호하는 방법이다.
관계를 일대일에서 일대다로 변경할 떄 테이블 구조를 그대로 유지할 수 있는 장점이 있다.

이제 해당 방식에 대해 객체를 매핑할건데, 결론부터 얘기하자면 JPA는 대상 테이블에 외래키 방식을 지원하지 않는다.
그러므로 연관관계의 주인을 바꿔서 사용하는 방법밖에 없다.

왜 지원하지 않을까 생각해봤는데… 모호한 부분이 많은 듯 하다.
일단 문법적으로, 이런 모양으로 매핑할 수 있는 방법이 없다.
일대다 관계처럼 객체-컬렉션 형태로 구분지어지지도 않기 때문이다.

결국 문법적으로 구분할 수 있는 방법은 mappedBy로 명시해주는 방법밖에 없는데,
이럴려면 무조건 양방향 매핑을 사용해야 하고,
이렇게 해서 대상테이블에 외래키를 구현한다고 해도, 반대편 엔티티에서 보면 어쩌피 또 주 테이블에 외래키 전략이 된다.
나는 … 이러한 이유로 주 테이블에 외래키 전략을 더 선호한다.
너무 RDB의 형태에 갇혀있을 필요는 없는 것 같다.

일대일 단방향(주테이블에 외래키)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
public class Member {
@Id
private Long id;

private String name;

@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}

@Entity
public class Locker {
@Id
private Long id;
private String naame;
}

양방향(주테이블에 외래키)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
public class Member {
@Id
private Long id;
private String username;

@OneToOne
@JoinColumn(name = "LOCKER_ID")
private Locker locker;
}

@Entity
public class Locker {
@Id
private Long id;
private String naame;

@OneToOne(mappedBy = "locker")
private Member member;
}

나는 객체지향 관점에서 봤을떄, 주테이블에 외래키를 가지는것이 좀 더 객체지향스럽다고 생각하고, 이 방식을 좀 더 선호한다(!!)
사실상 대상 테이블에 외래키를 가지는 것은 RDB 스러운 방법이라고 생각한다.
주 테이블에 외래키를 가지는 식으로 설계하면 주 테이블에서 관계의 개수만큼 외래키를 관리해줘야 하기 때문이다.

근데… 어플리케이션을 만드는 개발자가, 특히 객체지향을 사용하는 개발자가 이런 관점에 굳이 얽매여있을 필요가 있을까?
객체지향의 장점을 끌어올리고자 ORM을 사용하는 입장에서, 그러한 설계에 얽매이고, 굳이 연관관계를 뒤집어 가며 개발해야 할 이유가 있나 생각이 든다…

게다가, 나중에 나오곘지만 ORM에서는 프록시의 한계 때문에 연관관계의 주인이 아닌쪽에서의 lazy 로딩을 허용하지 않는다.
이 말인 즉, 대상 테이블에 외래키를 사용하는 형태로 양방향 관계를 형성하면, 주 테이블 쪽에서는 조회될 때 마다 자신과 연관된 모든 관계를 다 가져와야 한다는 의미가 된다.
(물론 해결 방법은 있다. byte instrument…)

다대다(N:M)

RDB는 다대다 관계를 표현할 수 없다.
그러므로 연결 테이블이라는 것을 사용해야 한다.

회원상품은 바로 N:M 관계를 맺을 수 없으니 중간에 주문 같은 테이블을 넣어줘야 N:M 관계를 형성할 수 있다.

반면에 객체는 다대다 관계를 표현할 수 있다.
컬렉션을 사용해서 서로 참조하고 있기만 하면된다.

다대다 관계는 연결 테이블 여부라는 패러다임 차이가 있기 때문에, 이를 풀어줘야 한다.
그러기에 기존의 방식인 다중성 표현 + 외래키 지정으로는 위의 패러다임 차이를 풀 수 없다.
그래서 JPA는 @JoinTable이라는 전략을 사용해 이를 지원한다.

단방향

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 다대다 단방향 회원
@Entity
public class Member {
@Id
private String id;

private String username;

@ManyToMany
@JoinTable(
name = "MEMBER_PRODUCT",
joinColumns = @JoinColumn(name = "MEMBER_ID"),
inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
private List<Product> products = new ArrayList<>();
}

@Entity
public class Product {
@Id
private String id;

private String name;
}

다대다 관계이므로 @ManyToMany를 사용하였고, 위에서 언급한 @JoinTable이 등장하였다.
@ManyToMany는 별다른거 없고(진짜 그런건 아니지만), @JoinTable의 속성 대해 알아보자.

  • name : 연결 테이블을 지정한다
  • joinColumns : 현재 방향에서 매핑할 조인 컬럼 정보
  • inverseJoinColumns : 반대 방향에서 매핑할 조인 컬럼 정보

이 정보들을 기반으로 연결 테이블을 생성한다.
이로 인해 우리는 연결 테이블을 전혀 신경쓰지 않아도 된다!

아래는 저장하는 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public void save() {
Product product = new Product();
product.setId("productA");
product.setName("상품A");
em.persist(productA);

Member member = new Member();
member.setId("member1");
member.setUsername("회원1");
member.getProducts().add(ProductA); // 연관관계 설정

em.persist(member1);
}
1
2
3
INSERT INTO PRODUCT ...
INSERT INTO MEMBER ...
INSERT INTO MEMBER_PRODUCT ...

연결 테이블에 데이터가 저장된다.

아래는 탐색하는 코드이다.

1
2
3
4
5
6
7
8
public void find() {
Member member = em.find(Member.class, "member1");
List<Product> products = member.getProducts(); // 객체 그래프 탐색

for(Product product : products) {
System.out.println("product.name = " + product.getName());
}
}
1
2
3
4
SELECT * 
FROM MEMBER_PRODUCT MP
INNER JOIN PRODUCT P ON MP.PRODUCT_ID=P.PRODUCT_ID
WHERE MP.MEMBER_ID=?

연결 테이블과 조인해서 데이터를 들고온다.

양방향

다대다의 반대 또한 다대다이므로, @ManyToMany로 연결해주면 된다.

1
2
3
4
5
6
7
8
@Entity
public class Product {
@Id
private String id;

@ManyToMany(mappedBy = "products")
private List<Member> members;
}

mappedBy로 연관관계의 주인만 지정해주면 된다.
사실상 연결 테이블로 관리되는 다대다 관계에서는 연관관계의 주인이 별로 의미가 없다…
물론 위의 상황에서는 Member만이 연관관계를 컨트롤할 수 있지만,
연관관계 편의 메서드만 추가해줘도 양쪽에서 컨트롤 할 수 있게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Entity
public class Member {
@Id
private String id;

private String username;

@ManyToMany
@JoinTable(name = "MEMBER_PRODUCT"
,joinColumns = @JoinColumn(name = "MEMBER_ID")
,inverseJoinColumns = @JoinColumn(name = "PRODUCT_ID"))
private List<Product> products = new ArrayList<>();

// 연관관계 편의 메서드
public void addProduct(Product product){
if(!this.products.contains(product)){
this.products.add(product);
}

if(!product.getMembers().contains(this)){
product.getMembers().add(this);
}
}
}

@Entity
public class Product {
@Id
private String id;

private String name;

@ManyToMany(mappedBy = "products")
private List<Member> members;

// 연관관계 편의 메서드
public void addMember(Member member){
if(!this.members.contains(member)){
this.members.add(member);
}

if(!member.getProducts().contains(this)){
member.getProducts().add(this);
}
}
}

연관관계의 주인이 아닌쪽에서 편의메서드를 사용하면 결국 연관관계의 주인쪽에도 추가되므로, 연결 테이블이 영향을 받게 된다.
즉, 편의메서드를 통하면 양쪽에서 다 컨트롤 가능하다.

다대다에서는 연관관계 편의메서드를 작성하지 않는것이 좋아보인다. side effect가 많다.

다대다의 한계

@ManyToMany를 사용하면 연결 테이블을 알아서 관리해주므로 여러모로 편리하지만, 실제 실무에서는 이 정도로만 사용하기에는 한계가 있다.
MEMBER_IDPRODUCT_ID만 담지 않고, 추가적인 정보를 담는 경우가 많기 떄문이다.
(날짜, 수량등을 추가해서 ORDER 테이블로 사용한다거나…)

하지만 이렇게 컬럼을 추가하면 더이상 @ManyToMany를 사용할 수 없게된다.
추가 컬럼을 정의한 연결 테이블에 매핑되는 엔티티를 만들어야하고, 테이블간의 관계도 다대다에서 일대다, 다대일의 관계로 풀어야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Entity
public class Member {
@Id
private String id;

private String username;

@OneToMany(mappedBy = "member")
private List<MemberProduct> memberProducts = new ArrayList<>();
}

@Entity
public class Product {
@Id
private String id;

private String name;

// 여기도 필요에 따라 추가할 수 있다
}

@Entity
@IdClass
public class MemberProduct{
@Id
@ManyToOne
@JoinColumn(name = "MEMBER_ID")
private Member member;

@Id
@ManyToOne
@JoinColumn(name = "PRODUCT_ID")
private Product product;

private Integer orderAmount;

@Temporal(TemporalType.TIMESTAMP)
private Date orderDate;
}

(현재 @IdClass라는 것을 사용해서 복합키를 매핑하였는데, 이는 뒷부분에서 다룬다.)
추가적인 컬럼을 가진 MemberProduct를 정의하였다.

  • 외래키를 직접 관리하므로 이 엔티티가 연관관계의 주인이 된다. @JoinColumn을 선언했음을 볼 수 있다.
  • Member 엔티티는 외래키를 관리하지 않으므로 mappedBy 속성을 줘서 연관관계의 주인이 아님을 명시했다.
  • Product 엔티티에서 직접 MemberProduct를 참조할 일이 없다고 판단해서 연관관계를 추가하지 않았다.

실무(아니 그냥 일반적으로)에서는 위와 같은 방식으로 더 많이 사용된다.
사용하는 방식은 일반적인 다대일, 일대다 관계와 같다.