영속성 컨텍스트란?
Hibernate의 Persistence Context(영속성 컨텍스트)는 엔티티를 관리하는 1차 캐시이자 작업 단위(Unit of Work)다. EntityManager가 관리하는 이 메모리 영역에서 엔티티의 생명주기, 변경 감지, 지연 로딩이 모두 동작한다. JPA를 사용하면서 이 개념을 정확히 이해하지 않으면 예측 불가능한 쿼리, 성능 문제, 데이터 불일치에 빠진다.
엔티티 생명주기 4단계
| 상태 | 설명 | 영속성 컨텍스트 |
|---|---|---|
| New (비영속) | new로 생성만 한 상태 |
관리 안 함 |
| Managed (영속) | persist() 또는 find()로 조회된 상태 |
관리 중 (1차 캐시) |
| Detached (준영속) | detach() 또는 트랜잭션 종료 후 |
관리 안 함 (ID 있음) |
| Removed (삭제) | remove() 호출 |
삭제 예약 |
// 비영속 → 영속
User user = new User("alice"); // New (비영속)
em.persist(user); // Managed (영속) — INSERT 예약
// 영속 → 준영속
em.detach(user); // Detached — 변경 감지 안 됨
user.setName("bob"); // DB에 반영 안 됨!
// 준영속 → 영속 (재부착)
User merged = em.merge(user); // Managed — 새 인스턴스 반환
// 주의: user와 merged는 다른 객체!
1차 캐시: 동일 트랜잭션 내 동일성 보장
영속성 컨텍스트는 엔티티를 ID 기준으로 캐싱한다. 같은 트랜잭션 안에서 같은 ID를 두 번 조회하면 DB 쿼리는 한 번만 실행된다.
@Transactional
public void example() {
User u1 = em.find(User.class, 1L); // SELECT 실행
User u2 = em.find(User.class, 1L); // 캐시 히트 — SQL 없음
assert u1 == u2; // true! 같은 객체 참조 (동일성 보장)
}
// JPQL은 항상 DB를 조회하지만, 결과를 1차 캐시와 병합
User u1 = em.find(User.class, 1L);
User u2 = em.createQuery("SELECT u FROM User u WHERE u.id = 1", User.class)
.getSingleResult(); // SELECT 실행되지만...
assert u1 == u2; // true! 1차 캐시의 기존 객체 반환
이 동일성 보장은 Repeatable Read와 유사한 효과를 애플리케이션 수준에서 제공한다.
Dirty Checking: 자동 변경 감지
Hibernate의 가장 강력한 기능 중 하나다. 영속 상태 엔티티의 필드를 변경하면 별도의 update 호출 없이 트랜잭션 커밋 시 자동으로 UPDATE SQL이 생성된다.
@Transactional
public void updateUser(Long id, String newName) {
User user = em.find(User.class, id); // 영속 상태
user.setName(newName); // 변경
// em.merge() 불필요! flush 시점에 자동 UPDATE
}
// 내부 동작:
// 1. find() 시 엔티티의 "스냅샷"을 1차 캐시에 저장
// 2. flush() 시 현재 상태와 스냅샷을 비교 (dirty checking)
// 3. 변경된 필드가 있으면 UPDATE SQL 생성
@DynamicUpdate로 변경 컬럼만 UPDATE
// 기본: 모든 컬럼 UPDATE
UPDATE users SET name=?, email=?, age=?, ... WHERE id=?
// @DynamicUpdate 사용 시: 변경된 컬럼만
@Entity
@DynamicUpdate
public class User {
// ...
}
// UPDATE users SET name=? WHERE id=?
컬럼이 많은 테이블이나 인덱스가 많은 경우 @DynamicUpdate가 성능에 도움된다. 단, SQL 캐싱 효율은 떨어질 수 있다.
Flush 모드: 언제 SQL이 실행되나?
Flush는 영속성 컨텍스트의 변경사항을 DB에 동기화하는 작업이다. 트랜잭션 커밋이 아니라 flush 시점에 SQL이 실행된다.
| FlushModeType | 동작 | 적합 상황 |
|---|---|---|
| AUTO (기본) | 쿼리 실행 전 + 커밋 전 자동 flush | 대부분의 경우 |
| COMMIT | 커밋 시에만 flush | 대량 읽기 전용 작업 |
// AUTO 모드에서의 자동 flush
user.setName("alice"); // dirty
// JPQL 실행 전에 자동 flush → UPDATE 먼저 실행
List<User> users = em.createQuery("SELECT u FROM User u").getResultList();
// COMMIT 모드로 변경 — 읽기 작업 시 불필요한 flush 방지
em.setFlushMode(FlushModeType.COMMIT);
// 대량 조회 시 성능 향상 (dirty checking 생략)
Proxy와 지연 로딩의 함정
Hibernate는 @ManyToOne(fetch = LAZY) 관계를 프록시 객체로 대체한다. 실제 필드에 접근할 때 SELECT가 실행되는데, 영속성 컨텍스트가 닫힌 후 접근하면 LazyInitializationException이 발생한다.
// ❌ 트랜잭션 밖에서 Lazy 접근 → 예외
@Transactional
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
}
// 컨트롤러에서:
Order order = service.getOrder(1L);
order.getUser().getName(); // LazyInitializationException!
// ✅ 해결 1: Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
Optional<Order> findByIdWithUser(@Param("id") Long id);
// ✅ 해결 2: EntityGraph
@EntityGraph(attributePaths = {"user"})
Optional<Order> findById(Long id);
JPA EntityGraph 페치 전략을 참고하면 다양한 페치 전략을 비교할 수 있다.
대량 처리 시 영속성 컨텍스트 관리
수만 건의 엔티티를 한 트랜잭션에서 처리하면 1차 캐시가 메모리를 잡아먹고 dirty checking 비용이 급증한다.
// ❌ 메모리 폭발
@Transactional
public void importUsers(List<UserDto> dtos) {
for (UserDto dto : dtos) {
em.persist(new User(dto)); // 1차 캐시에 10만 건 쌓임
}
}
// ✅ 배치 flush + clear
@Transactional
public void importUsers(List<UserDto> dtos) {
int batchSize = 50;
for (int i = 0; i < dtos.size(); i++) {
em.persist(new User(dtos.get(i)));
if (i % batchSize == 0) {
em.flush(); // INSERT 실행
em.clear(); // 1차 캐시 비우기
}
}
}
// application.yml — JDBC 배치 설정
// spring.jpa.properties.hibernate.jdbc.batch_size: 50
// spring.jpa.properties.hibernate.order_inserts: true
hibernate.jdbc.batch_size와 order_inserts를 함께 설정해야 실제로 JDBC 배치 INSERT가 동작한다. Spring JPA Batch Insert 최적화에서 자세한 벤치마크를 확인할 수 있다.
em.getReference() vs em.find()
// find(): 즉시 SELECT 실행
User user = em.find(User.class, 1L); // SELECT * FROM users WHERE id=1
// getReference(): 프록시 반환 (SELECT 없음)
User ref = em.getReference(User.class, 1L); // 프록시 객체
// FK 설정에 유용 — SELECT 없이 관계 설정
Order order = new Order();
order.setUser(ref); // user_id = 1 설정됨, User SELECT 불필요
em.persist(order);
getReference()는 FK만 설정할 때 불필요한 SELECT를 제거하는 최적화 기법이다.
OSIV: Open Session In View
Spring Boot는 기본적으로 OSIV(Open Session In View)가 활성화되어 있다. HTTP 요청 전체에 걸쳐 영속성 컨텍스트가 열려 있어 컨트롤러에서도 Lazy Loading이 동작하지만, DB 커넥션을 오래 점유하는 문제가 있다.
# application.yml — 프로덕션에서는 비활성화 권장
spring:
jpa:
open-in-view: false # OSIV 비활성화
# 비활성화 후 Lazy 접근이 필요한 곳은 서비스 계층에서 Fetch Join 사용
clear() vs detach() vs evict()
em.clear()— 영속성 컨텍스트 전체 초기화. 모든 엔티티가 Detached 상태로 전환.em.detach(entity)— 특정 엔티티만 분리. 해당 엔티티의 변경은 더 이상 추적 안 됨.em.contains(entity)— 특정 엔티티가 영속 상태인지 확인.
User user = em.find(User.class, 1L);
assert em.contains(user); // true
em.detach(user);
assert !em.contains(user); // true
user.setName("changed"); // DB에 반영 안 됨
정리
Hibernate Persistence Context는 JPA의 모든 마법의 원천이다. 1차 캐시의 동일성 보장, Dirty Checking의 자동 UPDATE, Flush 타이밍, Proxy 기반 지연 로딩 — 이 네 가지 메커니즘을 이해하면 JPA의 예측 불가능한 동작이 사라진다. 핵심 원칙은 하나다: 영속 상태 엔티티만 Hibernate가 관리한다. 이 경계를 의식하면 LazyInitializationException, 불필요한 쿼리, 메모리 문제를 모두 예방할 수 있다.