findById를 두 번 불러도 쿼리가 한 번인 이유 — Hibernate 1차 캐시

seonwoo_jung·2026년 5월 24일

1. 도입

JPA를 쓰다 보면 같은 트랜잭션 안에서 findById를 두 번 호출했는데 쿼리가 한 번만 나가는 장면을 자주 만난다. 처음에는 "캐시인가?" 정도로 넘겼지만, 어느 날 update 쿼리가 나가야 할 자리에서 안 나가서 한참을 헤맸다. 결국 1차 캐시(Persistence Context)를 한 번 따라가 봐야겠다 싶었다.

이 글에서 정리할 것:

  • Persistence Context가 무엇이고 왜 필요한가
  • 엔티티 상태 전이와 1차 캐시 동작 단계
  • 자주 헷갈리는 dirty checking·flush 시점

2. 핵심 개념

Persistence Context는 "엔티티의 1차 캐시이자, 영속 상태를 관리하는 작업 단위(Unit of Work)"다.

Hibernate User Guide §5.2에 따르면 Session(또는 JPA의 EntityManager)은 자신이 알고 있는 엔티티들을 내부 맵으로 들고 있고, 같은 식별자(PK)에 대해서는 항상 같은 자바 객체를 돌려준다. 이걸 "identity guarantee"라고 부른다.

왜 이런 게 필요한가? 단순한 이유 두 가지다.

  1. 같은 트랜잭션 안에서 같은 행을 두 번 조회해도 DB에 두 번 다녀오면 손해다. → 캐시.
  2. 자바 객체의 필드만 바꿔도 UPDATE가 나가야 ORM답다. → 변경 추적(dirty checking)을 하려면 "어떤 객체가 영속 상태인지"를 트래킹해야 한다.

이 두 가지를 한 군데서 처리하는 게 Persistence Context다.

3. 내부 동작

3.1 엔티티 상태

JPA 스펙(Jakarta Persistence §3.2)이 정의하는 상태는 네 가지로 알려져 있다.

상태설명Persistence Context 안에?
New(Transient)new로 만든 직후. PC 모름X
Managedpersist() 또는 조회 결과O
Detachedclear()/close() 이후X
Removedremove() 호출 후 flush 전O (삭제 예약)

1차 캐시가 의미 있는 건 Managed 상태의 엔티티뿐이다. Detached가 되는 순간 같은 객체를 다시 줘도 캐시 hit이 아니다.

3.2 조회 흐름

em.find(Member.class, 1L)을 호출했을 때 대략 이런 흐름으로 알려져 있다.

em.find(Member.class, 1L)
        │
        ▼
[1] PC 안에 (Member, 1L) 있나?  ─── yes ──► 그대로 반환 (쿼리 X)
        │ no
        ▼
[2] SELECT 실행
        │
        ▼
[3] 결과를 엔티티로 hydrate
        │
        ▼
[4] PC에 (key=Member#1L, value=엔티티, snapshot=현재 필드값) 저장
        │
        ▼
[5] 반환

여기서 핵심은 [4]의 snapshot이다. Hibernate는 엔티티가 PC에 들어가는 순간 모든 영속 필드를 복사해 둔다. 이게 나중에 dirty checking의 기준선이 된다.

3.3 변경 감지(dirty checking)와 flush

영속 상태 엔티티의 필드를 바꿔도 우리는 update()를 명시적으로 호출하지 않는다. 대신 flush 시점에 Hibernate가 이렇게 동작한다.

  1. PC 안의 모든 managed 엔티티를 순회.
  2. 각 엔티티의 현재 필드값 vs 저장된 snapshot을 비교.
  3. 다르면 UPDATE 쿼리를 생성해 ActionQueue에 넣음.
  4. 큐를 DB로 flush.

flush가 트리거되는 지점은 공식 문서에 따르면 보통 다음과 같다.

  • 트랜잭션 commit 직전
  • em.flush() 명시 호출
  • JPQL/Criteria 쿼리 실행 직전 (FlushModeType이 AUTO일 때)

마지막 항목이 자주 함정이다. JPQL을 호출하기 전에 자동 flush가 일어나므로, 같은 트랜잭션 내 JPQL 결과에는 직전 변경분이 반영된다.

3.4 자주 헷갈리는 지점

  • em.clear() 이후의 객체는 같은 PK라도 다른 자바 객체로 돌아온다. identity guarantee는 PC 단위다.
  • persist는 즉시 INSERT를 의미하지 않는다. INSERT는 flush에서 결정된다 (단, IDENTITY 전략은 PK 확보를 위해 즉시 INSERT가 나간다고 알려져 있다).
  • Detached 엔티티의 필드를 바꿔도 dirty checking 대상이 아니다. merge()로 다시 PC에 붙여야 추적된다.

4. 예시

가장 작은 예시 하나만 본다.

@Transactional
public void changeNickname(Long memberId, String newNickname) {
    // [1] PC miss → SELECT 1번
    Member m = em.find(Member.class, memberId);

    // [2] PC hit → 쿼리 0번, 같은 자바 객체
    Member m2 = em.find(Member.class, memberId);
    assert m == m2;

    // [3] 단순 setter — update() 호출 없음
    m.setNickname(newNickname);

    // [4] 메서드 종료 → commit → flush
    //     snapshot과 비교하여 nickname만 UPDATE
}

이 코드에서 SQL은 SELECT 1번 + UPDATE 1번만 나간다. 만약 em.clear()를 [2]와 [3] 사이에 끼우면 m은 Detached가 되어 setter가 무시되고, UPDATE가 나가지 않는다.

5. 정리

1차 캐시는 "같은 PK = 같은 자바 객체"를 트랜잭션 동안 보장하는 맵이고, 그 위에 snapshot 기반 dirty checking이 얹혀 있다.

성능 최적화 캐시라기보다는 ORM이 객체-관계 매핑을 일관되게 유지하기 위한 작업 단위로 보는 편이 이해가 쉬웠다. 캐시 hit으로 인한 쿼리 절감은 부수 효과에 가깝다.

다음에 더 파고들 만한 주제:

  • flush() 시점과 FlushMode (AUTO vs COMMIT)의 실제 차이
  • 2차 캐시(@Cacheable, region)와 1차 캐시의 상호작용

참고 자료

  • Hibernate User Guide §5.2 "Persistence Context"
  • Jakarta Persistence Specification §3.2 "Entity Instance's Life Cycle"
  • Hibernate User Guide §6 "Flushing"

0개의 댓글