회원 목록을 한 번 조회했는데 SQL 로그에 SELECT가 101줄 찍힌다. 이것이 N+1 문제다. 연관 엔티티를 가져오는 첫 쿼리 1개에, 그 연관을 건드릴 때마다 추가로 N개가 더 나가 총 N+1개가 된다. 추정 밀리초가 아니라 로그에 찍힌 쿼리 개수가 이 문제의 신호다.

1개가 101개가 되는 과정

MemberTeam을 지연 로딩(FetchType.LAZY)으로 참조한다고 하자. 회원 100명을 조회하고 반복문에서 각 회원의 팀 이름을 출력한다.

List<Member> members = memberRepository.findAll(); // 쿼리 1개

for (Member member : members) {
    System.out.println(member.getTeam().getName()); // 여기서 팀마다 쿼리 1개
}

첫 줄에서 회원을 한 번에 가져온다.

select m.id, m.name, m.team_id from member m;

문제는 반복문이다. member.getTeam()은 지연 로딩이라 팀 이름에 접근하는 순간 DB를 친다. 회원마다 한 번씩, 같은 모양의 SELECT가 나간다.

select t.id, t.name from team t where t.id = 1;
select t.id, t.name from team t where t.id = 2;
select t.id, t.name from team t where t.id = 3;
-- ... 회원 수만큼 반복
select t.id, t.name from team t where t.id = 100;

회원 조회 1개 + 팀 조회 100개 = 101개다. 데이터가 늘면 비례해서 늘어난다. 1:N(팀→회원), N:M(학생↔수업), 다단계 연관(부서→팀→회원)에서도 같은 메커니즘으로 터지고, 다단계일수록 곱셈으로 불어난다. 로그의 SELECT 줄 수로 확인된다.

위험한 이유

쿼리 하나하나는 빨라도 쿼리 개수 자체가 비용이다. 각 SELECT마다 커넥션을 잡고 네트워크 왕복(RTT)이 더해진다. 동시 사용자가 많으면 커넥션 풀이 금세 바닥나 다른 요청이 대기하거나 타임아웃으로 이어진다. N+1은 느린 쿼리가 아니라 쿼리가 너무 많은 문제이고, 해결 방향은 쿼리 개수를 줄이는 것이다.

Eager Loading으로 막으려 하면 연관을 항상 로딩해 오히려 다른 곳에서 N+1을 만든다. 전역 EAGER 대신 필요한 조회에서만 선택적으로 해결하는 게 원칙이다.

Fetch Join

가장 직접적인 해법이다. JPQL의 JOIN FETCH는 연관 엔티티를 SQL 조인으로 함께 즉시 로딩한다. 일반 JOIN은 연관을 조건절에만 쓰고 로딩하지 않지만, JOIN FETCH는 영속성 컨텍스트에 채워 넣어 추가 쿼리가 없게 한다.

@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();

101개가 1개로 줄어든다.

select m.id, m.name, t.id, t.name
from member m
inner join team t on t.id = m.team_id;

제약이 있다. 컬렉션을 Fetch Join하면 카테시안 곱으로 행이 중복될 수 있어 DISTINCT가 필요하다. 페이징과 함께 쓰면 DB가 아니라 메모리에서 페이징되며(HHH000104 경고와 함께) 위험하다. 둘 이상의 컬렉션을 동시에 Fetch Join하면 MultipleBagFetchException이 난다. 보통 컬렉션은 하나만 Fetch Join하고 나머지는 다음 방법으로 넘긴다.

@BatchSize

@BatchSize는 연관을 개별 조회하는 대신 ID를 모아 IN 절로 한 번에 가져온다. 팀 100개를 BatchSize 100으로 두면 팀 조회가 100개가 아니라 1개가 된다.

@Entity
public class Member {
    @ManyToOne(fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    private Team team;
}

로그는 이렇게 바뀐다. 회원 1개 + 팀 1개(IN 절) = 2개.

select m.id, m.name, m.team_id from member m;
select t.id, t.name from team t where t.id in (1, 2, 3, ..., 100);

개별 필드가 아니라 전역으로 적용하려면 hibernate.default_batch_fetch_size를 설정한다. 배치 크기는 보통 50~100을 쓰며, IN 절 길이 제한과 메모리를 함께 고려한다. 페이징과 함께 써도 안전해 컬렉션 N+1의 현실적인 기본 해법이다.

@EntityGraph

@EntityGraph는 특정 조회에서 어떤 연관을 함께 로딩할지 선언한다. 내부적으로 Fetch Join과 비슷하게 조인 쿼리가 나간다.

@EntityGraph(attributePaths = {"team"})
@Query("SELECT m FROM Member m")
List<Member> findAllWithTeam();

attributePaths에 지정한 속성만 EAGER로 당겨온다. 타입은 둘이다. FETCH는 지정한 속성만 EAGER, 나머지는 LAZY로 강제한다. LOAD는 지정한 속성은 EAGER로 하되 나머지는 엔티티에 선언된 기본 전략을 따른다.

DTO 직접 조회

화면에 필요한 값이 정해져 있으면 엔티티 그래프를 로딩할 이유가 없다. JPQL new로 필요한 컬럼만 SELECT해 DTO에 담으면 연관 로딩 문제가 처음부터 발생하지 않는다.

@Query("SELECT new com.example.dto.MemberDto(m.id, m.name, t.name) " +
       "FROM Member m JOIN m.team t")
List<MemberDto> findAllMemberDto();

조인 한 번으로 끝나고 불필요한 컬럼과 메모리도 아낀다. 조회 전용 API에 적합하다.

QueryDSL은 fetchJoin()으로 동적 쿼리에서 Fetch Join을 적용할 수 있고, 복잡한 조인이 필요하면 Native Query로 직접 SQL을 쓴다. 조회 전용 메서드에 @Transactional(readOnly = true)를 붙이면 변경 감지용 스냅샷을 만들지 않아 메모리가 줄고 읽기 최적화가 적용될 수 있다.

먼저 보이게 만들기

N+1은 코드만 읽어서 찾기 어렵다. 지연 로딩이 조용히 동작하기 때문이다. 로그를 켜는 것이 첫걸음이다.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

이렇게 두고 기능 하나를 호출했을 때 같은 모양의 SELECT가 반복해서 찍히면 N+1이다. 더 정밀하게는 통계를 켠다.

spring.jpa.properties.hibernate.generate_statistics=true

Hibernate Statistics는 실행된 쿼리 수를 직접 보여줘서, 수정 전후로 쿼리 개수가 101 → 2로 줄었는지 숫자로 확인할 수 있다. 운영에서는 p6spy로 바인딩 파라미터까지 채워진 실제 SQL과 실행 시간을 보거나, APM/DB 프로파일러로 같은 쿼리가 반복되는 구간을 잡는다.