본문 바로가기
Back-end/JPA 개념

15. 지연 로딩 & 즉시 로딩(N + 1 문제)

by devraphy 2022. 4. 11.

0. 개요

- 이전 포스팅에서 Proxy에 대해 알아보았다.

- 이번 포스팅에서는 Proxy를 이용한 지연 로딩과 즉시 로딩에 대해서 알아보자.

 

1. 지연 로딩

a) 예시 코드

- 다음과 같은 객체를 가지는 프로젝트가 있다고 해보자.

@Entity
public class Player {

    @Id @GeneratedValue
    @Column(name = "PLAYER_ID")
    private Long id;

    @Column(name = "PLAYER_NAME")
    private String name;

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

    // Getter & Setter 생략
}

 

@Entity
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    @Column(name = "TEAM_NAME")
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Player> players = new ArrayList<>();
    
    // Getter & Setter 생략
}

 

b) 예시 상황 - 언제 사용할까?

- 위의 예시 코드를 기반으로 이해해보자. 

 

- 위의 프로젝트는 Player와 Team 객체를 함께 조회하는 비즈니스 로직을 사용한다.

- 그러나 대부분의 경우, Player 객체와 Team 객체의 정보의 필요 시점이 다르다고 한다. 

- 객체 하나를 사용하기 위해서 두 객체를 모두 조회하는 것은 자원 낭비다.

 

- 이러한 상황을 지연 로딩을 이용하여 해결할 수 있다. 

 

c) FetchType.LAZY

- JPA는 지연 로딩 옵션을 제공한다.

- 다음과 같은 방식으로 지연 로딩을 사용할 수 있다.

@Entity
public class Player {

    @Id @GeneratedValue
    @Column(name = "PLAYER_ID")
    private Long id;

    @Column(name = "PLAYER_NAME")
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

 

- 위의 코드처럼 fetch 옵션을 통해 지연 로딩을 설정할 수 있다.

- 지연 로딩을 설정하면 team 객체를 조회할 때 Proxy 객체를 사용한다.

- 즉, Team 객체는 사용하는 시점에서 조회된다.

 

- 다음 예시 코드와 실행 결과를 확인해보자. 

 

- 최초에 조회된 결과는 아래의 사진과 같다.

 

- Team 객체의 정보를 필요로 하지 않는 시점에서는 Player 객체만 DB에서 조회한다.

- Team 객체의 class를 확인해보면 Proxy 객체임을 확인할 수 있다. 

 

- 이후 getTeam.getName() 코드를 실행하게 되는데, 이 시점에서 Team 객체의 정보를 필요로 하게 된다.

- 즉, 이 부분이 Proxy 객체를 통해 Team 객체의 정보를 호출하는 시점이다.

 

- 위의 사진처럼, Team 객체의 정보를 필요로 하는 시점에서 Team 객체를 조회하는 쿼리가 발생한다.

- 이 과정에서 Proxy 객체를 통해 진짜 객체에 접근하게 된다. (이전 포스팅에서 설명한 내용)

 

d) 결론 

- 이처럼 지연 로딩을 설정하면, 연관 객체를 Proxy 객체로 반환한다.

- 연관 객체가 Proxy 이므로 진짜 객체의 정보가 필요한 시점에서 쿼리가 발생한다.

 

2. 즉시 로딩

a) 예시 상황 - 언제 사용할까?

- 지연 로딩과는 반대의 상황으로, Player와 Team 객체를 항시 함께 조회하는 경우가 대부분이라고 해보자.

- 이러한 경우 즉시 로딩의 사용을 권장한다.

 

b) FetchType.EAGER 

- 다음과 같이 즉시 로딩으로 설정하여 실행해보자.

 

 

- 위의 코드를 실행하면 다음과 같은 쿼리가 발생한다.

 

- 위의 쿼리를 보면, Join을 사용하여 Player와 Team 객체를 모두 조회한다.

- Team 객체의 class를 확인해보면 Team 객체를 반환한다.

- 즉, 즉시 로딩을 사용하면 연관 객체를 함께 DB에서 조회한다. 

 

- Team 객체의 정보를 조회하면 다음과 같은 결과를 얻는다.

- Proxy 객체를 사용하는 것이 아니므로 Team 객체의 정보를 사용할 때 쿼리가 따로 발생하지 않는다.

 

c) 결론

- 즉시 로딩을 사용하면 Join을 사용하여 연관 객체를 함께 조회한다.

 

3. 실무에서는 어떻게 사용할까? 

- 실무에서는 지연 로딩을 사용하는 것을 권장한다.

- 그 이유가 무엇인지, 즉시 로딩이 갖는 문제점이 무엇인지 살펴보자.

 

a) 예상치 못한 SQL

- 즉시 로딩을 사용하면 개발자가 예상하지 못한 SQL이 작성된다.

- 예상치 못한 SQL이란, 개발자의 의도와 다른 SQL이 작성된다는 것이다.

- 그 이유는 다음과 같다.

 

- JPA는 객체 간의 관계를 연관 관계로 표현한다.

- 하나의 객체와 연관된 객체가 다수인 경우, DB는 해당 연관 객체의 테이블을 모두 Join 한다.

- 만약 연관 객체가 5개라면 5개의 테이블을 Join 하는 것이다. 

 

- 이처럼 연관된 테이블을 모두 Join 하는 경우, 조회 성능에 문제가 발생한다.

- 더불어, 개발자는 하나의 객체 정보를 조회하는 목적으로 코드를 작성하지만 즉시 로딩으로 인해

  의도하지 않은 Join 문이 발생하게 된다.

 

b1) N + 1 문제

- 즉시 로딩을 사용하는 경우, JPQL을 사용한 조회에서 N + 1번의 쿼리가 작성되는 문제가 발생한다.

- 다음 예시를 통해서 이해해보자.

 

- 위의 예시는 JPQL을 이용하여 Player 객체를 조회하는 코드다.

- Player 테이블을 조회하기 위해 총 1번의 쿼리가 발생할 것으로 예상된다.

- 그러나 이를 실행하면 다음과 같이 2번의 쿼리가 발생한다.

 

- 분명 Player 객체를 조회했는데, Team 테이블까지 조회하는 것을 확인할 수 있다.

- 이와 같은 현상을 JPQL의 N + 1 문제라고 하는데, N + 1 문제가 발생하는 이유는 다음과 같다.

 

b2) JPQL에서 N + 1 문제가 발생하는 이유 

- em.find()를 사용하여 객체를 조회하는 경우, 조회하는 객체의 class와 PK를 매개변수로 넘긴다.

em.find(Player.class, player.getId());

- 그러므로 해당 테이블을 조회하면서 연관된 객체를 Join으로 가져온다. (즉시 로딩 사용 시)

 

- 그러나 JPQL은 우선 입력된 문자열(= JPQL)을 SQL로 번역하는 과정을 거친다.

"select p from Player p" ==> select * from Player;

- 위의 JPQL을 번역하기 위해 Player 객체를 탐색하는데, 이때 JPA가 Player의 참조 필드(Team 객체)를 발견한다.

- JPA는 Team 객체의 FetchType.EAGER 옵션을 확인하고, Team 객체를 조회하는 쿼리문을 작성한다.

 

- 즉시 로딩은 반드시 연관 객체의 값을 가져와야 하는 설정이다. 

- 즉, 즉시 로딩 옵션으로 인하여 개발자가 의도하지 않은 추가적인 쿼리가 발생하게 되는 것이다.

 

- 다음 예시를 살펴보자.

Team team1 = new Team();
team1.setName("ManUnited");
em.persist(team1);

Team team2 = new Team();
team2.setName("RealMadrid");
em.persist(team2);

Player player = new Player();
player.setName("JI-SUNG PARK");
player.setTeam(team1);

Player player2 = new Player();
player2.setName("Raul");
player2.setTeam(team2);

em.persist(player);
em.persist(player2);

em.flush();
em.clear();

List<Player> players = em.createQuery("select p from Player p", Player.class).getResultList();

tx.commit();

 

- 위의 예시는 JPQL을 이용하여 Player 객체를 조회하는 코드다.

- Player 객체 내부에는 Team 객체를 연관 객체(= 참조 필드)로 보유하고 있으며, 즉시 로딩 설정이 되어 있는 상태다.

- Player 테이블은 총 2개의 Player 객체를 보유하고 있으므로, 총 3번의 쿼리가 발생할 것으로 예상된다.

 

- 다음 실행 결과를 살펴보자.

 

- 첫 번째 쿼리는 Player 테이블을 조회하는 쿼리다. 2명의 선수 데이터를 보유하므로, 총 2개의 결과를 반환한다.

- 2개의 Player 정보에 매칭 하는 Team 정보가 필요하므로, 각 Player 객체의 Id 값을 이용한 조회 쿼리가 2번 작성된다.

- 이렇게 총 3번의 쿼리가 발생하게 된다.

 

b3) N + 1의 의미

- 1은 개발자가 입력한 객체의 정보를 조회하기 위해 작성되는 쿼리의 수를 의미한다.

   ex) select * from Player ==> PK 1번, 2번을 가지는 데이터를 반환

 

- N은 첫 번째 쿼리문에서 조회된 결과만큼, 각 연관 객체를 N번 조회한다는 의미다.

   ex) select * from Team where player_id = 1;

   ex) select * from Team where player_id = 2;

 

   ex) Player 조회 결과 10개의 데이터를 반환하면, 이에 매칭 하는 10개의 Team 정보가 필요하다.

         그러므로 각 Player 객체의 Id 값을 이용하여 Team 객체를 10번 조회하게 된다.

 

   ex) 객체 A의 연관 객체로 객체 B와 객체 C가 있다고 해보자.

         만약 객체 A의 조회 결과가 10개라면, 이에 대응하는 객체 B와 객체 C의 정보가 각각 10개씩 필요하다.

         그러므로 총 21번의 쿼리가 발생하게 된다. (= 객체 A 조회 1번, 객체 B 조회 10번, 객체 C 조회 10번) 

 

- 지연 로딩을 사용한다면 N + 1 문제는 발생하지 않는다.

- N + 1 문제를 해결하는 다양한 전략에 대해서는 추후에 자세히 다루도록 하겠다.

 

c) @ManyToOne, @OneToOne

- @ManyToOne과 @OneToOne 연관 관계는 즉시 로딩을 기본 설정(default)으로 사용한다.

- 그러므로 이와 같은 연관 관계를 사용하는 경우에는 필수적으로 지연 로딩 옵션을 설정하자.

 

- 반대로 @OneToMany는 지연 로딩을 기본 설정(default)으로 사용한다.

 

d) 결론 

- 모든 연관 관계에서 지연 로딩을 사용하자.

- 실무에서는 지연 로딩만 사용한다.

- 즉시 로딩은 사용하지 않는다. 

'Back-end > JPA 개념' 카테고리의 다른 글

17. 고아 객체(Orphan)  (0) 2022.04.13
16. 영속성 전이(Cascade)  (0) 2022.04.12
14. 프록시(Proxy)  (0) 2022.04.08
13. @MappedSuperClass  (0) 2022.04.07
12. 상속 관계 매핑  (0) 2022.04.06

댓글