findById가 100번의 SELECT를 발생시킬 때 — Spring Data JPA와 N+1 문제

JPA로 User 엔티티를 조회했다. 쿼리 로그를 보니 SELECT가 101번 실행됐다 — User 1번 + User의 Order 목록 100번. 이것이 N+1 문제다: 1번의 메인 쿼리 + N번의 연관 엔티티 조회. Hibernate는 지연 로딩(lazy loading)을 기본으로 하므로, 연관 컬렉션에 접근할 때마다 개별 쿼리가 실행된다.

이 글은 Spring Data JPA의 핵심 — 엔티티 매핑, 리포지토리 메서드, N+1 해결(fetch join, @EntityGraph), 그리고 Hibernate 7.x(JPA 3.2) 기반을 풀어간다. (Spring Data JPA Reference)

엔티티 매핑 — 테이블과 객체의 연결

// Spring Boot 4 / Java 25 — JPA 엔티티 (JPA 3.2 / Hibernate 7.x)
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.*;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    private Integer age;

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();

    @CreationTimestamp
    private LocalDateTime createdAt;

    protected User() {}   // JPA 요구사항 (프록시 생성용 no-arg 생성자)

    public User(String name, String email, Integer age) {
        this.name = name;
        this.email = email;
        this.age = age;
    }

    // getters, update methods...
}

mappedBy는 "이 관계의 주체가 반대편(Order)에 있다"를 의미한다. fetch = FetchType.LAZY가 기본값 — 연관 엔티티가 즉시 로드되지 않고, 실제 접근 시 쿼리가 실행된다. 이 LAZY가 N+1의 원인이다.

Spring Data JPA 리포지토리 — 구현체 없는 쿼리

// Spring Boot 4 / Java 25 — Spring Data JPA 리포지토리
import org.springframework.data.jpa.repository.*;
import org.springframework.data.domain.*;
import java.util.*;

public interface UserRepository extends JpaRepository<User, Long> {

    // 메서드 이름으로 쿼리 자동 생성
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String name);
    boolean existsByEmail(String email);

    // 정렬
    List<User> findByOrderByCreatedAtDesc();

    // 페이지네이션
    Page<User> findByAgeGreaterThan(int age, Pageable pageable);

    // @Query로 커스텀 JPQL
    @Query("SELECT u FROM User u WHERE u.age BETWEEN :min AND :max")
    List<User> findByAgeRange(@Param("min") int min, @Param("max") int max);

    // 네이티브 쿼리
    @Query(value = "SELECT * FROM users WHERE email LIKE %:domain",
           nativeQuery = true)
    List<User> findByEmailDomain(@Param("domain") String domain);
}

Spring Data JPA는 인터페이스만 정의하면 구현체를 런타임에 자동 생성한다. findByEmail 메서드 이름을 분석하여 SELECT * FROM users WHERE email = ? 쿼리를 만든다. (Spring Data JPA - Query Methods)

인터페이스만 있는데 어떻게 실행되는가? Spring Data JPA가 애플리케이션 시작 시 UserRepository 인터페이스를 발견하면 → JDK 동적 프록시로 구현체를 생성한다. 이 프록시는 SimpleJpaRepository(Spring Data JPA가 제공하는 범용 구현체)에 모든 호출을 위임한다.

비유: 리모컨의 "버튼 모양"만 있고 회로가 없다고 상상해 보자. Spring Data JPA가 시작 시 "버튼 배치도"(인터페이스)를 보고 알맞은 회로(구현체)를 자동으로 납땜해 준다. findByEmail 버튼을 누르면, 알아서 "email로 검색하는 회로"가 작동한다.

메서드 이름이 어떻게 SQL이 되는가: Spring Data JPA의 PartTree 클래스가 메서드 이름을 파싱한다. findByEmailAndAgeGreaterThan을 예로 들면:

  1. findBySELECT ... FROM (접두사 제거, "조회"로 해석)
  2. Email → 첫 번째 조건: WHERE email = ?
  3. And → 조건 연결: AND
  4. AgeGreaterThanage > ?

이 4단계를 거쳐 SELECT u FROM User u WHERE u.email = ?1 AND u.age > ?2 JPQL이 생성된다. 매개변수는 위치 기반(?1, ?2)으로 자동 매핑된다.

주의: 메서드 이름 파싱은 편리하지만 한계가 있다. 이름이 너무 길어지면 (findByUserOrdersOrderItemsProductCategoryNameContaining) 가독성이 떨어진다. 이럴 때는 @Query("JPQL")로 명시적으로 작성하는 것이 안전하다. 오타가 있어도 컴파일 에러가 아니라 런타임 에러(PropertyReferenceException)이므로 주의해야 한다.

쿼리 메서드 키워드

키워드 SQL
findBy findByName(name) WHERE name = ?
findBy...Containing findByNameContaining("Ali") WHERE name LIKE '%Ali%'
findBy...GreaterThan findByAgeGreaterThan(20) WHERE age > 20
findBy...IsNull findByEmailIsNull() WHERE email IS NULL
findBy...OrderBy...Desc findByOrderByCreatedAtDesc() ORDER BY created_at DESC
countBy countByAgeGreaterThan(20) SELECT COUNT(*) WHERE age > 20
existsBy existsByEmail(email) SELECT 1 ... LIMIT 1

N+1 문제 — 이 글의 출발점

// Spring Boot 4 / Java 25 — N+1 발생
List<User> users = userRepository.findAll();   // SELECT 1번
for (User user : users) {
    System.out.println(user.getOrders().size());   // 각 user마다 SELECT 1번!
}
// 100명의 user → 1 + 100 = 101번 쿼리

왜 이런 일이 일어나는가? 핵심은 Hibernate의 지연 로딩(lazy loading) 메커니즘에 있다.

User 엔티티의 orders 필드는 fetch = FetchType.LAZY로 선언되어 있다. 이 말은 — userRepository.findAll()이 실행될 때 orders 컬렉션을 즉시 로드하지 않고, 대신 프록시 객체(가짜 컬렉션)를 넣어둔다는 뜻이다. 이 프록시는 "데이터가 필요해지면 그때 DB에서 가져오겠다"고 약속만 해둔 상태다.

user.getOrders().size()를 호출하는 순간 — 프록시가 "아, 이제 진짜 데이터가 필요하구나"라고 판단하고, 그때서야 SELECT * FROM orders WHERE user_id = ? 쿼리를 실행한다. User가 100명이면 이 쿼리가 100번 실행된다.

비유: 도서관에서 "책 목록"만 받아왔다(1번 쿼리). 각 책의 "상세 페이지 수"를 확인하려면 — 책마다 다시 도서관에 가서 물어봐야 한다(100번 쿼리). 처음부터 목록과 상세를 함께 받아오면 한 번이면 되는데, "필요할 때만 물어보는" 지연 로딩 정책 때문에 매번 왕복이 발생한다.

flowchart TD
    Q1["1. findAll() → SELECT * FROM users<br/>(1번)"] --> LOOP["for each user"]
    LOOP --> Q2["2. getOrders() → SELECT * FROM orders WHERE user_id = ?<br/>(N번)"]
    Q2 --> LOOP
    LOOP --> END["총 N+1번 쿼리"]

해결 1: fetch join

// Spring Boot 4 / Java 25 — fetch join으로 N+1 해결
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();
// LEFT JOIN으로 orders를 한 번에 가져옴 → 쿼리 1번

해결 2: @EntityGraph

// Spring Boot 4 / Java 25 — @EntityGraph
@EntityGraph(attributePaths = {"orders"})
@Query("SELECT u FROM User u")
List<User> findAllWithOrdersGraph();
// 명시적으로 orders를 fetch → 쿼리 1번

fetch join@EntityGraph 모두 N+1을 해결한다. @EntityGraph는 어노테이션 기반이라 재사용이 쉽고, fetch join은 JPQL에 직접 작성하여 더 세밀하게 제어할 수 있다. (Spring Data JPA - EntityGraph)

해결 3: @BatchSize (배치 로딩)

// Spring Boot 4 / Java 25 — 배치 사이즈로 N+1 완화
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 50)   // 50개씩 한 번에 로드
private List<Order> orders = new ArrayList<>();
// N+1 → N/50 + 1 번 쿼리로 감소

Spring Boot 4.0 / Hibernate 7.x

항목 Spring Boot 3 (Hibernate 6) Spring Boot 4 (Hibernate 7)
JPA 버전 JPA 3.1 JPA 3.2
Hibernate 6.x 7.x
Jakarta EE EE 9+ EE 11
jakarta.persistence.* O O (동일)

Hibernate 7의 주요 변화: 성능 개선, deprecation 정리, Hibernate Validator 9.x 호환. 애플리케이션 코드 수준에서는 jakarta.persistence.*가 동일하므로 마이그레이션 부담이 적다.

실습 — N+1 vs fetch join

// Spring Boot 4 / Java 25 — NPlusOneDemo.java
// N+1 패턴 시뮬레이션
import java.util.*;

public class NPlusOneDemo {
    record Order(Long id, Long userId) {}
    record User(Long id, String name, List<Order> orders) {}

    public static void main(String[] args) {
        // 데이터 준비
        List<User> users = List.of(
            new User(1L, "Alice", List.of()),
            new User(2L, "Bob", List.of()),
            new User(3L, "Charlie", List.of())
        );
        Map<Long, List<Order>> ordersByUser = Map.of(
            1L, List.of(new Order(101L, 1L), new Order(102L, 1L)),
            2L, List.of(new Order(103L, 2L)),
            3L, List.of()
        );

        // N+1 시뮬레이션: 각 user마다 orders 별도 조회
        int queryCount = 1;   // findAll() 1번
        for (User user : users) {
            List<Order> orders = ordersByUser.get(user.id());   // 별도 쿼리
            queryCount++;
            System.out.println(user.name() + ": " + orders.size() + "개 주문");
        }
        System.out.println("총 쿼리 수: " + queryCount + " (N+1)");

        // fetch join 시뮬레이션: 한 번에 조회
        System.out.println("\nfetch join 사용 시: 쿼리 1번");
    }
}
java NPlusOneDemo.java
Alice: 2개 주문
Bob: 1개 주문
Charlie: 0개 주문
총 쿼리 수: 4 (N+1)

fetch join 사용 시: 쿼리 1번

확인할 것: N+1 패턴은 사용자 수만큼 추가 쿼리가 실행된다. fetch join으로 한 번의 쿼리로 모든 데이터를 가져온다.

Spring Data JPA Specification — 동적 쿼리

메서드 이름이나 @Query로 표현하기 어려운 동적 조건 검색Specification을 사용한다:

// Spring Boot 4 / Java 25 — Specification으로 동적 검색
public interface UserRepository extends JpaRepository<User, Long>,
        JpaSpecificationExecutor<User> { }

// 사용
public List<User> search(String name, Integer minAge, Integer maxAge) {
    return userRepository.findAll((root, query, cb) -> {
        var predicates = new ArrayList<Predicate>();
        if (name != null) {
            predicates.add(cb.like(root.get("name"), "%" + name + "%"));
        }
        if (minAge != null) {
            predicates.add(cb.greaterThanOrEqualTo(root.get("age"), minAge));
        }
        if (maxAge != null) {
            predicates.add(cb.lessThanOrEqualTo(root.get("age"), maxAge));
        }
        return cb.and(predicates.toArray(new Predicate[0]));
    });
}

Specification은 Criteria API를 래핑하여 타입 안전한 동적 쿼리를 작성할 수 있게 한다. 검색 조건이 런타임에 결정되는(사용자 입력에 따라) 경우에 적합하다.

요약 — 이 글의 결론

  • Spring Data JPA는 리포지토리 인터페이스만 정의하면 구현체를 자동 생성한다. findByEmail, findByNameContaining 등 메서드 이름으로 쿼리를 추론한다.
  • N+1은 지연 로딩(LAZY fetch)의 부작용이다. 연관 컬렉션에 접근할 때마다 개별 쿼리가 실행된다.
  • fetch join 또는 @EntityGraph로 N+1을 해결한다. 한 번의 JOIN 쿼리로 모든 데이터를 가져온다.
  • @BatchSize로 배치 로딩을 설정할 수 있다. N+1을 N/ batchSize + 1로 완화한다.
  • Spring Boot 4.0은 Hibernate 7.x / JPA 3.2 기반이다. jakarta.persistence.*는 Spring Boot 3과 동일하다.

생각해 볼 문제

  1. fetch = FetchType.EAGER를 쓰면 N+1이 발생하지 않는가? 어떤 단점이 있는가?
  2. @Query의 JPQL과 nativeQuery = true의 차이는? 각각 언제 써야 하는가?
  3. Spring Data JPA의 save()merge()를 호출하는 경우는 언제인가? (id가 있는 엔티티)
  4. @Transactional(readOnly = true)가 Hibernate flush 모드를 어떻게 변경하는가?
  5. Hibernate 7에서 deprecation된 기능은 무엇인가? 마이그레이션에 미치는 영향은?

참고

Spring Data JPA 페이징과 정렬

// Spring Boot 4 / Java 25 — 페이징
Page<User> page = userRepository.findAll(PageRequest.of(0, 20, Sort.by("createdAt").descending()));

System.out.println("현재 페이지: " + page.getNumber());
System.out.println("전체 페이지: " + page.getTotalPages());
System.out.println("전체 항목 수: " + page.getTotalElements());
System.out.println("내용: " + page.getContent().size() + "건");

Page<T>는 페이지네이션 메타데이터(총 항목 수, 총 페이지 수, 현재 페이지)를 포함한다. Slice<T>는 다음 페이지 존재 여부만 알고 총 카운트를 계산하지 않아 — 대용량 데이터에서 COUNT(*) 쿼리 비용을 피할 수 있다.

커스텀 리포지토리 — 복잡한 쿼리 캡슐화

// Spring Boot 4 / Java 25 — 커스텀 리포지토리 구현
public interface CustomUserRepository {
    List<UserStatistics> getUserStatistics();
}

@Repository
class CustomUserRepositoryImpl implements CustomUserRepository {
    private final EntityManager em;

    public List<UserStatistics> getUserStatistics() {
        return em.createQuery(
            "SELECT new com.example.UserStatistics(u.name, COUNT(o)) " +
            "FROM User u LEFT JOIN u.orders o GROUP BY u.name", UserStatistics.class)
            .getResultList();
    }
}

// 인터페이스 결합
public interface UserRepository
        extends JpaRepository<User, Long>, CustomUserRepository { }
// userRepository.getUserStatistics() 호출 가능

Spring Data JPA는 인터페이스만으로 쿼리를 생성하지만, 복잡한 집계/조인은 커스텀 구현체(~ Impl)로 작성해야 한다. UserRepositoryCustomUserRepository를 상속하면 자동으로 결합된다.

'Develop Artifacts > Spring Boot' 카테고리의 다른 글

SpringBoot - 07. security  (0) 2026.07.12
SpringBoot - 06. testing  (0) 2026.07.12
SpringBoot - 4. rest api  (0) 2026.07.12
SpringBoot - 03. embedded-server  (0) 2026.07.12
SpringBoot - 02. starter-config  (0) 2026.07.12