티스토리 뷰
N + 1 문제에 대해서 알아보기 이전에 JPQL 부터 간단하게 알아보자
JPQL(Java Persistence Query Language) 는 엔티티의 객체를 대상으로 검색하는 객체 지향 쿼리이다.
EntityManager.find() 메소드를 사용하면 식별자로 엔티티 하나를 조회하여 객체 그래프 탐색을 사용하면서 연관된 엔티티를 찾을 수 있다.
하지만 이것만으로는 복잡하거나 어려운 쿼리문을 사용하기에 힘들어진다.
이를 해결하고자 만들어진 것이 바로 JPQL이다.
(☞゚ヮ゚)☞ JPA 과 JPQL 관계
JPQL을 사용하면 JPA는 해당 JPQL을 분석한 다음 적절한 SQL을 만들어서 데이터를 조회한다.
조회된 결과값을 엔티티 객체를 생성해서 반환한다.
예시)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByName(String name);
}
//findByName 호출하였을 때 실행되는 JPQL
select user0_.id as id1_1_, user0_.created_at as created_2_1_, user0_.name as name3_1_ from user user0_ where user0_.name=?
N + 1 문제란 한 번 조회할 것을 N개의 데이터만큼 추가로 조회하게 되서 N + 1만큼 조회 하는 문제를 말한다.
1:N, N:1 연관 관계로 이루어진 테이블에서 데이터를 조회할 때 발생한다.
.
.
그럼 예제를 통해 한번 확인해보자.
즉시로딩과 N + 1 문제
Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private LocalDateTime createdAt;
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER) //즉시로딩
private List<Account> accountList = new ArrayList<>();
}
public class Account {
@Id
private String number;
private String bank;
@ManyToOne
@JoinColumn(name = "id", referencedColumnName = "id")
private User user;
}
👋🏼 여기서 잠깐, 테스트를 위해 데이터가 필요하기 때문에 위 엔티티를 기반으로 사전에 데이터를 입력해 놓았다.
user는 총 3명 : 김세영, 홍길동, 박철수
account는 총 7개 : 김세영's 3개, 홍길동's 2개, 박철수's 2개
테스트 코드
@Test
@DisplayName("사용자 전체 조회 시 N + 1 문제 발생 확인 - 즉시로딩")
void findAllWithEagerLoading() {
List<User> users = userRepository.findAll();
users.forEach(user -> System.out.println(user.getId()));
}
//실행 된 쿼리
1) Hibernate: select user0_.id as id1_1_, user0_.created_at as created_2_1_, user0_.name as name3_1_ from user user0_
2) Hibernate: select accountlis0_.id as id3_0_0_, accountlis0_.number as number1_0_0_, accountlis0_.number as number1_0_1_, accountlis0_.bank as bank2_0_1_, accountlis0_.id as id3_0_1_ from account accountlis0_ where accountlis0_.id=?
3) Hibernate: select accountlis0_.id as id3_0_0_, accountlis0_.number as number1_0_0_, accountlis0_.number as number1_0_1_, accountlis0_.bank as bank2_0_1_, accountlis0_.id as id3_0_1_ from account accountlis0_ where accountlis0_.id=?
4) Hibernate: select accountlis0_.id as id3_0_0_, accountlis0_.number as number1_0_0_, accountlis0_.number as number1_0_1_, accountlis0_.bank as bank2_0_1_, accountlis0_.id as id3_0_1_ from account accountlis0_ where accountlis0_.id=?
위 테스트 코드를 실행하였을 때, 쿼리는 총 4번이 실행되었다.
user 테이블에서 사용자를 전체 조회해 온것인데, 4번이라니!
실행된 쿼리를 순차적으로 살펴보자.
1) user 테이블에서 사용자 전체를 조회해온다. = 사용자 3명 (이 쿼리만 실행 될 줄 알았던 ..!)
2 ~ 4) 1 쿼리에서 가져온 사용자 3명을 기준으로 account 를 즉시(* 페치 전략이 EAGER 이니까) 조회한다.
.
.
혹시 즉시로딩을 사용해서 N + 1 문제가 발생한 것이 아닐까?
지연로딩은 문제가 없을까? 한번 확인 해보자.
.
.
지연로딩과 N + 1 문제
그 전에 user 테이블의 코드를 좀 수정해야 한다.
public class User {
..
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY) //지연로딩
private List<Account> accountList = new ArrayList<>();
}
테스트 코드
@Test
@DisplayName("사용자 전체 조회 시 N + 1 문제 발생 확인 - 지연로딩")
void findAllWithLazyLoading() {
List<User> users = userRepository.findAll();
users.forEach(user -> System.out.println(user.getId()));
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
users.forEach(user -> user.getAccountList()
.forEach(account -> System.out.println(account.getNumber())));
}
//실행된 쿼리 및 로그
Hibernate: select user0_.id as id1_1_, user0_.created_at as created_2_1_, user0_.name as name3_1_ from user user0_
김세영
홍길동
박철수
1초 쉬겠습니다..
1초 다 쉬었습니다..
Hibernate: select accountlis0_.id as id3_0_0_, accountlis0_.number as number1_0_0_, accountlis0_.number as number1_0_1_, accountlis0_.bank as bank2_0_1_, accountlis0_.id as id3_0_1_ from account accountlis0_ where accountlis0_.id=?
110-123-4567892
110-123-4567893
110-123-4567894
Hibernate: select accountlis0_.id as id3_0_0_, accountlis0_.number as number1_0_0_, accountlis0_.number as number1_0_1_, accountlis0_.bank as bank2_0_1_, accountlis0_.id as id3_0_1_ from account accountlis0_ where accountlis0_.id=?
110-123-4567890
110-123-4567891
Hibernate: select accountlis0_.id as id3_0_0_, accountlis0_.number as number1_0_0_, accountlis0_.number as number1_0_1_, accountlis0_.bank as bank2_0_1_, accountlis0_.id as id3_0_1_ from account accountlis0_ where accountlis0_.id=?
110-123-45678910
110-123-45678911
지연로딩이라서 연관된 account는 바로 조회되지 않다는 것 즉, 실제로 account 를 출력할 때 쿼리문을 실행한다는 것을 알 수 있다.
물론 user의 하나에 대한 연관된 account만 조회한다면 문제가 되지 않는다 !
문제는 전체 user의 연관된 account를 사용할 때 발생한다는 것이다.
즉, user의 수만큼 추가 쿼리가 발생하는 것으로 지연로딩 또한 N + 1 문제가 발생할 수 있다.
그렇다면 왜 N + 1 문제가 발생하는 것일까?
JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 페치 전략을 참고하지 않고 오직 JPQL 자체만 사용하기 때문이다.
즉, 즉시 로딩이든 지연 로딩이든 구분하지 않고 JPQl 쿼리 자체에 충실하게 SQL을 만든다는 것이다.
그럼 N + 1 문제를 해결하기 위한 방법은 무엇이 있을까?
페치 조인 사용
페치 조인은 JPQL에서 성능 최적화를 위해 제공하는 기능으로 SQL 한 번으로 연관된 엔티티를 함께 조회할 수 있어서 SQL 호출 횟수를 줄일 수 있다.
페치 조인은 글로벌 로딩 전략보다 우선시 된다.
예를 들어 글로벌 로딩 전략으로 지연 로딩을 설정해도 JPQL에서 페치 조인을 사용하면 페치 조인을 적용해서 함께 적용되며, 연관된 엔티티를 쿼리 시점에 조회하므로 지연로딩이 발생하지 않는다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT user FROM User user JOIN FETCH user.accountList")
List<User> findAllWithFetchJoin();
}
@Test
@DisplayName("N + 1 문제 해결 - collection fetchJoin")
void findAllWithFetchJoin() {
List<User> users = userRepository.findAllWithFetchJoin();
users.forEach(user -> {
user.getAccountList().forEach(account -> {
System.out.println(user.getName() + ", " + account.getNumber());
});
});
}
//실행된 쿼리 및 로그
Hibernate: select user0_.id as id1_1_0_, accountlis1_.number as number1_0_1_, user0_.created_at as created_2_1_0_, user0_.name as name3_1_0_, accountlis1_.bank as bank2_0_1_, accountlis1_.id as id3_0_1_, accountlis1_.id as id3_0_0__, accountlis1_.number as number1_0_0__ from user user0_ inner join account accountlis1_ on user0_.id=accountlis1_.id
김세영, 110-123-4567892
김세영, 110-123-4567893
김세영, 110-123-4567894
김세영, 110-123-4567892
김세영, 110-123-4567893
김세영, 110-123-4567894
김세영, 110-123-4567892
김세영, 110-123-4567893
김세영, 110-123-4567894
홍길동, 110-123-4567890
홍길동, 110-123-4567891
홍길동, 110-123-4567890
홍길동, 110-123-4567891
박철수, 110-123-45678910
박철수, 110-123-45678911
박철수, 110-123-45678910
박철수, 110-123-45678911
위 실행된 로그를 확인하게 되면, 쿼리는 한 번만 실행 됬는데 결과 값이 중복으로 나오는 것을 확인 할 수가 있다.
이유는 페치 조인은 카디션 곱이 되기 때문이다.
예를 들어 김세영이라는 사용자는 한 명이지만, 관련된 account는 3개이기 때문에 User List 안에 김세영이라는 데이터가 3개 존재하는 것이다.
이를 해결하기 위한 방법은 아래와 같다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
//1. distinct 추가하기
@Query("SELECT DISTINCT user FROM User user JOIN FETCH user.accountList")
List<User> findAllWithFetchJoin();
//2. set 으로 리턴받기
@Query("SELECT user FROM User user JOIN FETCH user.accountList")
Set<User> findAllWithFetchJoin();
}
(참고)
패치 조인을 사용하였을 때, primary key 를 기준으로 참조할 때만 가능하다.
account 엔티티에서 user 엔티티의 name (primary 키 아님)을 참조하고 있을 때 페치 조인을 사용하면, N + 1 만큼 조회되는 것을 확인할 수 있다.
public class Account {
@ManyToOne
@JoinColumn(name = "name", referencedColumnName = "name")
private User user;
private String bank;
@Id
private String number;
}
//테스트 코드
@Test
@DisplayName("N + 1 문제 해결 - fetchJoin")
void findAllWithFetchJoin() {
//중복 데이터 발생
List<User> users = userRepository.findAllWithFetchJoin();
users.forEach(user -> {
user.getAccountList().forEach(account -> {
System.out.println(user.getName() + ", " + account.getNumber());
});
});
}
//로그
Hibernate: select user0_.id as id1_1_0_, accountlis1_.number as number1_0_1_, user0_.created_at as created_2_1_0_, user0_.name as name3_1_0_, accountlis1_.bank as bank2_0_1_, accountlis1_.name as name3_0_1_, accountlis1_.name as name3_0_0__, accountlis1_.number as number1_0_0__ from user user0_ inner join account accountlis1_ on user0_.name=accountlis1_.name
Hibernate: select user0_.id as id1_1_0_, user0_.created_at as created_2_1_0_, user0_.name as name3_1_0_ from user user0_ where user0_.name=?
Hibernate: select user0_.id as id1_1_0_, user0_.created_at as created_2_1_0_, user0_.name as name3_1_0_ from user user0_ where user0_.name=?
Hibernate: select user0_.id as id1_1_0_, user0_.created_at as created_2_1_0_, user0_.name as name3_1_0_ from user user0_ where user0_.name=?
패치 조인의 한계
- 페치 조인 대상에는 별칭을 줄 수 없다.
- 둘 이상의 컬렉션을 페치할 수 없다.
- 컬렉션을 페치 조인하면 Paging API를 사용할 수 없다.
@BatchSize
@Batchize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size 만큼 SQL의 IN 절을 사용해서 조회한다.
user의 연관관계인 accountList 필드에 @BatchSize 를 2로 설정해보자.
class User {
..
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 2)
private List<Account> accountList = new ArrayList<>();
}
테스트 코드
@Test
@DisplayName("N + 1 문제 해결 - @BatchSize")
void findAllWithBatchSize() {
List<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user.getName());
}
System.out.println(users.get(0).getAccountList().size());
System.out.println(users.get(1).getAccountList().size());
System.out.println(users.get(2).getAccountList().size());
}
//페치 전략이 지연로딩일 때 실행된 쿼리 및 로그
1) Hibernate: select user0_.id as id1_1_, user0_.created_at as created_2_1_, user0_.name as name3_1_ from user user0_
김세영
홍길동
박철수
2) Hibernate: select accountlis0_.id as id3_0_1_, accountlis0_.number as number1_0_1_, accountlis0_.number as number1_0_0_, accountlis0_.bank as bank2_0_0_, accountlis0_.id as id3_0_0_ from account accountlis0_ where accountlis0_.id in (?, ?)
3
2
3) Hibernate: select accountlis0_.id as id3_0_1_, accountlis0_.number as number1_0_1_, accountlis0_.number as number1_0_0_, accountlis0_.bank as bank2_0_0_, accountlis0_.id as id3_0_0_ from account accountlis0_ where accountlis0_.id=?
2
//페치 전략이 즉시로딩일 때 실행된 쿼리 및 로그
1) Hibernate: select user0_.id as id1_1_, user0_.created_at as created_2_1_, user0_.name as name3_1_ from user user0_
2) Hibernate: select accountlis0_.id as id3_0_1_, accountlis0_.number as number1_0_1_, accountlis0_.number as number1_0_0_, accountlis0_.bank as bank2_0_0_, accountlis0_.id as id3_0_0_ from account accountlis0_ where accountlis0_.id in (?, ?)
3) Hibernate: select accountlis0_.id as id3_0_1_, accountlis0_.number as number1_0_1_, accountlis0_.number as number1_0_0_, accountlis0_.bank as bank2_0_0_, accountlis0_.id as id3_0_0_ from account accountlis0_ where accountlis0_.id=?
김세영
홍길동
박철수
3
2
2
실행 된 쿼리문을 하나씩 살펴보자.
1) 사용자 목록을 조회하는 쿼리문 실행
2) @BatchSize 가 2로 설정되어 있으니, 사용자 2명에 대한 account 목록을 조회한다.
JPQL의 where 절을 확인하게 되면 where accountList.id in (?, ?) 으로 되어있는데, 이는 @BatchSize 만큼의 사용자에 대한 목록을 조회하는 것이다.
3) 나머지 1명에 대한 account 목록을 조회한다.
정리
즉시로딩과 지연로딩 둘 다 N + 1 문제를 가지고 있다.
하지만 즉시로딩은 위 문제를 포함하여 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩하기도 한다.
따라서 우리는 모두 지연로딩을 사용(연관관계 매핑의 페치 전략의 기본값은 확인 필수 !)하고 성능 최적화가 필요할 경우에 JPQL 페치 조인을 사용해서 해결하도록 하자.
참고
자바 ORM 표준 JPA 프로그래밍 - 김영한
'Framework > Spring' 카테고리의 다른 글
[Spring] DispatcherServelt (Feat. Front Controller) (0) | 2022.12.16 |
---|---|
[Spring] 순환 참조 (0) | 2022.12.13 |
[JPA] 즉시로딩과 지연로딩 (0) | 2022.11.14 |
[JPA] 프록시 객체 (0) | 2022.10.31 |
[JPA] 양방향 연관관계 매핑 (0) | 2022.10.26 |
- Total
- Today
- Yesterday
- spring boot
- 다중화
- 인스턴스변수
- AutoConfiguration
- fail-safe
- HashSet
- 고정 세션
- 티스토리챌린지
- Red-Black Tree
- Sticky Session
- 정적변수
- syncronized
- HashMap
- Load Balancer
- Security
- 추상클래스
- Spring
- @conditional
- nginx
- java
- fail-fast
- 로드 밸런서
- 인터페이스
- nosql
- Caching
- 자동구성
- JPA
- Hash
- object
- 오블완
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |