프로젝트 설정은 8장을 참조하자.
이전에 작성한 User, Post Entity를 그대로 활용할 것이다.
시작하기 전에, Lombok에서 제공하는 @Builder Annotation을 User, Post Entity에 적용하겠다.
적용 후 모습은 다음과 같다.
// User.java
@Entity
@Getter
@Setter
@Table(name = "\"user\"")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder // Builder Annotation 적용
public class User {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String name;
@OneToMany(mappedBy = "author")
@Builder.Default // 초기값이 있는 경우, 꼭 적용할 것
private List<Post> posts = new ArrayList<>();
}
// Post.java
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder // Builder Annotation 적용
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String title;
private String content;
@ManyToOne
private User author;
}
주의할 점은 @Builder 적용 시, private List<Post> posts = new ArrayList<>();와 같이 필드 초기화가 되는 부분에는 @Builder.Default를 추가해야 한다는 것이다.
또한 @AllArgsConstructor를 적용하지 않아도 오류가 발생하므로 적용해주자.
Post post = Post.builder()
.title("post1")
.content("content1")
.build();
User.builder()
.name("user")
.build();
이제 builder 메소드를 사용하여 원하는 필드만 옵셔널하게 설정하여 객체를 생성할 수 있다.
@OneToMany, @ManyToOne, @OneToOne, @ManyToMany 등의 연관 관계를 설정하기 위한 모든 Annotation에 fetch 옵션이 존재한다.
여기 들어갈 수 있는 값은 enum인 FetchType으로, 2가지 전략이 존재한다.
FetchType.EAGER, FetchType.LAZY다.
실제 코드는 다음과 같다.
public enum FetchType {
/** Defines that data can be lazily fetched. */
LAZY,
/** Defines that data must be eagerly fetched. */
EAGER
}
기본적으로 @OneToMany, @ManyToMany와 같이, 상대쪽이 Many인 경우는 FetchType.LAZY 가 기본값으로 설정되고,
@OneToOne, @ManyToOne과 같이 상대쪽이 One인 경우는 FetchType.EAGER이 기본값으로 설정된다.
무슨 차이인지 자세히 알아보기 전에 결론만 말하자면,
FetchType.LAZY를 명시하고 사용하는 것이 좋다.
아쉽게도 모든 연관 관계의 기본 fetch 옵션을 FetchType.LAZY로 전역 설정하는 방법은 없는 듯 하다.
귀찮지만 수동으로 FetchType.LAZY를 설정 해줘야 한다.
두 전략의 자세한 동작 방식의 차이에 대해서 알아보겠다.
두 Entity에 FetchType.EAGER을 적용하자.
// User.java
@Entity
@Getter
@Setter
@Table(name = "\"user\"")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String name;
// fetch 전략 EAGER 설정
@OneToMany(mappedBy = "author", fetch = FetchType.EAGER)
@Builder.Default
private List<Post> posts = new ArrayList<>();
}
// Post.java
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String title;
private String content;
// fetch 전략 EAGER 설정
@ManyToOne(fetch = FetchType.EAGER)
private User author;
}
이제 테스트 코드를 작성하겠다.
다음 코드의 주석을 자세히 읽어보자.
// UserTest.java
@SpringBootTest
@Transactional
@Commit // 모든 로직 성공 시, tx.commit 호출하여 DB에 변경사항 반영
class UserTest {
@Autowired
private EntityManager em;
@Test
void test() {
User user = User.builder()
.name("user")
.build();
Post post = Post.builder()
// 테이블 관점에서 봤을 때는, Post의 author_id를 설정하는 것
.author(user)
.title("post")
.content("content")
.build();
// SEQUENCE 전략이므로, DB에 insert into SQL을 전송하지 않고,
// Application으로부터 Sequence를 받아옴
em.persist(user);
em.persist(post);
// em.find는 Persistence Context에 동일한 Entity가 존재할 때,
// DB에 SQL을 보내지 않고 Persistence Context에서 Entity를 가져옴
System.out.println("----------em.find----------");
User foundUser = em.find(User.class, 1L);
// em.createQuery는 Persistence Context에 Entity가 존재해도
// DB에 SQL을 보내서 Entity를 가져옴
// 이 때, Persistence Context 내에 DB에 insert 되지 않은 Entity가 있다면
// 먼저 insert SQL을 내보낸 뒤, select SQL을 보내서 DB로부터 Entity를 가져옴
// 즉, em.createQuery는 em.flush를 발생시킴
System.out.println("----------em.createQuery----------");
List<User> foundUsers = em.createQuery("select u from User u", User.class)
.getResultList();
}
}
테스트 코드를 실행하여 Console에서 SQL을 살펴보자.
Hibernate:
select
nextval('user_seq')
Hibernate:
select
nextval('post_seq')
----------em.find----------
----------em.createQuery----------
Hibernate:
insert
into
"user"
(name, id)
values
(?, ?)
Hibernate:
insert
into
post
("author_id", content, title, id)
values
(?, ?, ?, ?)
Hibernate:
select
u1_0.id,
u1_0.name
from
"user" u1_0
em.flush를 호출하지 않았는데도 insert SQL이 출력된 것을 볼 수 있다.
GeneratedValue.IDENTITY 전략을 사용했다면 em.persist 시점에 insert SQL이 나갔겠지만,
현재는 GeneratedValue.SEQUENCE이기 때문에 em.flush가 호출되는 시점에 SQL이 전송된다.
@Commit annotation을 붙였기 때문에 모든 로직이 정상적으로 수행되면 최후에 tx.commit이 호출되고, 이는 em.flush를 호출한다.
그렇다고 해도 시점이 맞지 않는다.
만약 위 논리대로라면 em.createQuery가 호출된 뒤.
즉 select SQL 이후에 insert가 나갔어야 한다.
이로 말미암아 지금까지 본 적 없는 새로운 규칙이 하나 추가되었다는 것을 유추할 수 있다.
em.createQuery로 조회 시, Persistence Context를 경유하지 않고 바로 DB에 SQL을 보낸다는 규칙을 기억하는가.
만약 우리의 생각대로 select SQL 이후 insert SQL이 전송 되었다면,
em.persist가 호출되어 Persistence Context에만 Entity가 존재하고, DB에는 Entity가 존재하지 않는 상황에서
DB에서 가져올 Entity는 존재하지 않을 것이다.
고로 em.createQuery로 DB에서 Entity를 조회하는 경우는 Persistence Context에 저장된,
아직 DB에 저장되지 않은 Entity들을 먼저 insert 한 뒤 select 하는 것이다
즉, em.createQuery 호출 시, em.flush가 먼저 호출된 후 DB에 조회 SQL을 전송한다.
(정확히는 항상 insert 하는 것은 아니고, Persistence Context에서 '변경 감지'를 통해 변경된 사항이 존재할 때만 insert SQL을 날린다. em.createQuery를 2회 호출하면 이미 변경 사항이 반영되어 insert는 1번째 호출 때만 나갈 것이다.)
지금까지 학습했던 규칙을 아래에 정리했다.
GeneratedValue.IDENTITY 전략 사용 시, em.persist를 호출할 때 DB에 insert SQL을 전송 및 저장한다.
GeneratedValue.IDENTITY 이외의 전략 사용 시, em.persist를 호출할 때 Persistence Context에 Entity를 저장한다.
em.find 호출 시, Persistence Context에서 우선 Entity를 찾아보고 없을 경우에만 DB에 SQL을 전송하여 찾아온다.
em.createQuery 호출 시, Persistence Context를 건너 뛰고 DB에 SQL을 전송하여 찾아온다. 고로 조회 SQL을 전송하기 전에, Persistence Context에 존재하지만 DB에 아직 저장되지 않는 Entity를 먼저 insert 한 뒤 SQL을 전송한다.
SQL이 출력되는 순서 때문에 혼동될 수 있을 것 같다고 생각하여 위 규칙을 설명하였으며,
SQL을 좀 더 편하게 읽기 위해 em.flush를 호출하여 강제로 SQL을 전송하고 Persistence Context를 비우는 로직을 추가했다.
이제 조회 전에 insert SQL이 모두 전송되어 좀 더 명확하게 SQL을 읽을 수 있을 것이다.
// UserTest.java
@Test
void test() {
User user = User.builder()
.name("user")
.build();
Post post = Post.builder()
.author(user)
.title("post")
.content("content")
.build();
em.persist(user);
em.persist(post);
// Persistence Context 내의 Entity 저장 후 비우기
em.flush();
em.clear();
// em.find는 반드시 제거하자
System.out.println("----------em.createQuery----------");
List<User> foundUsers = em.createQuery("select u from User u", User.class)
.getResultList();
}
실행하면 다음과 같이 SQL이 출력된다.
Hibernate:
select
nextval('user_seq')
Hibernate:
select
nextval('post_seq')
Hibernate:
insert
into
"user"
(name, id)
values
(?, ?)
Hibernate:
insert
into
post
("author_id", content, title, id)
values
(?, ?, ?, ?)
----------em.createQuery----------
Hibernate:
select
u1_0.id,
u1_0.name
from
"user" u1_0
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
이전과 달리 em.createQuery 이후에는 조회 SQL만 나가서 좀 더 깔끔해졌다.
근데 이상한 부분이 있다.
분명 em.createQuery로 호출한 SQL은 select u from User u인데,
조회 SQL을 보면 post를 가져오는 SQL도 출력되었다.
이것이 EAGER 전략의 특징이다.
이 현상에 대해 좀 더 자세히 알아보겠다.
본래 우리가 보낸 SQL은 select u from User u이 전부다.
구분을 위해 이를 '본래 SQL'이라고 하겠다.
post에 대한 SQL은 우리가 작성하지 않은 SQL이다.
이를 '추가 SQL'이라고 하겠다.
Jpa는 연관 관계를 맺은 필드에 대해 자동으로 '추가 SQL'을 생성 및 전송한다.
user에 대한 SQL만 전송해도 자동으로 post에 대한 SQL까지 만들어 주기 때문에 언뜻 보면 매우 편리한 기능이다.
그러나 다음 예제를 보면 생각이 바뀔 것이다.
// UserTest.java
@Test
void test() {
// 10개의 User, Post 생성
for (int i = 0; i < 10; i++) {
User user = User.builder()
.name("user" + i)
.build();
Post post = Post.builder()
.author(user)
.title("post" + i)
.content("content" + i)
.build();
em.persist(user);
em.persist(post);
}
em.flush();
em.clear();
System.out.println("----------em.createQuery----------");
List<User> foundUsers = em.createQuery("select u from User u", User.class)
.getResultList();
}
단순히 1개씩 만들던 User, Post를 10개로 늘렸다.
테스트를 실행해보자.
----------em.createQuery----------
Hibernate:
select
u1_0.id,
u1_0.name
from
"user" u1_0
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
-- 추가 SQL 총 10개
em.createQuery 호출 이후의 SQL만 보면 이렇다.
차근차근 살펴보면 첫 SQL은 user에 대한 조회 SQL은 1개인데,
이후 10개는 post에 대한 조회 SQL이다.
본래 SQL은 여전히 1개지만, 추가 SQL이 1개에서 10개로 증가한 것이다.
생각해보면 user Entity가 1개던 10개던 10000개던 select u from User u 하나의 SQL로 모든 Entity를 가져올 수 있다.
그러나 이후 각 user에 대한 post를 가져오기 위해서는
select p from Post p where p.author_id = 1
select p from Post p where p.author_id = 2
select p from Post p where p.author_id = 3
select p from Post p where p.author_id = 4
...
위와 같이 각 user의 id와 일치하는 post를 각각의 SQL로 찾아와야 한다.
이번에 가져온 user Entity가 10개이므로 post에 대한 SQL이 10개가 된 것이다.
정리하면 어떤 상황에서도 언제나 본래 SQL은 1개다.
그리고 추가 SQL의 수는 본래 SQL로 가져온 Entity의 개수 x 연관 관계의 수다.
헷갈리면 안 되는 것이, user가 post를 여러 개 갖고 있다고 post에 대한 SQL 수가 늘어나지는 않는다는 것이다.
지금은 user가 각각 1개의 post를 가지고 있는데,
만약 user가 각각 2개의 post를 가지고 있다고 해도 추가 SQL의 개수는 여전히 10개일 것이다.
select p from Post p where p.author_id = 1
위 SQL문 하나면 user 한 명이 가진 모든 post를 다 가져올 수 있기 때문이다. 개수와 상관 없이.
그래서 추가 SQL 개수 공식은 본래 SQL로 가져온 Entity의 개수 x 연관 관계의 수인 것이다.
이를 검증하기 위해 각 user 당 100개의 post를 갖도록 코드를 수정해보자.(어차피 몇개여도 SQL 개수는 똑같기 때문에 그 이상 해도 상관은 없다.)
// UserTest.java
@Test
void test() {
for (int i = 0; i < 10; i++) {
User user = User.builder()
.name("user" + i)
.build();
for (int j = 0; j < 100; j++) {
Post post = Post.builder()
.author(user)
.title("post" + j)
.content("content" + j)
.build();
em.persist(post);
}
em.persist(user);
}
em.flush();
em.clear();
System.out.println("----------em.createQuery----------");
List<User> foundUsers = em.createQuery("select u from User u", User.class)
.getResultList();
}
실행하면 역시 결과는 동일하다.
----------em.createQuery----------
Hibernate:
select
u1_0.id,
u1_0.name
from
"user" u1_0
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
-- 추가 SQL 총 10개
다음으로 Item이라는 Entity를 하나 추가해보겠다.
추가 SQL의 개수를 구하는 공식이 본래 SQL로 가져온 Entity 수 x 연관 관계의 수라고 했으니, user에 연관 관계를 하나 추가하면
추가 SQL은 2배인 20개가 될 것이다.
// User.java
@Entity
@Getter
@Setter
@Table(name = "\"user\"")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String name;
@OneToMany(mappedBy = "author", fetch = FetchType.EAGER)
@Builder.Default private List<Post> posts = new ArrayList<>();
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
@Builder.Default private List<Item> items = new ArrayList<>();
}
// Item.java
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Item {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String itemName;
@ManyToOne
private User owner;
}
테스트 코드는 변경 없이 실행하겠다.
그래도 item에 대한 추가 SQL이 나갈 것이다.
왜냐면 user 테이블은 post, item에 대해 모두 1:N 관계이므로, FK가 존재하지 않기 때문에 SQL을 쏴 봐야 연관 관계인 Record의 유무를 알 수 있다.
무조건 쏴봐야 있나 없나 알기 때문에 굳이 user에 item을 넣어주지 않아도 추가 SQL은 실행된다.
----------em.createQuery----------
Hibernate:
select
u1_0.id,
u1_0.name
from
"user" u1_0
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
Hibernate:
select
i1_0."owner_id",
i1_0.id,
i1_0.item_name
from
item i1_0
where
i1_0."owner_id"=?
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
--- 총 20개의 추가 SQL
추가 SQL을 세보면 20개로 예상과 동일하다.
본래 SQL은 여전히 1개다.
아마 들어봤을 수도 있는데, 이것이 그 악명 높은 N+1 문제다.
1개의 본래 SQL + N개의 추가 SQL이 발생하는 것을 의미하며,
추가 SQL의 개수, N을 구하는 공식은 앞서 봤던대로다.
N+1 문제는 반드시 최적화를 요하는 문제다.
관계가 지금처럼 1~2개가 아니라 훨씬 더 많은 테이블이 엮이게 된다면 엄청난 개수의 추가 SQL이 전송될 것이며, 이는 심각한 오버헤드를 유발할 것이다.
(추후 다양한 방법으로 최적화 해볼 것이기 때문에 해결 방법은 나중에 자세히 알아보자.)
게다가 user Entity를 가져올 때,
post, item이 모두 필요하지 않은 상황이 있을 수도 있다.
예를 들어, 사용자가 작성한 post를 조회하기 위해 id가 1인 user를 조회했다고 가정하자.
이 때도 user에 대한 본래 SQL이 전송된 후, post, item에 대한 추가 SQL도 전송된다.
item을 조회할 필요가 없는데도 말이다.
EAGER 로딩은 추가 SQL을 본래 SQL이 전송된 직후, 바로 전송한다.
이것이 EAGER 로딩 전략의 문제점이다.
가져오고 싶은 연관 관계와 가져올 타이밍을 선택할 수 없다는 것.
이로 인해 EAGER 전략은 사용되지 않으며, LAZY 로딩 전략을 사용해야 한다.
그리고 헷갈리면 안 되는 부분이 있는데,
추가 SQL이 전송되는 것은 EAGER 만의 문제가 아니다.
LAZY 로딩 전략을 사용하더라도 추가 SQL은 전송된다.
EAGER와 LAZY 전략은 모두 본 SQL 이후, 추가 SQL이 생성된다.
이 부분을 인지하고 들어가보자.
// User.java
@Entity
@Getter
@Setter
@Table(name = "\"user\"")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String name;
// EAGER -> LAZY로 변경
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
@Builder.Default private List<Post> posts = new ArrayList<>();
// @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
// @Builder.Default
// private List<Item> items = new ArrayList<>();
}
items는 추가 SQL의 개수가 본래 SQL로 가져온 Entity 수 x 연관 관계의 개수임을 증명하기 위해 작성했던 부분이므로, 제거하겠다.
OneToMany의 fetch 전략도 LAZY로 변경했다.
이전에 작성한 테스트 코드를 그대로 실행해보자.
----------em.createQuery----------
Hibernate:
select
u1_0.id,
u1_0.name
from
"user" u1_0
본래 SQL 이후 추가 SQL가 전송되지 않는다.
몇 번이나 강조했던 EAGER와 LAZY 전략은 모두 본 SQL 이후, 추가 SQL이 생성된다. 라는 말과 다르다는 생각이 들 것이다.
정확히는 LAZY 전략도 추가 SQL이 생성되지만, EAGER 전략과 추가 SQL이 생성되는 시점이 다르다.
추가 SQL이 '아직' 생성되지 않은 상태인 것이다.
LAZY 전략은 연관 관계를 조회할 때 추가 SQL를 생성한다.
각 user에서 연관 관계인 post를 가져오는 getPosts를 호출하도록 코드를 변경해보자.
// UserTest.java
@Test
void test() {
for (int i = 0; i < 10; i++) {
User user = User.builder()
.name("user" + i)
.build();
for (int j = 0; j < 100; j++) {
Post post = Post.builder()
.author(user)
.title("post" + j)
.content("content" + j)
.build();
em.persist(post);
}
em.persist(user);
}
em.flush();
em.clear();
System.out.println("----------em.createQuery----------");
List<User> foundUsers = em.createQuery("select u from User u", User.class)
.getResultList();
// 본 SQL로 가져온 모든 User의 post를 조회
for (User foundUser : foundUsers) {
foundUser.getPosts();
}
}
그러나 여전히 Console에는 본 SQL밖에 보이지 않는다.
user.getPosts를 호출해도, 현재 들어있는 것은 초기에 posts에 할당헀던 빈 List를 반환할 뿐이다.(정확히는 Hibernate에 의해 List가 Proxy 객체로 치환되지만 이 부분은 지금은 넘어가도록 하자.)
getPosts를 호출한 뒤, 그 안에서 실제 Post를 꺼내 와야지만 추가 SQL이 나가는 것이다.
for (User foundUser : foundUsers) {
foundUser.getPosts().get(0);
}
방금 작성한 테스트 코드에서 마지막 조회 부분만 살짝 바꿔주자.
----------em.createQuery----------
Hibernate:
select
u1_0.id,
u1_0.name
from
"user" u1_0
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
Hibernate:
select
p1_0."author_id",
p1_0.id,
p1_0.content,
p1_0.title
from
post p1_0
where
p1_0."author_id"=?
-- post에 대한 추가 SQL 총 10개
이제 추가 SQL이 잘 출력되는 것을 확인할 수 있다.
이로써 LAZY 전략도 EAGER 전략과 마찬가지로 추가 SQL을 호출한다는 사실이 증명되었다.
추가 SQL 시점을 호출하는 시점이 다른 것이다.
EAGER 로딩 전략은 본 SQL이 나간 직후 연관 관계에 대한 추가 SQL이 발생하고, LAZY 로딩 전략은 연관 관계에 속하는 객체를 조회할 때 추가 SQL이 발생한다.
그런데 EAGER 전략과 달리 LAZY는 조회를 하지 않으면 추가 SQL을 호출하는 시점이 영원히 오지 않는다.
user가 연관 관계로 post, item을 가지고, 사용자의 post만 조회하고 싶을 때 EAGER는 모든 post, item을 본 SQL 직후에 모두 가져오기 때문에 연관 관계를 선택해서 가져올 수 없다.
LAZY 사용 시, post만 조회하고 item를 조회하지 않으면, item에 대한 추가 SQL을 생성하는 시점이 오지 않기 때문에, 결과적으로 추가 SQL을 선택적으로 생성할 수 있게 된다.
연관 관계가 늘어날수록 LAZY 전략이 훨씬 유연하고 경제적일 것이다.