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을 예로 들면:
findBy→SELECT ... FROM(접두사 제거, "조회"로 해석)Email→ 첫 번째 조건:WHERE email = ?And→ 조건 연결:ANDAgeGreaterThan→age > ?
이 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과 동일하다.
생각해 볼 문제
fetch = FetchType.EAGER를 쓰면 N+1이 발생하지 않는가? 어떤 단점이 있는가?@Query의 JPQL과nativeQuery = true의 차이는? 각각 언제 써야 하는가?- Spring Data JPA의
save()가merge()를 호출하는 경우는 언제인가? (id가 있는 엔티티) @Transactional(readOnly = true)가 Hibernate flush 모드를 어떻게 변경하는가?- Hibernate 7에서 deprecation된 기능은 무엇인가? 마이그레이션에 미치는 영향은?
참고
- Spring Data JPA Reference - 접근 2026-07-10
- Spring Data JPA - Query Methods - 접근 2026-07-10
- Spring Boot 4.0 - JPA - 접근 2026-07-10
- Hibernate ORM 7.x Docs - 접근 2026-07-10
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)로 작성해야 한다.UserRepository가CustomUserRepository를 상속하면 자동으로 결합된다.
'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 |
