JPA를 쓰다 보면 같은 트랜잭션 안에서 findById를 두 번 호출했는데 쿼리가 한 번만 나가는 장면을 자주 만난다. 처음에는 "캐시인가?" 정도로 넘겼지만, 어느 날 update 쿼리가 나가야 할 자리에서 안 나가서 한참을 헤맸다. 결국 1차 캐시(Persistence Context)를 한 번 따라가 봐야겠다 싶었다.
이 글에서 정리할 것:
Persistence Context는 "엔티티의 1차 캐시이자, 영속 상태를 관리하는 작업 단위(Unit of Work)"다.
Hibernate User Guide §5.2에 따르면 Session(또는 JPA의 EntityManager)은 자신이 알고 있는 엔티티들을 내부 맵으로 들고 있고, 같은 식별자(PK)에 대해서는 항상 같은 자바 객체를 돌려준다. 이걸 "identity guarantee"라고 부른다.
왜 이런 게 필요한가? 단순한 이유 두 가지다.
이 두 가지를 한 군데서 처리하는 게 Persistence Context다.
JPA 스펙(Jakarta Persistence §3.2)이 정의하는 상태는 네 가지로 알려져 있다.
| 상태 | 설명 | Persistence Context 안에? |
|---|---|---|
| New(Transient) | new로 만든 직후. PC 모름 | X |
| Managed | persist() 또는 조회 결과 | O |
| Detached | clear()/close() 이후 | X |
| Removed | remove() 호출 후 flush 전 | O (삭제 예약) |
1차 캐시가 의미 있는 건 Managed 상태의 엔티티뿐이다. Detached가 되는 순간 같은 객체를 다시 줘도 캐시 hit이 아니다.
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의 기준선이 된다.
영속 상태 엔티티의 필드를 바꿔도 우리는 update()를 명시적으로 호출하지 않는다. 대신 flush 시점에 Hibernate가 이렇게 동작한다.
flush가 트리거되는 지점은 공식 문서에 따르면 보통 다음과 같다.
em.flush() 명시 호출AUTO일 때)마지막 항목이 자주 함정이다. JPQL을 호출하기 전에 자동 flush가 일어나므로, 같은 트랜잭션 내 JPQL 결과에는 직전 변경분이 반영된다.
em.clear() 이후의 객체는 같은 PK라도 다른 자바 객체로 돌아온다. identity guarantee는 PC 단위다.persist는 즉시 INSERT를 의미하지 않는다. INSERT는 flush에서 결정된다 (단, IDENTITY 전략은 PK 확보를 위해 즉시 INSERT가 나간다고 알려져 있다).merge()로 다시 PC에 붙여야 추적된다.가장 작은 예시 하나만 본다.
@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가 나가지 않는다.
1차 캐시는 "같은 PK = 같은 자바 객체"를 트랜잭션 동안 보장하는 맵이고, 그 위에 snapshot 기반 dirty checking이 얹혀 있다.
성능 최적화 캐시라기보다는 ORM이 객체-관계 매핑을 일관되게 유지하기 위한 작업 단위로 보는 편이 이해가 쉬웠다. 캐시 hit으로 인한 쿼리 절감은 부수 효과에 가깝다.
다음에 더 파고들 만한 주제:
flush() 시점과 FlushMode (AUTO vs COMMIT)의 실제 차이@Cacheable, region)와 1차 캐시의 상호작용