Spring Boot 데이터 접근 기술(1/6)
Kotlin/Spring

JPA 심화 - N+1, 지연 로딩, Dirty Checking

JPA를 쓰다 마주치는 핵심 개념들. N+1 문제, 지연/즉시 로딩, dirty checking, 1차 캐시를 정리한다.

2026-03-27
13 min read
#JPA#Hibernate#N+1#Spring Boot#Kotlin

JPA와 Hibernate의 관계

JPA(Java Persistence API)는 스펙이다. "ORM이 이런 기능을 제공해야 한다"는 인터페이스 모음이다.

Hibernate는 그 스펙을 구현한 구현체다. Spring Boot에서 JPA를 쓰면 실제로는 Hibernate가 동작한다.

[ JPA 스펙 ]
    │  EntityManager, @Entity, @OneToMany 등 인터페이스 정의
    ▼
[ Hibernate ]
    │  실제 SQL 생성, 캐시 관리, 변경 감지 등 구현
    ▼
[ JDBC ]
    │  DB와 통신
    ▼
[ Database ]

개발자가 @Entity, @OneToMany를 쓰면 JPA 스펙이고, 실제로 SQL을 만들고 날리는 건 Hibernate다.

왜 Hibernate를 알아야 하는가?

JPA 스펙만 알아도 개발은 가능하다. 그런데 N+1 문제, 예상치 못한 UPDATE, 성능 이슈가 생기면 Hibernate가 내부에서 어떻게 동작하는지 알아야 원인을 찾을 수 있다.


영속성 컨텍스트 (Persistence Context)

JPA의 모든 동작은 영속성 컨텍스트를 중심으로 돌아간다.

영속성 컨텍스트는 엔티티를 관리하는 1차 저장소다. DB가 아닌 메모리에 존재한다.

[ 애플리케이션 ]
       │
       ▼
[ 영속성 컨텍스트 ]  ← Hibernate가 관리하는 메모리 공간
   │  ├── 엔티티 저장 (1차 캐시)
   │  ├── 변경 감지 (Dirty Checking)
   │  └── 쓰기 지연 (Write-Behind)
       │
       ▼
  [ Database ]

EntityManager가 영속성 컨텍스트를 다루는 인터페이스다. Spring에서는 트랜잭션 범위와 영속성 컨텍스트 범위가 보통 일치한다.

엔티티 상태

엔티티는 4가지 상태를 가진다.

val user = User(name = "홍길동")  // ① 비영속 (transient) - JPA 모름

entityManager.persist(user)      // ② 영속 (managed) - JPA가 관리

entityManager.detach(user)       // ③ 준영속 (detached) - JPA가 더 이상 관리 안 함

entityManager.remove(user)       // ④ 삭제 (removed) - 삭제 예정

영속 상태 엔티티만 변경 감지, 1차 캐시, 쓰기 지연의 대상이 된다.


1차 캐시

영속성 컨텍스트 안에 존재하는 캐시다. 같은 트랜잭션 안에서 같은 id로 조회하면 DB 쿼리 없이 캐시에서 반환한다.

@Transactional
fun example() {
    val user1 = userRepository.findById(1L).get()  // SELECT 쿼리 발생
    val user2 = userRepository.findById(1L).get()  // 캐시에서 반환 (쿼리 없음)

    println(user1 === user2)  // true - 완전히 같은 객체
}

스냅샷

엔티티를 영속성 컨텍스트에 저장할 때, Hibernate는 스냅샷도 함께 저장한다. 처음 조회했을 때 상태의 복사본이다.

영속성 컨텍스트
├── 엔티티: User(id=1, name="홍길동")  ← 현재 상태
└── 스냅샷: User(id=1, name="홍길동")  ← 처음 조회한 상태

나중에 Dirty Checking에서 이 스냅샷을 사용한다.

1차 캐시의 범위

1차 캐시는 트랜잭션 범위다. 트랜잭션이 끝나면 영속성 컨텍스트가 닫히고 캐시도 사라진다. 서로 다른 트랜잭션은 캐시를 공유하지 않는다.

Redis 같은 2차 캐시(애플리케이션 레벨)와 다르다. 1차 캐시는 짧은 수명의 임시 캐시다.


Dirty Checking (변경 감지)

save()를 호출하지 않아도 트랜잭션 안에서 영속 상태 엔티티가 변경되면 자동으로 UPDATE가 나간다.

@Transactional
fun updateUserName(id: Long, newName: String) {
    val user = userRepository.findById(id).orElseThrow()
    user.name = newName
    // save() 호출 없음!
}
// 트랜잭션 종료 시 → UPDATE users SET name = ? WHERE id = ?

동작 원리

트랜잭션 시작
    │
    ▼
findById() 실행
    │  → 엔티티를 1차 캐시에 저장
    │  → 스냅샷도 함께 저장
    ▼
user.name = newName  ← 엔티티 변경 (스냅샷은 그대로)
    │
    ▼
트랜잭션 커밋 직전
    │  → flush() 자동 호출
    │  → 엔티티와 스냅샷 비교
    │  → 다른 필드 있으면 UPDATE SQL 생성
    ▼
커밋

flush()는 영속성 컨텍스트의 변경 내용을 DB에 반영하는 작업이다. 커밋 시 자동으로 호출된다.

Hibernate의 UPDATE 방식

Hibernate는 기본적으로 변경된 필드만이 아닌 전체 필드를 UPDATE한다.

// name만 변경했지만
user.name = "홍길순"

// 실행되는 SQL
// UPDATE users SET name = ?, email = ?, age = ?, created_at = ? WHERE id = ?
// 모든 컬럼이 포함됨

이유는 SQL을 미리 캐싱해두기 위해서다. 어떤 필드가 바뀌든 같은 UPDATE 구문을 재사용한다.

변경된 필드만 UPDATE하려면 @DynamicUpdate 를 붙이면 된다.

@Entity
@DynamicUpdate  // 변경된 필드만 UPDATE
class User(...)

조회 전용 트랜잭션

단순 조회라면 @Transactional(readOnly = true)를 붙인다.

@Transactional(readOnly = true)
fun getUsers(): List<User> {
    return userRepository.findAll()
    // Dirty Checking을 건너뜀 → 성능 향상
    // 스냅샷 저장도 생략 → 메모리 절약
}

readOnly 트랜잭션에서는 스냅샷을 저장하지 않고, flush도 하지 않는다. 조회만 하는데 변경 감지가 동작할 필요가 없으니 불필요한 작업을 생략한다.


지연 로딩 vs 즉시 로딩

연관 데이터를 언제 조회할지 결정한다.

// 지연 로딩 (LAZY) - 실제로 접근할 때 쿼리
@OneToMany(fetch = FetchType.LAZY)
val orders: List<Order>

// 즉시 로딩 (EAGER) - 항상 같이 조회
@ManyToOne(fetch = FetchType.EAGER)
val user: User

기본값

관계기본값
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

@ManyToOne이 기본값 EAGER인 이유는 단건 참조라서 항상 같이 조회해도 큰 비용이 없다고 설계된 것이다. 그러나 실무에서는 이것도 LAZY로 바꾸는 경우가 많다.

프록시 객체

LAZY 로딩은 프록시 객체로 동작한다. Hibernate가 실제 클래스를 상속한 가짜 객체를 생성한다.

val user = userRepository.findById(1L).get()
// user.orders는 아직 DB 조회 안 함
// Hibernate가 만든 프록시 객체 (List를 감싼 가짜 컬렉션)

user.orders.size  // 이 시점에 SELECT * FROM orders WHERE user_id = 1

프록시는 실제 데이터에 접근하는 순간 DB 쿼리를 날린다.

LazyInitializationException

트랜잭션 밖에서 LAZY 필드에 접근하면 예외가 발생한다.

@Transactional
fun getUser(id: Long): User {
    return userRepository.findById(id).get()
}  // 트랜잭션 종료 → 영속성 컨텍스트 닫힘

// 컨트롤러에서
val user = userService.getUser(1L)
user.orders.size  // LazyInitializationException!
// 트랜잭션이 없어서 프록시가 DB 조회를 할 수 없음

해결 방법:

  1. 트랜잭션 안에서 필요한 데이터를 모두 조회한 후 DTO로 변환해서 반환
  2. Fetch Join으로 한 번에 가져오기
  3. @Transactional(readOnly = true)를 컨트롤러까지 확장 (권장하지 않음)

N+1 문제

JPA의 가장 흔한 성능 문제다.

@Entity
class User(
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    val orders: List<Order> = emptyList()
)
val users = userRepository.findAll()  // SELECT * FROM users (1번)

users.forEach { user ->
    println(user.orders.size)
    // SELECT * FROM orders WHERE user_id = ? (유저 수만큼 반복)
}

유저가 100명이면 1 + 100 = 101번 쿼리가 나간다.

왜 발생하는가?

JPA는 연관 데이터를 기본적으로 LAZY로 가져온다. findAll()로 유저를 조회할 때 orders는 프록시 상태다. 나중에 .orders에 접근하면 그때야 각각 쿼리를 날린다.

EAGER로 설정하면 N+1이 안 생길까? 생긴다. 오히려 더 나쁘다. EAGER는 findAll() 시점에 유저마다 orders를 조회하는 쿼리를 날린다. 결과는 똑같이 N+1이다.

해결: Fetch Join

@Query("SELECT u FROM User u JOIN FETCH u.orders")
fun findAllWithOrders(): List<User>
-- 실행되는 SQL (1번)
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.id

한 번의 쿼리로 유저와 주문을 함께 조회한다.

주의: Fetch Join + 페이징 함께 쓰면 문제가 생긴다.

// 이렇게 쓰면 안 됨
@Query("SELECT u FROM User u JOIN FETCH u.orders")
fun findAllWithOrders(pageable: Pageable): Page<User>
// → Hibernate가 전체 데이터를 메모리에 올리고 페이징함 (HHH90003004 경고)

컬렉션 Fetch Join과 페이징은 함께 쓸 수 없다. 해결하려면 @BatchSize 또는 @EntityGraph를 활용한다.

해결: @EntityGraph

@EntityGraph(attributePaths = ["orders"])
fun findAll(): List<User>

JPQL 없이 어노테이션으로 Fetch Join 효과를 낸다. 내부적으로는 LEFT OUTER JOIN을 사용한다.

해결: @BatchSize

@Entity
class User(
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    val orders: List<Order> = emptyList()
)

LAZY 상태에서 orders에 접근할 때, 유저마다 1번씩 쿼리하는 게 아니라 100개씩 묶어서 WHERE user_id IN (1, 2, 3, ...) 형태로 조회한다.

-- N+1 대신
SELECT * FROM orders WHERE user_id IN (1, 2, 3, ..., 100)

Fetch Join처럼 한 번은 아니지만, 쿼리 수를 대폭 줄인다.


쓰기 지연 (Write-Behind)

Hibernate는 SQL을 즉시 실행하지 않는다. 트랜잭션 커밋 전 flush() 시점까지 모아뒀다가 한 번에 실행한다.

@Transactional
fun createUsers() {
    userRepository.save(User("Alice"))  // SQL 즉시 실행 안 함
    userRepository.save(User("Bob"))    // SQL 즉시 실행 안 함
    userRepository.save(User("Charlie"))// SQL 즉시 실행 안 함

    // 여기까지 INSERT 쿼리 0번
}
// 커밋 시점에 INSERT 3번 한꺼번에 실행

이 방식의 장점은 불필요한 SQL을 줄일 수 있다는 것이다.

@Transactional
fun example() {
    val user = userRepository.save(User("Alice"))  // INSERT 대기
    user.name = "Alice Updated"                     // UPDATE 대기

    // flush 시점에 Hibernate가 판단:
    // INSERT + UPDATE = INSERT만 하면 됨 (최적화)
}

정리

개념핵심
JPA vs HibernateJPA는 스펙, Hibernate는 구현체
영속성 컨텍스트엔티티를 관리하는 메모리 공간
1차 캐시같은 트랜잭션 내 동일 id 재조회 시 캐시 사용
Dirty Checking트랜잭션 안에서 객체 변경 시 자동 UPDATE
지연 로딩기본적으로 LAZY, 필요할 때만 Fetch Join
N+1 문제연관 데이터 접근 시 쿼리가 N번 추가 발생
쓰기 지연flush 시점까지 SQL을 모아서 실행

시리즈: Spring Boot 데이터 접근 기술

  1. JPA 심화 ← 현재 글
  2. QueryDSL - 타입 세이프 동적 쿼리
  3. MyBatis - SQL Mapper
  4. JOOQ - 타입 세이프 SQL
  5. Kotlin Exposed - Kotlin ORM
  6. 어떤 걸 선택해야 할까?