
간단한 토이 프로젝트에서 발생한 N+1 이슈를 파헤쳐보고 해결하는 방법에 대해서 기술해보자.
JPA 처럼 ORM 기술을 통해 데이터를 조회할 때, 1번의 쿼리로 N개의 데이터를 가져온 후 연관된 데이터를 추가로 조회하기 위해 N번의 쿼리가 추가로 발생하는 성능 저하 현상
아래로 진행할 예제의 데이터베이스 모델링은 이러하다.
회원은 여러번의 예매를 할 수 있고,
예매는 회원과 공연의 정보가 필요하다.
즉 회원(1)-예매(N)-공연(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번에서 데이터를 전부 가져왔는데 데이터 개수만큼 또 쿼리가 나가는 예외 상황이 발생한 것이다.
데이터 로딩 시점이 문제인 것일까?
EAGER : 엔티티를 조회할 때 연관된 엔티티도 데이터베이스에서 즉시 함께 조회하는 방식
내부적으로 LEFT OUTER JOIN 을 통해 한번에 쿼리를 날렷 데이터를 가져온다.
EX : 예매을 조회하면 연관된 회원,공연도 쿼리 한번으로 같이 조회됨
주의사항: 연관된 데이터가 많아지면 수많은 Join 쿼리가 발생해 성능이 저하됨
LAZY : 연관 엔티티를 즉시 조회하지않고 실제 그 데이터가 필요한 시점에 데이터베이스에서 조회하는 방식
EX : 예매를 조회하면 회원,공연과 같은 자리에 가짜 객체인 프록시(Proxy) 객체를 넣어둔다. 이후 booking.getMember().getUsername() 처럼 실제 데이터에 접근할 때 데이터베이스에 쿼리가 나간다.
그렇다면 FetchType.LAZY 로 변경한다고 해결이 되는 것인가?
아니다.
단순히 처음 호출 시 쿼리가 N개 더 나가지않는다고 해서 해결되는 것이 아니라,
엄밀히 말하면 지연 로딩으로 인해 N+1 문제가 발생되는 시점만 바뀌게 된다.
명확한 해결 방법을 알아보자.

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

쿼리 결과
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 를 사용하는 방법도 있으나 쿼리를 통한 조건절 결합은 불가하다.
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=?
DTO 가 아닌 엔티티를 주입해서 사용하는 경우는 join 문에서 추가적인 fetchJoin 을 선언해줘야한다.
모든 연관관계는 기본적으로 FetchType.LAZY 를 기반으로 설계한다.
가볍고 단순한 조회의 경우 Fetch Join or @EntityGraph 를 사용하며, 로직 내에서 엔티티의 상태 변경을 해야한거나, 영속성 컨텍스트의 관리가 필요한 단순 연관 관계 조회는 JPQL의 Fetch Join 을 사용한다.
화면에 뿌려주기 위한 조회 전용 데이터이거나 데이터 양이 방대하여 최적화가 필요한 경우 Querydsl 로 필요한 컬럼만 뽑아서 DTO 로 조회한다.