목록 API가 갑자기 느려졌다 — N+1 쿼리 추적기
주문 목록 API의 응답 시간이 어느 날부터 눈에 띄게 늘었다. 코드는 그대로인데 데이터가 쌓이면서 드러난 전형적인 N+1 문제였다. 원인을 추적하고 해결한 과정을 그대로 남긴다.
증상: 갑자기 느려진 목록
주문 목록 화면이 처음엔 빨랐는데, 운영 데이터가 늘면서 응답이 수백 ms까지 느려졌다. 코드 변경이 없었기에 더 당황스러웠다. 목록의 각 주문은 회원(member) 정보를 함께 보여주는 구조였다.
첫 단서
데이터가 적을 땐 멀쩡하다가 늘어날수록 느려진다 → 응답 시간이 데이터 건수에 비례한다는 뜻이다. 쿼리가 건수만큼 늘어나는 패턴을 의심했다.
추적: 쿼리 로그 켜기
추측 대신 실제로 어떤 쿼리가 나가는지 봐야 했다. JPA의 SQL 로그와 실행 횟수를 켜서 확인했다.
spring:
jpa:
show-sql: true
properties:
hibernate.format_sql: true
# 쿼리 카운트를 보려면 datasource-proxy / p6spy 같은 도구가 유용로그를 보니 주문 목록 1건을 조회하는 쿼리 뒤에, 각 주문의 회원을 조회하는 쿼리가 줄줄이 따라붙고 있었다. 주문이 50건이면 쿼리가 51번 나갔다.
select * from orders; -- 1번: 주문 50건 조회
select * from member where id = 1; -- +1
select * from member where id = 2; -- +1
... -- 주문 수만큼 반복
select * from member where id = 50; -- +1 → 총 51번원인: 지연 로딩과 N+1
원인은 명확했다. @ManyToOne 연관관계가 지연 로딩(LAZY)이라, 목록을 돌면서 각 주문의 member에 접근하는 순간마다 추가 쿼리가 나간 것이다. 목록(N건) 조회 1번 + 각 건의 연관 조회 N번 = N+1번의 쿼리.
@Entity
class Order {
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
// 목록을 돌며 member.getName() 접근 → 건마다 쿼리 발생
orders.forEach(o -> log.info(o.getMember().getName()));LAZY가 잘못은 아니다
지연 로딩 자체는 권장 설정이다. 문제는 ‘목록에서 연관 데이터를 전부 쓰면서도 한 번에 가져오지 않은’ 접근 방식이었다. EAGER로 바꾸는 건 다른 곳에서 더 큰 문제를 부른다.
해결: fetch join
목록 조회 시점에 회원을 함께 가져오도록 fetch join을 명시했다. 한 번의 조인 쿼리로 모든 데이터를 끌어오면 추가 쿼리가 사라진다.
@Query("select o from Order o join fetch o.member")
List<Order> findAllWithMember();
// 단 한 번의 조인 쿼리로 주문 + 회원을 함께 로딩@EntityGraph 대안
JPQL을 직접 쓰기 싫다면 @EntityGraph(attributePaths = "member")로도 같은 효과를 낼 수 있다.
컬렉션은 주의
일대다(컬렉션) fetch join은 페이징과 함께 쓰면 메모리에서 페이징하는 문제가 있다. 이 경우 @BatchSize나 default_batch_fetch_size로 IN 쿼리 묶음 전략을 쓴다.
검증과 교훈
수정 후 다시 로그를 확인하니 쿼리가 51번에서 1번으로 줄었고, 응답 시간도 데이터 건수와 무관하게 일정해졌다.
남은 교훈
- 의심되면 로그부터: 성능 문제는 추측하지 말고 실제 쿼리 수를 먼저 센다. 보이지 않으면 고칠 수 없다.
- 응답 시간이 데이터에 비례하면 N+1을 의심: 가장 흔한 ORM 함정이다.
- EAGER는 답이 아니다: 전역 EAGER는 의도치 않은 곳에서 또 다른 N+1과 과다 조회를 만든다. 필요한 곳에서만 fetch join으로 명시한다.
- 테스트로 고정: 쿼리 수를 검증하는 테스트를 두면 회귀를 막을 수 있다.
이 주제의 이론적 배경은 Spring Boot · JPA 데이터 접근 글에서 더 다룬다.
N+1은 코드가 틀려서가 아니라, 데이터가 쌓이면서 드러난다.
성능은 추측이 아니라 측정으로 잡는다 — 쿼리 로그가 가장 정직한 단서다.