연관 관계 매핑 (2)
🎬 Intro
연관 관계의 종류를 알아봅시다.
✅ @ManyToOne
JPA 연관관계에서 가장 많이 사용하고 한 객체가 여러 객체와 연관될 수 있는 관계입니다. 보통 부모-자식 관계에서 부모가 여러 자식을 가질수 있는 경우를 나타냅니다. 예로 하나의 BookStore는 여러 Book을 포함할 수 있고, 각 Book은 하나의 BookStore에만 속할 수 있는 경우가 @ManyToOne 관계입니다. DB 상에는 Many 쪽인 Book에 BookStore의 외래키가 존재하며, 객체 상에서 연관관계의 주인은 Many쪽인 Book이 됩니다.
또한 @ManyToOne의 경우 @JoinColumn으로 외래키를 선언하지 않아도 JPA는 기본적으로 자식 엔티티에 부모 엔티티를 참조하는 외래 키 컬럼을 자동으로 생성합니다.
✅ @OneToMany
보통 @ManyToOne 연관관계 시 양방향을 위해 사용하는 애노테이션입니다. @OneToMany를 단방향으로 사용한다면 외래키를 One쪽에서 관리하게 됩니다.(주인은 여전히 Many쪽) 예를 들어 Book 과 BookStore가 N:1이라면 Book 쪽에 BookStore의 외래키가 존재하게 됩니다. 하지만 이를 객체상에서 @OneToMany를 이용하여 BookStore에서 이 외래키를 관리하게 할 수 있습니다. 하지만 이렇게 되면 BookStore에 Book을 추가할때마다 Book쪽의 외래키를 업데이트해줘야하는 쿼리가 필요하게 됩니다. 이러한 이유로 @OneToMany 단방향 관계는 잘사용하지 않습니다.
@OneToMany의 경우 @JoinColumn으로 외래키를 선언하지 않으면 JPA는 둘 사이의 관계를 관리할 수 없기 때문에 조인 테이블을 자동으로 생성하게 됩니다.
✅ @OneToOne
두 객체의 1:1 괸계입니다. 예를들어 1명의 고객은 1개의 카드만 소지할 수 있는 경우입니다. 단방향, 양방향 모두 가능합니다. @OneToOne은 외래키가 없는 쪽의 객체가 연관관계의 주인이 될 수 없습니다. 따라서 만약 카드쪽에 고객의 외래키가 있는데 객체 상에서 고객을 이용해서 카드를 처리해야할 로직이 많다면 연관관계의 주인은 카드로 두고 고객쪽을 양방향으로 설정하여 처리해야 됩니다.
하지만 이 경우에는 고객을 조회할때 지연로딩이라 하더라도 고객이 카드를 소지하고 있지 않으면 프록시 객체에 null을 넣어야 함으로 카드 테이블을 필수로 조회하게 됩니다. 즉, 즉시로딩이 강제가 됩니다.
✅ @ManyToMany
DB는 다대다 개념이 없기 때문에 @ManyToMany를 사용할 경우 JPA는 조인테이블을 자동으로 생성합니다. 따라서 다대다 관계가 필요할경우 @ManyToMany를 사용하기 보다는 중간 테이블을 두어서 1:N, N:1 관계로 풀어내야합니다. 예를 들어 여러 상품을 주문을 할 수 있을경우 주문은 상품을 여러개 가질 수 있고, 상품 역시 주문이 여러번 되기 때문에 주문을 여러개 가질 수 있습니다. 이경우 주문상품이라는 중간 테이블을 두어서 이 관계를 풀어내야합니다.
📝 예제
@ManyToOne
Book (Many)이 외래키를 가지고 있으므로 연관관계 주인이다.
단방향
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
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
@ManyToOne
@JoinColumn(name = "book_store_id")
private BookStore bookStore;
@Builder
public Book(final Long id, final String title, final String author) {
this.id = id;
this.title = title;
this.author = author;
}
}
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class BookStore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
- 단방향이므로 BookStore객체에서는 Book을 읽지 못합니다.
양방향
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
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class BookStore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "bookStore", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Book> books = new HashSet<>();
@Builder
public BookStore(final Long id, final String name) {
this.id = id;
this.name = name;
}
public void addBook(final Book book) {
this.books.add(book);
}
}
- 양방향이므로 BookStore에서 Book객체를 읽을 수 있습니다.
- Book, BookStore 생성시 연관관계 편의 메서드를 사용하여 DB와 객체 사이의 데이터 구조를 일치 시켜 줍니다.
@OneToMany
연관관계 주인은 Book(Many) 이지만 외래키를 BookStore가 관리
단방향
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
47
48
49
50
51
52
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
@Builder
public Book(final Long id, final String title, final String author) {
this.id = id;
this.title = title;
this.author = author;
}
}
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class BookStore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "book_store_id")
private Set<Book> books = new HashSet<>();
@Builder
public BookStore(final Long id, final String name) {
this.id = id;
this.name = name;
}
public void addBook(final Book book) {
this.books.add(book);
}
}
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
@DataJpaTest
public class JpaTest3 {
@Autowired
private BookRepository bookRepository;
@Autowired
private BookStoreRepository bookStoreRepository;
@Autowired
private EntityManager em;
@DisplayName("@OneToMany 단방향 테스트")
@Test
void oneToManyTest() throws Exception {
//given
Book book = Book.builder()
.title("테스트북")
.author("테스트저자")
.build();
BookStore bookStore = BookStore.builder()
.name("테스트북스토어")
.build();
bookStore.addBook(book);
bookRepository.save(book);
bookStoreRepository.save(bookStore);
System.out.println("======= 쓰기 지연 ========");
em.flush();
//when
//then
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Hibernate:
insert
into
book
(author, title, id)
values
(?, ?, default)
Hibernate:
insert
into
book_store
(name, id)
values
(?, default)
======= 쓰기 지연 ========
Hibernate:
update
book
set
book_store_id=?
where
id=?
- id 생성 전략이 IDENTITY 이므로 쓰기 지연은 지원되지 않습니다. 따라서 save시점에 바로 INSERT쿼리가 실행됩니다.
- BookStore에 Book을 추가하였고 BookStore가 연관관계 주인이기 때문에 둘의 관계가 DB에 반영됩니다.
- Book테이블에 외래키인 BookStore id를 업데이트하기 위해 업데이트 쿼리가 실행됩니다.
양방향
@OneToMany 양방향시에 @ManyToOne 쪽은 읽기, 쓰기를 false 처리해야합니다.
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
47
48
49
50
51
52
53
54
55
56
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
@ManyToOne
@JoinColumn(name = "book_store_id", insertable = false, updatable = false) // 원투매니 양방향시에 매니투원쪽은 읽기, 쓰기를 false로해야함
private BookStore bookStore;
@Builder
public Book(final Long id, final String title, final String author) {
this.id = id;
this.title = title;
this.author = author;
}
}
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class BookStore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "book_store_id")
private Set<Book> books = new HashSet<>();
@Builder
public BookStore(final Long id, final String name) {
this.id = id;
this.name = name;
}
public void addBook(final Book book) {
this.books.add(book);
}
}
- 연관관계의 주인은 여전히 Many쪽에 존재합니다. 단지 외래키를 One쪽에서 관리할뿐입니다.
- 테스트 결과는 위에 단방향과 동일합니다.
@OneToOne
연관관계의 주인은 DB상 외래키가 있는 쪽이 됩니다. 아래 예제는 고객이 카드의 외래키를 갖고 있을때입니다. 따라서 연관관계의 주인은 고객이 됩니다. 단방향, 양방향 모두 @ManyToOne과 유사합니다. 하지만 JPA는 @OneToOne의 경우 연관관계 주인이 아닌 쪽이 외래키를 관리하는 경우를 지원하지 않습니다.
단방향
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
47
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(mappedBy = "customer_id")
private Card card;
@Builder
public Customer(final Long id, final String name, final Card card) {
this.id = id;
this.name = name;
this.card = card;
}
}
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class Card {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String number;
@OneToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@Builder
public Card(final Long id, final String number) {
this.id = id;
this.number = number;
}
}
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
@SpringBootTest
public class JpaTest4 {
@Autowired
private CardRepository cardRepository;
@Autowired
private CustomerRepository customerRepository;
@Autowired
private EntityManager em;
@DisplayName("@OneToOne 단방향 관계 테스트")
@Test
void oneToManyTest() throws Exception {
//given
Customer customer = Customer.builder()
.name("테스트고객")
.build();
Card card = Card.builder()
.number("1234")
.customer(customer)
.build();
//when
customerRepository.save(customer);
cardRepository.save(card);
//then
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Hibernate:
insert
into
customer
(name, id)
values
(?, default)
Hibernate:
insert
into
card
(card_id, number, id)
values
(?, ?, default)
- @OneToMany는 외래키를 주인이 아닌 쪽에서 관리하는 경우, 즉 BookStore에서 Book을 추가하고 둘 다 save를 할때 둘 관계를 DB에 반영하기 하고 Book 쪽 외래키가 업데이트 되었습니다.
- 하지만 @OneToOne은 주인이 아닌쪽에서 관리하게 되면, 주인이 아닌쪽에도 외래키가 생기고 주인쪽 외래키는 업데이트되지 않아 null이 들어가게 됩니다.
- Jpa는 @OneToOne에서 주인이 아닌쪽이 외래키를 관리하는것을 지원하지 않는다는걸 알 수 있습니다.
- 따라서 @OneToOne의 외래키는 무조건 해당 외래키를 가지고 있는 객체에서 관리를 해야합니다.
양방향
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
47
48
49
50
51
52
53
54
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "card_id")
private Card card;
@Builder
public Customer(final Long id, final String name, final Card card) {
this.card = card;
this.id = id;
this.name = name;
}
}
@Entity
@Table
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class Card {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String number;
@OneToOne(mappedBy = "card", fetch = FetchType.LAZY)
private Customer customer;
@Builder
public Card(final Long id, final String number, final Customer customer) {
this.id = id;
this.number = number;
this.customer = customer;
}
// 연관관계 편의 메서드
public void updateCustomer(final Customer customer) {
customer.setCard(this);
this.customer = customer;
}
}
- 고객 테이블에 카드 외래키가 존재하고 있습니다. 서로를 참조하는 양방향 입니다.
- Fetch는 Lazy로 사용했지만 외래키가 없는 Card에서 Card를 가져올때는 EAGER 전략과 마찬가지로 Customer의 정보도 같이 가져오게 됩니다.
- 그 이유는 해당 카드 프록시 객체에 고객이 없으면 null을 넣어야하기 때문에 어쩔수없이 고객 테이블을 조회해야 합니다.
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@DataJpaTest
public class JpaTest5 {
@Autowired
private CardRepository cardRepository;
@Autowired
private CustomerRepository customerRepository;
@Autowired
private EntityManager em;
@DisplayName("@OneToOne 양방향 테스트")
@Test
void oneToOneTest() throws Exception {
//given
Card card = Card.builder()
.number("1234")
.build();
Customer customer = Customer.builder()
.card(card)
.name("테스트고객")
.build();
Card card1 = Card.builder()
.number("4321")
.build();
Customer customer1 = Customer.builder()
.card(card1)
.name("테스트고객1")
.build();
//when
card.updateCustomer(customer);
cardRepository.save(card);
customerRepository.save(customer);
cardRepository.save(card1);
customerRepository.save(customer1);
/**
* DB에 영속성 컨텍스트에 쌓인 쿼리반영, 1차 캐시 지우기
*/
em.flush();
em.clear();
System.out.println("=== 모든 카드 조회시 고객도 함께 가져온다 ===");
List<Card> cards = cardRepository.findAll();
System.out.println("====================================");
System.out.println("=========== 추가적인 SELECT 쿼리가 실행안된다 ============");
for (Card c : cards) {
System.out.println(c.getCustomer().getName());
}
System.out.println("====================================================");
/**
* 위에서 카드를 가져온것이 1차 캐시에 저장이 되어있다.
* 따라서 1차 캐시를 비우지 않으면 고객에서 카드를 조회할때 1차 캐시에서 가져오기때문에 SELECT쿼리가 발생하지 않는다.
*/
em.clear();
System.out.println("=== 모든 고객 조회시 고객만 가져온다 ===");
List<Customer> customers = customerRepository.findAll();
System.out.println("================================");
System.out.println("=========== 추가적인 SELECT 쿼리가 실행된다. =============");
for (Customer cus : customers) {
System.out.println(cus.getCard().getNumber());
}
System.out.println("====================================================");
//then
}
}
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
[Hibernate]
insert
into
card
(number, id)
values
(?, default)
[Hibernate]
insert
into
customer
(card_id, name, id)
values
(?, ?, default)
[Hibernate]
insert
into
card
(number, id)
values
(?, default)
[Hibernate]
insert
into
customer
(card_id, name, id)
values
(?, ?, default)
=== 모든 카드 조회시 고객도 함께 가져온다 ===
[Hibernate]
select
c1_0.id,
c1_0.number
from
card c1_0
[Hibernate]
select
c1_0.id,
c1_0.card_id,
c1_0.name
from
customer c1_0
where
c1_0.card_id=?
[Hibernate]
select
c1_0.id,
c1_0.card_id,
c1_0.name
from
customer c1_0
where
c1_0.card_id=?
====================================
=========== 추가적인 SELECT 쿼리가 실행안된다 ============
테스트고객
테스트고객1
====================================================
=== 모든 고객 조회시 고객만 가져온다 ===
[Hibernate]
select
c1_0.id,
c1_0.card_id,
c1_0.name
from
customer c1_0
================================
=========== 추가적인 SELECT 쿼리가 실행된다. =============
[Hibernate]
select
c1_0.id,
c1_0.number
from
card c1_0
where
c1_0.id=?
[Hibernate]
select
c1_0.id,
c1_0.card_id,
c1_0.name
from
customer c1_0
where
c1_0.card_id=?
1234
[Hibernate]
select
c1_0.id,
c1_0.number
from
card c1_0
where
c1_0.id=?
[Hibernate]
select
c1_0.id,
c1_0.card_id,
c1_0.name
from
customer c1_0
where
c1_0.card_id=?
4321
====================================================
- INSERT문은 무시하시고 아래 SELECT문을 보시면 모든 Card를 조회할때 추가로 Customer에 SELECT 쿼리가 발생하는것을 알 수 있습니다.
- 앞전에 말씀드린것 처럼 Card 프록시 객체에 있는 Customer의 null 유무를 판단하기 위해서 SELECT 쿼리가 실행된것입니다.
- Customer의 null 유무만 판단하면 되므로 EAGER 처럼 left join으로 가져오지 않습니다.
- Card는 findAll()에 의해 1차 캐시에 저장되어 있는 상태입니다.
- 따라서 em.clear()로 1차 캐시를 비우지 않으면 Customer 객체에서 Card 정보를 조회할때 1차 캐시에 있는 Card를 사용하게 됩니다.
- Customer는 외래키를 가진 주체이기 때문에 LAZY전략이 잘반영되었고, Customer에서 card 정보를 꺼내오는 시점에 Customer 객체 수 만큼 추가 쿼리가 발생하였습니다(N + 1)
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
[Hibernate]
insert
into
card
(number, id)
values
(?, default)
[Hibernate]
insert
into
customer
(card_id, name, id)
values
(?, ?, default)
[Hibernate]
insert
into
card
(number, id)
values
(?, default)
[Hibernate]
insert
into
customer
(card_id, name, id)
values
(?, ?, default)
=== 모든 카드 조회시 고객도 함께 가져온다 ===
[Hibernate]
select
c1_0.id,
c1_0.number
from
card c1_0
[Hibernate]
select
c1_0.id,
c2_0.id,
c2_0.number,
c1_0.name
from
customer c1_0
left join
card c2_0
on c2_0.id=c1_0.card_id
where
c1_0.card_id=?
[Hibernate]
select
c1_0.id,
c2_0.id,
c2_0.number,
c1_0.name
from
customer c1_0
left join
card c2_0
on c2_0.id=c1_0.card_id
where
c1_0.card_id=?
====================================
=========== 추가적인 SELECT 쿼리가 실행안된다 ============
테스트고객
테스트고객1
====================================================
=== 모든 고객 조회시 고객만 가져온다 ===
[Hibernate]
select
c1_0.id,
c1_0.card_id,
c1_0.name
from
customer c1_0
[Hibernate]
select
c1_0.id,
c2_0.id,
c2_0.name,
c1_0.number
from
card c1_0
left join
customer c2_0
on c1_0.id=c2_0.card_id
where
c1_0.id=?
[Hibernate]
select
c1_0.id,
c2_0.id,
c2_0.name,
c1_0.number
from
card c1_0
left join
customer c2_0
on c1_0.id=c2_0.card_id
where
c1_0.id=?
================================
=========== 추가적인 SELECT 쿼리가 실행된다. =============
1234
4321
====================================================
- 전략을 EAGER로 바꾸면 Card, Customer 모두 조회시 left join으로 연관된 객체를 전부 가져오게 됩니다.
- 따라서 Customer에서도 N+1 문제가 사라지게 됩니다.
- 하지만 EAGER는 말씀드린대로 연관된 모든 객체를 다가져오기 때문에 전부 사용하는것이 아니라면 메모리 측면에서 비효율적입니다.
- 따라서 연관된 일부 객체만 필요하다면 LAZY 전략을 기반으로 fetch join, entityGraph 등 방법을 사용해야합니다. 해당 내용은 추후 다른 포스팅에서 다루도록 하겠습니다.
참고
@ManyToMany는 연관 관계 매핑 (3)에서 다루도록 하겠습니다.
✨ Summary
@ManyToOne
- 가장 많이 사용
- Many쪽에 외래키가 생성되고, 연관 관계의 주인은 Many
@OneToMany
- @ManyToOne과 양방향 매핑시 주로 사용됨
- Many쪽에 외래키가 생성되고, 연관 관계의 주인은 Many
- 외래키가 없는 One쪽에서 외래키를 관리하게끔 할 수 있음(권장 X)
@OneToOne
- 두 엔티티가 1:1로 매핑
- @OneToMany와 다르게 외래키가 없는 쪽에서 외래키를 관리할 수 없음
- 무조건 외래키를 가진쪽이 외래키를 관리해야함
- 이미 DB에 외래키가 잡혀있고, 외래키가 없는 쪽에서 대상 테이블에 조회가 많을 시 양방향 매핑을 고려
- 양방향 매핑이 되어있을때 외래키가 A에 있다면 외래키가 없는 B가 findAll로 B객체를 조회시, 프록시 객체에 A의 null 유무를 알아야하므로 EAGER처럼 A의 정보도 모두 가져옴
LAZY vs EAGER
- 상황에 따라 적절히 사용해야함
- 주로 LAZY 기반으로 fetch join, EntityGraph등을 사용하여 연관된 객체중 필요한 놈들만 골라서 함께 가져옴
- 추후 다른 포스팅에서 자세히 다룰 예정