Java & Spring/JPA

영속성 컨텍스트 메커니즘 - 1차 캐시 · Dirty Checking · 쓰기 지연

승주우에요 2026. 5. 9. 16:53

1. JPA와 Hibernate

JPA(Jakarta Persistence API) 는 자바 객체를 관계형 DB에 매핑하는 명세이다. 인터페이스와 어노테이션의 집합일 뿐, 실제 동작 코드는 없다.

Hibernate 는 이 명세의 구현체다. JPA보다 먼저 존재했고, 오히려 JPA 명세가 Hibernate의 영향을 받아 만들어졌다.

 

구분                           JPA                                                                                                  Hibernate

위치 명세 구현체
패키지 jakarta.persistence.* org.hibernate.*
역할 인터페이스/어노테이션 정의 실제 동작 코드

spring-boot-starter-data-jpa 의존성 하나를 추가하면 JPA + Hibernate + Spring Data JPA가 함께 들어온다.


2. 영속성 컨텍스트란?

JPA 명세는 영속성 컨텍스트를 이렇게 정의한다.

A persistence context is a set of managed entity instances in which for any persistent entity identity there is a unique entity instance.

쉽게 말하면, 애플리케이션과 DB 사이에 존재하는 메모리 캐시이자 변경 추적기다.

Hibernate에서는 StatefulPersistenceContext로 구현되며, Javadoc은 이렇게 설명한다.

Represents the state of "stuff" Hibernate is tracking, including entities, collections, snapshots, proxies. Often referred to as the "first level cache".

생명주기

영속성 컨텍스트는 EntityManager 단위로 생성되며, 보통 트랜잭션 단위로 살아있다.

@Transactional 메서드 시작
  → JpaTransactionManager가 EntityManager 생성
  → 내부적으로 새 StatefulPersistenceContext 생성 (비어있음)
  → ThreadLocal에 EntityManager 바인딩

비즈니스 로직 실행
  → 같은 트랜잭션 안의 모든 Repository 호출이 동일한 EntityManager 사용
  → 동일한 영속성 컨텍스트 = 동일한 1차 캐시

@Transactional 메서드 종료
  → flush() 실행 (Dirty Checking → SQL 생성)
  → commit()
  → EntityManager.close()
  → 영속성 컨텍스트 소멸 → 모든 엔티티 Detached 상태로

3. 엔티티의 4가지 상태

JPA에서 엔티티는 항상 아래 4가지 상태 중 하나에 있다.

상태 영문 설명

비영속 Transient new로 생성된 직후. JPA가 모름
영속 Managed 영속성 컨텍스트가 관리 중. 변경 자동 추적
준영속 Detached 한때 영속이었으나 컨텍스트에서 분리됨
삭제 Removed 삭제 대상으로 표시. flush 시 DELETE
User user = new User("alice");   // 비영속 (Transient)

em.persist(user);                // 영속 (Managed) — 컨텍스트에 등록

em.detach(user);                 // 준영속 (Detached)
// 또는 트랜잭션 종료 시 자동으로 Detached

em.remove(user);                 // 삭제 (Removed) — flush 시 DELETE

Detached 상태와 merge()

준영속 엔티티는 변경해도 DB에 반영되지 않는다. 다시 영속화하려면 merge()를 써야 한다.

User detached = em.find(User.class, 1L);
em.detach(detached);

detached.setName("ignored");      // DB에 반영 안 됨

User managed = em.merge(detached); // 새로운 Managed 인스턴스 반환
managed.setName("changed");        // 이건 반영됨

merge()의 반환값이 새로운 인스턴스라는 점이 중요하다. 원본 detached는 여전히 Detached 상태다. 이 동작이 Spring Data JPA의 save() 내부 로직과 직결된다.


4. 1차 캐시 (First-Level Cache)

내부 구조

영속성 컨텍스트 내부(StatefulPersistenceContext)에는 세 가지 핵심 자료구조가 있다.

public class StatefulPersistenceContext implements PersistenceContext {
    private Map<EntityKey, Object>   entitiesByKey;         // 1차 캐시
    private EntityEntryContext       entityEntryContext;     // 엔티티 상태 관리
    private Map<EntityKey, Object[]> entitySnapshotsByKey;  // Dirty Checking용 스냅샷
}

자료구조 용도

entitiesByKey ID로 엔티티 인스턴스 반환 — 1차 캐시
entityEntryContext 영속/준영속/삭제 상태 추적
entitySnapshotsByKey 로드 시점의 원본 값 — Dirty Checking용

동작

@Transactional
public void example() {
    User u1 = userRepository.findById(1L).orElseThrow(); // SELECT 실행, 캐시에 저장
    User u2 = userRepository.findById(1L).orElseThrow(); // 캐시 HIT, DB 안 침

    System.out.println(u1 == u2); // true — 동일한 인스턴스
}

findById()가 호출되면 Hibernate는 먼저 entitiesByKey에서 EntityKey(User, 1L)을 조회한다. 캐시에 있으면 즉시 반환, 없으면 SELECT를 실행한 뒤 캐시에 등록하고 반환한다.

Application-level Repeatable Read

같은 트랜잭션 안에서 같은 엔티티를 여러 번 조회하면 항상 동일한 인스턴스가 반환된다. 중간에 다른 트랜잭션이 그 행을 바꿔도, 1차 캐시에 있는 인스턴스가 그대로 반환된다.

이를 Application-level Repeatable Read라고 한다. DB의 격리 수준(Isolation Level)과는 별개로 JPA 레벨에서 트랜잭션 내 일관성을 보장한다.

1차 캐시의 한계

1차 캐시는 트랜잭션 단위다. 트랜잭션이 다르면 캐시도 다르고, 다른 사용자의 요청과 공유되지 않는다.

대규모 캐싱이 필요하다면 Redis 같은 별도 캐시 레이어를 써야 한다. 1차 캐시의 주 목적은 성능 캐싱이 아니라 Dirty Checking과 객체 동일성 보장이다.


5. 쓰기 지연 (Write-behind)

핵심 아이디어

persist(), remove(), setter 호출 같은 변경 작업은 즉시 SQL로 실행되지 않는다. 대신 영속성 컨텍스트 내부의 ActionQueue에 쌓아두었다가, flush 시점에 한꺼번에 실행한다.

@Transactional
public void writeBehindExample() {
    em.persist(new User("alice"));   // SQL 안 나감, ActionQueue에 큐잉
    em.persist(new User("bob"));     // SQL 안 나감
    em.persist(new User("charlie")); // SQL 안 나감

    // 메서드 종료 → flush → INSERT 3개 한꺼번에 실행
}

왜 지연하는가

① JDBC 배치를 통한 성능 최적화

INSERT를 모아두면 JDBC Batch Insert로 묶어 한 번의 네트워크 라운드트립?으로 보낼 수 있다.

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
          order_inserts: true
          order_updates: true

만 건 INSERT 시 1만 번의 네트워크 호출이 200번으로 줄어든다.

② DB 락 점유 시간 최소화

UPDATE/DELETE는 락을 잡는다. 마지막 순간에 한꺼번에 실행하면 락 점유 시간이 짧아진다.

③ 중간 변경 무시

같은 엔티티를 여러 번 수정해도 마지막 결과만 UPDATE된다. 중간 단계 SQL이 없다.

ActionQueue 내부

public class ActionQueue {
    private ExecutableList<AbstractEntityInsertAction> insertions;
    private ExecutableList<EntityUpdateAction>         updates;
    private ExecutableList<EntityDeleteAction>         deletions;
    private ExecutableList<CollectionRecreateAction>   collectionCreations;
    private ExecutableList<CollectionUpdateAction>     collectionUpdates;
    private ExecutableList<CollectionRemoveAction>     collectionRemovals;
    private ExecutableList<OrphanRemovalAction>        orphanRemovals;
}

ActionQueue 소스 코드 주석에는 이렇게 적혀있다.

Object insertions, updates, and deletions have list semantics because they must happen in the right order to respect referential integrity.
객체의 INSERT, UPDATE, DELETE는 리스트 구조를 갖는다. 참조 무결성을 지키기 위해 올바른 순서로 실행되어야 하기 때문이다!


6. flush — SQL이 실제로 실행되는 시점

flush는 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 작업이다. commit이 아니다. flush 후에도 트랜잭션은 유지되며 롤백이 가능하다.

flush 발생 시점

트랜잭션 커밋 직전 가장 일반적. @Transactional 메서드 종료 시
JPQL/Criteria 쿼리 실행 전 쿼리 결과가 변경사항을 반영해야 하므로 자동 flush
em.flush() 명시 호출 개발자가 직접 강제

flush 시 일어나는 일 (3단계)

[1단계] Dirty Checking
  → 모든 영속 엔티티에 대해 현재 상태 vs 스냅샷 비교
  → 변경된 엔티티에 EntityUpdateAction 큐잉

[2단계] ActionQueue 정렬
  → 참조 무결성 보장을 위한 순서로 재정렬

[3단계] SQL 실행
  → 고정된 순서로 SQL 실행

ActionQueue 실행 순서

Hibernate는 개발자가 호출한 순서가 아니라 고정된 순서로 SQL을 실행한다.

1. OrphanRemovalAction   ← orphanRemoval=true에 의한 삭제
2. EntityInsertAction    ← INSERT
3. EntityUpdateAction    ← UPDATE
4. CollectionRemoveAction
5. CollectionUpdateAction
6. CollectionRecreateAction
7. EntityDeleteAction    ← DELETE

INSERT가 DELETE보다 먼저인 이유는 참조 무결성 때문이다. 부모 → 자식 순 INSERT 후 자식 → 부모 순 DELETE가 대부분의 시나리오에서 안전하다.

흔한 함정 — DELETE 후 같은 UNIQUE 키로 INSERT

@Transactional
public void replaceShape() {
    Shape old = em.find(Shape.class, 1L); // slug = "circle"
    em.remove(old);                        // DELETE 큐잉

    Shape fresh = new Shape();
    fresh.setSlug("circle");               // 같은 slug
    em.persist(fresh);                     // INSERT 큐잉

    // flush 시점:
    // 1. INSERT (slug="circle") ← 기존 row가 아직 있으니 UNIQUE 제약 위반!
    // 2. DELETE (id=1)
    // → DataIntegrityViolationException
}

해결법은 명시적 flush로 순서를 강제하는 것이다.

@Transactional
public void replaceShape() {
    Shape old = em.find(Shape.class, 1L);
    em.remove(old);
    em.flush(); // 여기서 DELETE 먼저 실행

    Shape fresh = new Shape();
    fresh.setSlug("circle");
    em.persist(fresh); // 이제 안전하게 INSERT
}

7. Dirty Checking (변경 감지)

동작 원리

save()를 호출하지 않아도 UPDATE가 나가는 이유가 Dirty Checking이다.

@Transactional
public void updateUser(Long userId, String newName) {
    User user = userRepository.findById(userId).orElseThrow();
    user.setName(newName);
    // save() 없음. 그런데도 메서드 종료 시 UPDATE 발생
}

JPA는 엔티티가 영속성 컨텍스트에 등록될 때 스냅샷(로드 시점의 원본 값) 을 함께 저장한다. flush 시점에 현재 상태와 스냅샷을 비교해서 달라진 필드가 있으면 UPDATE SQL을 생성한다.

[엔티티 로드 시]
  SELECT 결과 → 엔티티 인스턴스 생성
  → entitySnapshotsByKey에 원본 값 배열(Object[]) 저장

[setter 호출 시]
  user.setName("alice2")
  → 그냥 자바 객체의 필드가 바뀔 뿐
  → JPA는 이 시점엔 아무것도 안 함

[flush 시점]
  for each 영속 엔티티:
    currentState = persister.getValues(entity)
    snapshot    = entitySnapshotsByKey.get(entityKey)
    dirtyProps  = persister.findDirty(currentState, snapshot, ...)
    if (dirtyProps != null):
      ActionQueue에 EntityUpdateAction 추가

save()가 진짜로 필요한 경우

영속 상태의 엔티티에 save()를 호출하면 Spring Data JPA 내부에서 merge()가 실행된다. 이미 영속 상태인 엔티티를 merge해도 결국 Dirty Checking으로 UPDATE가 나간다. 오히려 merge()가 추가 SELECT를 유발할 수 있어 손해다.

// SimpleJpaRepository
public <S extends T> S save(S entity) {
    if (entityInformation.isNew(entity)) {
        em.persist(entity); // 새 엔티티 → INSERT
        return entity;
    } else {
        return em.merge(entity); // 기존 엔티티 → merge (영속 상태면 불필요)
    }
}

save()가 진짜 필요한 경우는 세 가지다.

  • 새 엔티티를 처음 저장할 때 (persist)
  • Detached 엔티티를 다시 영속화할 때 (merge)
  • isNew() 판단을 위임하고 싶을 때

Bulk Update와 같이 쓸 때 주의

@Modifying 쿼리는 DB를 직접 수정하지만 1차 캐시는 갱신하지 않는다. 이미 캐시에 올라온 엔티티가 있다면 stale 데이터를 읽게 된다.

@Modifying(clearAutomatically = true) // 1차 캐시를 명시적으로 비움
@Query("UPDATE User u SET u.likeCount = u.likeCount + 1 WHERE u.id = :id")
void incrementLikeCount(@Param("id") Long id);

clearAutomatically = true로 bulk update 후 1차 캐시를 비워야 안전하다.

대량 처리 시 메모리 주의

스냅샷은 모든 영속 엔티티마다 보관된다. 한 트랜잭션에서 만 건을 로드하면 스냅샷도 만 건이 메모리에 올라간다. 배치 처리에서는 주기적으로 flush + clear를 해줘야 한다.

@Transactional
public void batchProcess() {
    for (int i = 0; i < 10000; i++) {
        em.persist(new User("user" + i));

        if (i % 50 == 0) {
            em.flush(); // 모아둔 INSERT 실행
            em.clear(); // 영속성 컨텍스트 비우기 (스냅샷도 삭제)
        }
    }
}

clear() 없이 만 건을 처리하면 flush 시 만 건의 Dirty Checking이 일어나 매우 느려진다.


8. 정리

메커니즘                      핵심                                                                                    주의점

1차 캐시 같은 트랜잭션 내 동일 ID 재조회 시 DB를 치지 않음 트랜잭션 범위 내에서만 유효
쓰기 지연 변경을 ActionQueue에 모아 flush 시 일괄 실행 INSERT → UPDATE → DELETE 고정 순서
Dirty Checking 스냅샷 비교로 자동 UPDATE. save() 불필요 Bulk Update 후 캐시 무효화 필요

JPA를 잘 쓴다는 것은 결국 영속성 컨텍스트의 생명주기를 이해하고, 언제 SQL이 실제로 나가는지 예측할 수 있는 것이다.