[JPA] N + 1 문제

이신영·2024년 8월 2일
0

JPA

목록 보기
2/2
post-thumbnail

자동적으로 처리하는건 비용이 늘어난다거나 오류가 발생하기 마련이다. 큰 힘이란 그런 법..

자칫 잘못쓰면 비용의 괴물이 되는 JPA의 대표적인 문제인 N + 1 문제에 대해서 알아보도록하자!


N + 1 ?

문제상황을 조금 더 와닿게 말하려면 1 + N 이 맞다.
1번 수행해야하는 쿼리를 추가적으로(N번) 수행된다는 문제다.

문제 상황

데이터베이스에 User와 Profile 테이블이 있다고 가정하자.

쿼리로 100명의 사용자 데이터를 한 번에 가져오고싶을 때 JPA를 사용해서 아래와 같이 작성했다.

@OneToOne
@JoinColumn(name = "profile_id")
private Profile profile;
List<User> users = userRepository.findAll(); // 1. 모든 사용자 조회 (1개 쿼리)
for (User user : users) {
    System.out.println(user.getProfile().getName()); // 2. 각 사용자의 프로필 접근 (N개 쿼리)
}

코드 짠 개발자의 생각 : users에 모든 사용자의 조회를 담아놨으니 아래 for문에서 user에 getProfile도 다 담겨져있겠지?

N+1 문제 코드의 쿼리 결과

모든 사용자를 조회하는 쿼리(1개 쿼리)

SELECT * FROM User;

각 사용자에 대해 프로필 정보를 조회하는 쿼리(N개 쿼리)

SELECT * FROM Profile WHERE user_id = 1;
SELECT * FROM Profile WHERE user_id = 2;
SELECT * FROM Profile WHERE user_id = 3;
...
SELECT * FROM Profile WHERE user_id = 100;

?? 어라 무려 1+100 번의 쿼리가 실행되었다. 왜그럴까?


원인

근본적인 문제는 비효율적인 데이터베이스 접근으로 인한 성능 저하이다. 발생하는 이유는 여러가지가 있다.

객체와 관계형 데이터베이스

객체 모델과 관계형 모델으로 서로 다른 모델이기 때문에 ORM의 본질적인 문제기도 하다.

1. JPQL은 기본적으로 JPQL만 가지고 SQL을 생성한다

JPQL은 객체 중심의 쿼리를 작성하도록 도와주지만, JPQL은 기본적으로 연관관계 데이터 없이 엔티티에 대한 쿼리를 처리하게된다. 이를 SQL로 변환할 때 연관 관계에 대한 추가 쿼리가 발생할 수 있다. 특히 지연 로딩 시점에서 문제가 될 수 있다. 그렇기 때문에 FetchType를 써야하는 것이다.

JPQL : JPQL은 데이터베이스 테이블이 아닌 엔티티 객체를 대상으로 쿼리를 수행하는 쿼리언어
ex) @Query("SELECT u FROM User u")

2. 지연 로딩

엔티티 로딩 원리

JPA에서 연관된 엔티티는 fetch type이 LAZY이라면 연관관계의 Entity들을 우선 프록시 객체로 채운다. 반면 EAGER인 경우는 따로 조회해서 진짜 객체로 바꾼다.

즉, JPA는 db연동횟수를 최대한 째서(?) 성능을 올리기 위해 디폴트로 LAZY한 로딩을 설정해뒀다. 하지만? 만약 아무런 설정을 하지않고 위처럼 코드를 짰다면 N+1 문제가 발생할 수 있다.

조금의 오해

단순히 FetchType.LAZY 설정 때문에 발생하는 것이 아니라, 지연 로딩과 관련된 ORM의 기본 작동 방식 및 비효율적인 데이터 접근 패턴의 결과고 이를 해결하기 위해 적절한 로딩 전략을 사용해야 한다는것이다.


해결법

0. FetchType.EAGER로 설정하기(비추천)

응~ 디폴트 바꿔서 한번에 다 불러오면 그만이야 ㅋㅋ

@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "profile_id")
private Profile profile;

하지만 데이터가 항상 함께 로드되므로 만약 다른 연관관계가 있다면 어디까지 EAGER 로딩할지 모르기 때문에 위험한 방법이라 권장되지 않는다.

그래서 다음 방법들을 사용하자..!

1. JPQL Fetch Join 사용하기

@Query("SELECT u FROM User u JOIN FETCH u.profile")
List<User> findAllWithProfile();

Fetch Join을 사용하여 연관된 모든 데이터를 한번의 JQPL 쿼리로 가져온다.

Fetch Join이랑 Join의 차이점

특징일반적인 JoinFetch Join
목적테이블 간의 데이터 결합 및 필터링연관 엔티티를 함께 로드하여 성능 최적화
사용처SQL 및 JPQL 쿼리JPQL에서만 사용
로딩 전략지연 로딩과 무관즉시 로딩 (Lazy Loading 해결)
선택적 데이터선택적 컬럼 로딩 가능모든 연관 엔티티 로드
결과 형태조인된 데이터 집합엔티티 객체의 그래프

즉? SQL에서 사용하는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능이다.

2. @EntityGraph 어노테이션 사용하기

특정 엔티티와 연관된 엔티티를 한 번에(EAGER) 가져오도록 설정할 수 있다. 또한 Fetch JOin처럼 JPQL도 쓸 수 있다.

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import java.util.List;

public interface UserRepository extends CrudRepository<User, Long> {

    // JPQL과 EntityGraph를 함께 사용하여 연관 엔티티 로딩
    @EntityGraph(attributePaths = {"profile"})
    @Query("SELECT u FROM User u WHERE u.username LIKE %:name%")
    List<User> findByUsernameLike(String name);
}

@EntityGraph(attributePaths = {"profile"})는 User 엔티티와 연관된 Profile 엔티티를 즉시 로딩하도록 설정하고
@Query("SELECT u FROM User u WHERE u.username LIKE %:name%")는 특정 이름 패턴에 맞는 User 엔티티를 조회하는 JPQL 쿼리이다.

두 방법을 결합하여 특정 조건에 맞는 사용자와 그들의 프로필을 한 번에 로드할 수 있다!

@EntityGraph vs Fetch Join

특징@EntityGraphFetch Join
유연성여러 엔티티 그래프 정의 가능특정 쿼리에서만 사용 가능
사용 편의성스프링 데이터 JPA와 함께 쉽게 사용직접 쿼리를 작성해야 함
쿼리 자동 생성 지원JPQL 없이도 연관 엔티티 로딩 가능수동으로 쿼리 작성 필요
쿼리 제어쿼리 제어 부족쿼리 세부 제어 가능
조인 방식외부 조인(LEFT OUTER JOIN) 사용내부 조인(INNER JOIN) 사용 가능
표현력제한적(복잡한 쿼리에는 제한적)다양한 JPQL 기능과 결합 가능
효율성특정 쿼리에서 즉시 로딩 가능필요한 데이터만 로드 가능
재사용성여러 리포지토리 메서드에서 사용 가능쿼리별로 사용, 재사용성 부족
코드 복잡도코드가 간결해짐쿼리가 복잡해질 수 있음
적용 범위리포지토리 메서드 전체에 적용 가능개별 JPQL 쿼리에서만 적용 가능

3. @BatchSize 사용하기(with hibernate)

Hibernate의 기능이고 지정한 만큼

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.OneToOne;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import org.hibernate.annotations.BatchSize;

@Entity
public class User {

    @Id
    private Long id;
    private String username;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "profile_id")
    @BatchSize(size = 10) // 한 번에 10개의 프로필을 로드
    private Profile profile;

    // Getters and setters
}

지연 로딩이 설정된 연관 엔티티에 대해, 일정 수(위 설정에서는 10개)만큼 일괄로 가져온다는 설정이다.

전역적으로 batch size를 쓰려면 설정파일(주로 .properties나 .xml)에서도 정의할 수 있다.
hibernate.default_batch_fetch_size=10

아까처럼 1번 + 100번 조회가 발생하는 상황을 다시 한번 써보자면?

쿼리 결과(N+1 문제 해소)

SELECT * FROM User;

전체 user 조회 1번

SELECT * FROM Profile WHERE user_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
SELECT * FROM Profile WHERE user_id IN (11, 12, 13, 14, 15, 16, 17, 18, 19, 20);
...

user를 10개씩 묶어서 조회 10번

이젠 1 + 10번의 로딩만 이루어진다!


결론

아무래도 객체모델과 RDB의 ORM은 억지로 맞춘다는 느낌도 들었다. 언젠간 RDB를 해소한 객체모델에 대칭되는 무언가가 나오지않을까 기대해본다 😅

profile
후회하지 않는 사람이 되자 🔥

0개의 댓글