N+1 발생부터 해결까지

goyo·2026년 6월 2일
post-thumbnail

간단한 토이 프로젝트에서 발생한 N+1 이슈를 파헤쳐보고 해결하는 방법에 대해서 기술해보자.


N+1 문제

JPA 처럼 ORM 기술을 통해 데이터를 조회할 때, 1번의 쿼리로 N개의 데이터를 가져온 후 연관된 데이터를 추가로 조회하기 위해 N번의 쿼리가 추가로 발생하는 성능 저하 현상

아래로 진행할 예제의 데이터베이스 모델링은 이러하다.

회원은 여러번의 예매를 할 수 있고,
예매는 회원과 공연의 정보가 필요하다.
즉 회원(1)-예매(N)-공연(1) 의 관계이다.


1. 발생


Booking

public class Booking {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String status;
    private LocalDateTime reservedAt;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "show_id")
    private Show show;

Member

public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String email;
    private String username;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
    private List<Booking> bookings = new ArrayList<>();

Show

public class Show {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String title;
    private String description;

    @OneToMany(mappedBy = "show",cascade = CascadeType.ALL)
    private List<Booking> bookings = new ArrayList<>();

BookingService

    @Transactional(readOnly = true)
    public List<BookingResponseDTO> getMyBookings(Long memberId) {
        List<Booking> bookings = bookingRepository.findAllByMemberId(memberId);

        return bookings.stream()
                .map(BookingResponseDTO::new)
                .collect(Collectors.toList());
    }

내가 예매한 모든 공연 내역을 조회한 쿼리 결과

2026-06-02T18:31:12.950+09:00 DEBUG 9364 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        b1_0.id,
        b1_0.member_id,
        b1_0.reserved_at,                  
        b1_0.show_id,
        b1_0.status 
    from
        bookings b1_0 
    left join
        member m1_0 
            on m1_0.id=b1_0.member_id 
    where
        m1_0.id=?
2026-06-02T18:31:12.951+09:00 TRACE 9364 --- [nio-8080-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
2026-06-02T18:31:12.959+09:00 DEBUG 9364 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        m1_0.id,
        m1_0.email,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.id=?
2026-06-02T18:31:12.959+09:00 TRACE 9364 --- [nio-8080-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
2026-06-02T18:31:12.964+09:00 DEBUG 9364 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        s1_0.id,
        s1_0.description,
        s1_0.title 
    from
        show s1_0 
    where
        s1_0.id=?
2026-06-02T18:31:12.964+09:00 TRACE 9364 --- [nio-8080-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]
2026-06-02T18:31:12.966+09:00 DEBUG 9364 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        s1_0.id,
        s1_0.description,
        s1_0.title 
    from
        show s1_0 
    where
        s1_0.id=?
2026-06-02T18:31:12.966+09:00 TRACE 9364 --- [nio-8080-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [2]
2026-06-02T18:31:12.967+09:00 DEBUG 9364 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        s1_0.id,
        s1_0.description,
        s1_0.title 
    from
        show s1_0 
    where
        s1_0.id=?
2026-06-02T18:31:12.967+09:00 TRACE 9364 --- [nio-8080-exec-2] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [3]

1. 먼저 리포지토리에 선언한 쿼리메서드 findAllByMemberId 가 실행되면서 회원_ID 1의 booking 내역을 조회하는 쿼리가 나간다.

2. 스트림으로 1번의 쿼리 결과를 BookingResponseDTO 로 매핑하는 과정에서 만약 N개의 예매내역이 있다면 쿼리가 N번 더 나간다.

분명 1번에서 데이터를 전부 가져왔는데 데이터 개수만큼 또 쿼리가 나가는 예외 상황이 발생한 것이다.

데이터 로딩 시점이 문제인 것일까?

1. FetchType.EAGER (즉시 로딩)

EAGER : 엔티티를 조회할 때 연관된 엔티티도 데이터베이스에서 즉시 함께 조회하는 방식
내부적으로 LEFT OUTER JOIN 을 통해 한번에 쿼리를 날렷 데이터를 가져온다.

EX : 예매을 조회하면 연관된 회원,공연도 쿼리 한번으로 같이 조회됨
주의사항: 연관된 데이터가 많아지면 수많은 Join 쿼리가 발생해 성능이 저하됨


2. FetchType.LAZY (지연 로딩)

LAZY : 연관 엔티티를 즉시 조회하지않고 실제 그 데이터가 필요한 시점에 데이터베이스에서 조회하는 방식

EX : 예매를 조회하면 회원,공연과 같은 자리에 가짜 객체인 프록시(Proxy) 객체를 넣어둔다. 이후 booking.getMember().getUsername() 처럼 실제 데이터에 접근할 때 데이터베이스에 쿼리가 나간다.



그렇다면 FetchType.LAZY 로 변경한다고 해결이 되는 것인가?

아니다.

단순히 처음 호출 시 쿼리가 N개 더 나가지않는다고 해서 해결되는 것이 아니라,
엄밀히 말하면 지연 로딩으로 인해 N+1 문제가 발생되는 시점만 바뀌게 된다.

명확한 해결 방법을 알아보자.


2. 해결방법


1. JPQL Fetch Join

쿼리 결과

2026-06-02T20:36:43.121+09:00 DEBUG 37917 --- [io-8080-exec-10] org.hibernate.SQL                        : 
    select
        b1_0.id,
        m1_0.id,
        m1_0.email,
        m1_0.username,
        b1_0.reserved_at,
        s1_0.id,
        s1_0.description,
        s1_0.title,
        b1_0.status 
    from
        booking b1_0 
    join
        member m1_0 
            on m1_0.id=b1_0.member_id 
    join
        show s1_0 
            on s1_0.id=b1_0.show_id 
    where
        m1_0.id=?
2026-06-02T20:36:43.122+09:00 TRACE 37917 --- [io-8080-exec-10] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]

결과적으로 1번의 쿼리만 나가게 된다.

1. Fetch Join

JPA 는 DB 결과셋을 바탕으로 booking 객체들을 생성할 때 member,show 컬렉션애 프록시를 가짜로 꽂아두는 것이 아니라 실제 member,show 엔티티 객체를 생성해서 채워넣는다.
이미 영속성 컨텍스트 내의 엔티티안에 실제 데이터가 들어가있기때문에 member,show 내부 메서드 호출시에도 DB 를 다시 찌르지않고 바로 꺼내서 쓰게 되므로 최초 쿼리 1회로 조회가 끝나게 된다.

Fetch Join 주의점

1. 일대다 관계 Fetch Join 시 페이징 불가
2. 둘 이상의 컬렉션은 Fetch Join 할 수 없음
3. Fetch Join 대상에게 Alias 를 주고 WHERE 절에서 필터링하면 안됨

2. EntityGraph

쿼리 결과

    select
        b1_0.id,
        m2_0.id,
        m2_0.email,
        m2_0.username,
        b1_0.reserved_at,
        s1_0.id,
        s1_0.description,
        s1_0.title,
        b1_0.status 
    from
        booking b1_0 
    left join
        member m1_0 
            on m1_0.id=b1_0.member_id 
    left join
        member m2_0 
            on m2_0.id=b1_0.member_id 
    left join
        show s1_0 
            on s1_0.id=b1_0.show_id 
    where
        m1_0.id=?
2026-06-02T21:22:44.669+09:00 TRACE 49340 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter (1:BIGINT) <- [1]

쿼리를 직접 작성하는 것이 번거로울 경우 JPQL 에서 제공하는 @EntityGraph 를 사용하는 방법도 있으나 쿼리를 통한 조건절 결합은 불가하다.


3. DTO Projection(Querydsl)

N+1 문제가 발생하는 근본적인 이유는 엔티티 객체 전체를 조회해서 영속성 컨텍스트가 연관 관계를 관리하게 만들기 때문인 것인데 DTO Projection(Querydsl) 을 사용하면 이런 문제를 종합적으로 해결할 수 있다.

소스코드

Querydsl Config

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }


}

BookingResponseDTO

@Getter
public class BookingResponseDTO {

    private Long bookingId;
    private String username;
    private String title;


    @QueryProjection
    public BookingResponseDTO(Long bookingId, String username, String title) {
        this.bookingId = bookingId;
        this.username = username;
        this.title = title;
    }

BookingRepository

public interface BookingRepository extends JpaRepository<Booking, Long>, BookingRepositoryCustom{
}

BookingRepositoryCustom

public interface BookingRepositoryCustom {
    List<BookingResponseDTO> findBookings(Long memberId);
}

BookingRepositoryCustomImpl

@RequiredArgsConstructor
public class BookingRepositoryCustomImpl implements BookingRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    @Override
    public List<BookingResponseDTO> findBookings(Long memberId) {
        return queryFactory.select(Projections.constructor(BookingResponseDTO.class,
                booking.id,
                booking.member.username,
                booking.show.title))
                .from(booking)
                .join(booking.member,member)
                .join(booking.show, show)
                .where(member.id.eq(memberId))
                .fetch();
    }
}

쿼리 결과

    select
        b1_0.id,
        m1_0.username,
        s1_0.title 
    from
        booking b1_0 
    join
        member m1_0 
            on m1_0.id=b1_0.member_id 
    join
        show s1_0 
            on s1_0.id=b1_0.show_id 
    where
        m1_0.id=?
1. 영속성 컨텍스트 관리 대상에서 제외
  • JPA로 엔티티를 조회하면 JPA 는 이 객체들을 영속성 컨텍스트라는 메모리 공간에 올리고 관리한다.
    DTO 프로젝션 조회 시 DB 에서 데이터를 꺼낼 때 엔티티 객체가 아닌, 순수 자바 데이터 보관함에 값을 꽂아넣게되는데 이렇게 DTO로 조회된 데이터는 영속성 컨텍스트가 전혀 관여하지 않는다. 애초에 JPA 의 추가 조회 대상이 아니므로 N+1 의 문제를 원천 차단할 수 있다.
2. 프록시 객체 탄생 차단
  • SELECT 대상 필드를 직접 선언해주면 프록시같은 가짜 객체가 생성될 수 없고 DB SQL 단계에서 이미 조인이 마친 실제 문자열만 DTO 생성자로 넘어가기 때문에 안전하다.
3. 주의점

DTO 가 아닌 엔티티를 주입해서 사용하는 경우는 join 문에서 추가적인 fetchJoin 을 선언해줘야한다.


결론

모든 연관관계는 기본적으로 FetchType.LAZY 를 기반으로 설계한다.

가볍고 단순한 조회의 경우 Fetch Join or @EntityGraph 를 사용하며, 로직 내에서 엔티티의 상태 변경을 해야한거나, 영속성 컨텍스트의 관리가 필요한 단순 연관 관계 조회는 JPQL의 Fetch Join 을 사용한다.

화면에 뿌려주기 위한 조회 전용 데이터이거나 데이터 양이 방대하여 최적화가 필요한 경우 Querydsl 로 필요한 컬럼만 뽑아서 DTO 로 조회한다.

0개의 댓글