Spring Data/JPA

JPA N+1 문제, LAZY로 바꿨는데 왜 쿼리는 여전히 많을까?

Hcode 2026. 10. 8.
반응형

주문 목록을 조회한 뒤 고객 이름을 붙였더니 SQL 로그가 갑자기 길어집니다. 연관관계를 LAZY로 바꿨는데도 주문 수만큼 고객 조회가 따라옵니다. 설정이 무시된 걸까요?

LAZY는 필요한 순간까지 연관 데이터 조회를 미루는 설정입니다. 목록을 만든 뒤 각 고객의 이름을 읽으면, 미뤄 두었던 조회가 그때 일어날 수 있습니다. 이 글은 같은 주문 데이터에서 “고객 이름을 읽었는가”와 “처음부터 함께 가져왔는가”만 바꿔 SQL 수를 비교합니다.

코미가 주문표 세 장을 들고 고객 카드가 든 서랍을 열어 대조한다.
주문만 조회한 뒤 고객 이름을 읽으면 추가 조회가 생길 수 있습니다.

주문 세 건과 서로 다른 고객 세 명으로 시작합니다

주문 Purchase이 고객 Customer을 참조하는 단방향 다대일 관계입니다. 고객이 없는 주문은 만들지 않았습니다. 고객 이름을 읽는 동작이 조회를 일으키는지 보려고 관계를 명시적으로 LAZY로 설정했습니다.

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
Customer customer;

실행 환경은 JDK 25.0.3, Hibernate 7.4.12.Final, H2 2.4.240입니다. 테스트는 Hibernate를 직접 구동하며 Spring Data Repository를 사용하지 않습니다. Boot 4.1.1은 의존성 관리에만 사용하고 Hibernate 버전을 명시했습니다.

각 테스트마다 DB와 영속성 컨텍스트를 새로 만들었습니다. 데이터 입력이 끝난 뒤 SQL 수를 초기화했고, 2차 캐시는 끄고 batch fetch 크기는 0으로 두었습니다. 이미 읽은 데이터나 일괄 조회 설정이 SQL 수에 영향을 주지 않도록 한 실험입니다.

고객을 읽지 않으면 1번, 이름까지 읽으면 4번

List<Purchase> purchases = session.createQuery(
    "select p from Purchase p where p.id <= 3 order by p.id",
    Purchase.class
).getResultList();

주문 목록만 얻고 고객에 접근하지 않은 경우에는 SQL이 한 번 실행됐습니다. 같은 조회 뒤 아래 코드를 실행하면 고객 이름을 읽기 위해 추가 조회가 발생했습니다.

List<String> names = purchases.stream()
    .map(p -> p.getCustomer().getName())
    .toList();
같은 데이터에서 바뀐 SQL 실행 수
조회·접근 방식 SQL 수 읽힌 내용
주문만 조회 1 주문 3건
서로 다른 고객 이름까지 접근 4 주문 1회 + 고객 3회
고객 이름을 두 번씩 접근 4 같은 컨텍스트에서 재사용
고객을 fetch join으로 조회 1 주문과 고객 함께
주문 3건이 같은 고객을 참조 2 주문 1회 + 고객 1회

여기서 N+1은 목록 조회 한 번 뒤 연관 데이터 조회가 반복되는 패턴을 말합니다. 이 조건에서는 주문 3건과 서로 다른 고객 3명으로 1+3이 됐습니다. “LAZY인데 왜 조회하나”보다 목록을 읽은 뒤 어떤 연관 필드까지 사용했나를 살피면 원인을 찾기 쉽습니다.

모든 경우가 정확히 주문 수 + 1은 아닙니다

같은 고객 이름을 한 번 더 읽는다고 SQL이 다시 3개 늘어나지는 않았습니다. 동일한 영속성 컨텍스트에서 이미 로딩한 고객을 재사용했기 때문입니다. 세 주문이 한 고객을 공유하는 별도 데이터에서는 전체 SQL이 두 번이었습니다.

따라서 테스트 데이터의 관계, 영속성 컨텍스트의 수명, 캐시와 batch fetch 설정이 바뀌면 관찰값도 달라질 수 있습니다. 주문 건수만 보고 쿼리 수를 단정하거나, 작은 데이터에서 쿼리가 적게 나왔다고 문제가 없다고 보기도 어렵습니다.

이 목록에 필요한 고객은 조회할 때 함께 가져옵니다

List<Purchase> purchases = session.createQuery(
    "select p from Purchase p join fetch p.customer " +
    "where p.id <= 3 order by p.id",
    Purchase.class
).getResultList();

이 쿼리에 join fetch p.customer를 넣자 주문과 고객을 함께 가져왔고, 이어서 이름을 읽어도 SQL은 한 번이었습니다. 실제 반환된 이름 세 개도 검사해 단순히 쿼리 수만 줄이고 필요한 데이터가 빠진 경우를 걸러 냈습니다.

이 예제의 join fetch는 필수 고객 한 명을 참조하는 to-one 관계를 대상으로 합니다. 고객이 선택 사항인 모델이라면 inner join으로 주문이 빠질 수 있는지부터 확인해야 합니다. 여러 컬렉션을 동시에 가져오거나 페이지 크기를 제한하는 조회는 데이터 중복과 조회 범위가 달라지는 별도 문제이므로 이 결과를 그대로 적용하지 않습니다.

관계 전체를 무조건 EAGER로 바꾸기보다, 해당 화면이나 작업이 실제로 쓰는 데이터를 정하고 쿼리별로 가져오는 범위를 선택하세요. Hibernate 공식 문서는 join fetch와 EntityGraph 등으로 필요한 관계를 명시하는 방식을 설명합니다. 모든 상황의 정답을 한 가지 설정으로 고정할 수는 없습니다.

SQL 수와 실제 결과를 함께 검증합니다

assertEquals(List.of("customer-1", "customer-2", "customer-3"),
    purchases.stream().map(p -> p.getCustomer().getName()).toList());

long count = factory.getStatistics().getPrepareStatementCount();
assertEquals(1, count);

통계는 데이터 입력 뒤 초기화했으며, 테스트에서는 SQL을 StatementInspector로 함께 수집했습니다. 아래는 다섯 테스트의 관찰값입니다.

lazy-not-accessed: statements=1
lazy-three-customers: statements=4
repeat-access: statements=4
fetch-join: statements=1
shared-customer: statements=2

쿼리 수가 줄었다는 사실만으로 응답 시간이 몇 배 빨라졌다고 말할 수는 없습니다. 운영에서는 반환 행 수, 네트워크 비용, DB 실행 계획과 전체 처리 시간도 봐야 합니다. 연결을 빌리는 단계부터 막힌 경우라면 HikariCP 연결 시간 초과를 구분하는 글을 함께 보면 쿼리 조회와 연결 풀 대기를 나눠 볼 수 있습니다.

직접 실행할 전체 예제

새 프로젝트에 아래 표시된 경로대로 두 파일을 저장하고 mvn test를 실행하면 됩니다. 실제 확인 환경은 JDK 25.0.3과 Maven 3.9.9입니다. 테스트는 인메모리 H2 DB에서 실행됩니다.

LAZY와 fetch join 5개 테스트: pom.xml과 전체 테스트 펼치기

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent>
  <groupId>blog.example</groupId><artifactId>jpa-nplusone</artifactId><version>1.0.0</version>
  <properties><java.version>17</java.version><hibernate.version>7.4.12.Final</hibernate.version></properties>
  <dependencies>
    <dependency><groupId>org.hibernate.orm</groupId><artifactId>hibernate-core</artifactId></dependency>
    <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency>
  </dependencies>
</project>

src/test/java/blog/jpa/FetchTest.java

package blog.jpa;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
import org.hibernate.resource.jdbc.spi.StatementInspector;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class FetchTest {
    SessionFactory factory;
    final List<String> statements = new ArrayList<>();

    @BeforeEach void setup() {
        factory = new Configuration()
            .addAnnotatedClass(Customer.class).addAnnotatedClass(Purchase.class)
            .setProperty("hibernate.connection.url", "jdbc:h2:mem:" + UUID.randomUUID())
            .setProperty("hibernate.connection.driver_class", "org.h2.Driver")
            .setProperty("hibernate.hbm2ddl.auto", "create-drop")
            .setProperty("hibernate.generate_statistics", "true")
            .setProperty("hibernate.cache.use_second_level_cache", "false")
            .setProperty("hibernate.default_batch_fetch_size", "0")
            .setStatementInspector((StatementInspector) sql -> { statements.add(sql); return sql; })
            .buildSessionFactory();
        factory.inTransaction(session -> {
            for (long i = 1; i <= 3; i++) {
                var customer = new Customer(i, "customer-" + i);
                session.persist(customer);
                session.persist(new Purchase(i, customer));
            }
            var shared = new Customer(10L, "shared");
            session.persist(shared);
            for (long i = 11; i <= 13; i++) session.persist(new Purchase(i, shared));
        });
        statements.clear();
        factory.getStatistics().clear();
    }

    @AfterEach void close() { if (factory != null) factory.close(); }

    void check(String name, long expected) {
        long count = factory.getStatistics().getPrepareStatementCount();
        assertEquals(expected, count);
        System.out.printf("RESULT %s: statements=%d%n", name, count);
        statements.forEach(sql -> System.out.println("SQL " + sql));
    }

    @Test void withoutAccessingAssociationOnlyOneSelect() {
        factory.inTransaction(session -> {
            List<Purchase> purchases = session.createQuery(
                "select p from Purchase p where p.id <= 3 order by p.id", Purchase.class).getResultList();
            assertEquals(3, purchases.size());
            assertEquals(1L, purchases.get(0).id);
        });
        check("lazy-not-accessed", 1);
    }

    @Test void threeDifferentCustomersProduceFourSelects() {
        factory.inTransaction(session -> {
            List<Purchase> purchases = session.createQuery(
                "select p from Purchase p where p.id <= 3 order by p.id", Purchase.class).getResultList();
            assertEquals(List.of("customer-1", "customer-2", "customer-3"),
                purchases.stream().map(p -> p.getCustomer().getName()).toList());
        });
        check("lazy-three-customers", 4);
    }

    @Test void fetchJoinLoadsCustomersWithOneSelect() {
        factory.inTransaction(session -> {
            List<Purchase> purchases = session.createQuery(
                "select p from Purchase p join fetch p.customer where p.id <= 3 order by p.id", Purchase.class)
                .getResultList();
            assertEquals(List.of("customer-1", "customer-2", "customer-3"),
                purchases.stream().map(p -> p.getCustomer().getName()).toList());
        });
        check("fetch-join", 1);
    }

    @Test void repeatedAccessUsesSamePersistenceContext() {
        factory.inTransaction(session -> {
            var purchases = session.createQuery(
                "select p from Purchase p where p.id <= 3 order by p.id", Purchase.class).getResultList();
            purchases.forEach(p -> { assertNotNull(p.getCustomer().getName()); assertNotNull(p.getCustomer().getName()); });
        });
        check("repeat-access", 4);
    }

    @Test void sharedCustomerReducesExtraSelects() {
        factory.inTransaction(session -> {
            var purchases = session.createQuery(
                "select p from Purchase p where p.id >= 11 order by p.id", Purchase.class).getResultList();
            assertEquals(3, purchases.size());
            purchases.forEach(p -> assertEquals("shared", p.getCustomer().getName()));
        });
        check("shared-customer", 2);
    }

    @Entity(name = "Customer")
    @Table(name = "customer")
    public static class Customer {
        @Id Long id;
        String name;
        protected Customer() { }
        Customer(Long id, String name) { this.id = id; this.name = name; }
        public String getName() { return name; }
    }

    @Entity(name = "Purchase")
    @Table(name = "purchase_order")
    public static class Purchase {
        @Id Long id;
        @ManyToOne(fetch = FetchType.LAZY, optional = false)
        @JoinColumn(name = "customer_id")
        Customer customer;
        protected Purchase() { }
        Purchase(Long id, Customer customer) { this.id = id; this.customer = customer; }
        public Customer getCustomer() { return customer; }
    }
}

목록을 쓰는 코드까지 따라가 보세요

조회 메서드에서 끝내지 말고, DTO를 만들거나 응답을 직렬화하는 동안 어떤 연관 데이터를 읽는지 확인해 보세요. 필요한 관계를 명시한 뒤 SQL 수와 반환값을 다시 비교하면, 불필요한 조회가 줄었는지 확인할 수 있습니다.

참고한 공식 자료

공식 문서·실험 확인일: 2026년 10월 8일. 문서 표시 버전과 실행 버전은 Hibernate 7.4.12.Final입니다.

반응형

댓글