영속성 컨텍스트 내부의 엔티티 저장소. 트랜잭션 범위에서만 살아 있다.
구조
영속성 컨텍스트 (EntityManager 하나 = 보통 트랜잭션 하나)
├─ 1차 캐시 : Map<식별자, 엔티티>
│ { Member#1 → 엔티티 인스턴스 }
├─ 스냅샷 : 조회 시점의 필드 값 복사본 (더티 체킹용)
└─ 쓰기 지연 SQL 저장소
동작
Member m1 = em.find(Member.class, 1L); // SELECT 실행 → 1차 캐시에 저장
Member m2 = em.find(Member.class, 1L); // 쿼리 없음! 캐시에서 반환
System.out.println(m1 == m2); // true — 같은 인스턴스
같은 트랜잭션 안에서 같은 ID를 조회하면 항상 같은 객체를 돌려준다. 이것을 동일성 보장(identity)이라 한다.
== 비교가 성립하므로 equals를 재정의하지 않아도 같은 엔티티임을 확인할 수 있다.
무엇에 쓰이나 — 캐시는 부수 효과일 뿐
이름이 "캐시"라 성능 장치로 오해하기 쉽지만, 진짜 목적은 동일성 보장과 더티 체킹의 기반이다.
더티 체킹 (변경 감지)
@Transactional
public void updateName(Long id) {
Member m = repo.findById(id).get();
m.setName("새이름"); // setter만 호출. save() 안 부른다
} // 커밋 시점: 스냅샷과 비교 → 달라진 필드만 UPDATE 자동 생성
조회 시점의 스냅샷을 1차 캐시가 들고 있기 때문에 비교가 가능하다.
save()를 부르지 않아도 수정이 반영되는 이유다.
쓰기 지연
em.persist(m1); // INSERT 를 바로 안 보낸다. 저장소에 쌓아 둔다
em.persist(m2);
// flush 시점에 모아서 전송 → JDBC 배치로 묶을 수 있다
hibernate.jdbc.batch_size 를 설정하면 여러 INSERT를 한 번에 보낸다.
함정 ① — JPQL은 1차 캐시를 조회하지 않는다
Member m1 = em.find(Member.class, 1L); // 캐시에 저장
List<Member> list = em.createQuery("select m from Member m")
.getResultList(); // DB로 쿼리가 나간다
JPQL은 항상 DB에 쿼리를 날린다. 캐시를 먼저 뒤지지 않는다. 다만 결과를 캐시에 넣을 때 이미 있는 엔티티는 DB 값을 버리고 캐시 것을 유지한다 (동일성 보장을 지키기 위해).
이 때문에 JPQL 전에 자동 flush가 일어난다 — 아직 DB에 안 보낸 변경이 쿼리 결과에 반영되지 않으면 이상하기 때문이다.
함정 ② — 벌크 연산은 캐시를 우회한다
@Modifying
@Query("update Member m set m.age = m.age + 1")
void bulkUpdate(); // DB는 바뀌었지만 1차 캐시는 옛 값 그대로 ✗
@Modifying(clearAutomatically = true) // 실행 후 캐시를 비운다
이 옵션이 없으면 이후 조회에서 옛 값을 보게 된다.
함정 ③ — 낙관적 락 재시도와의 충돌
@Transactional
public void retry() {
for (int i = 0; i < 3; i++) {
try { Stock s = repo.findById(1L).get(); s.decrease(); return; }
catch (OptimisticLockException e) { /* 재시도 */ }
}
}
두 번째 findById가 DB가 아니라 1차 캐시에서 옛 버전을 반환한다.
→ 몇 번을 해도 같은 버전으로 실패한다. 트랜잭션 자체를 새로 열어야 한다.
2차 캐시와의 차이
| 1차 캐시 | 2차 캐시 | |
|---|---|---|
| 범위 | 트랜잭션 | 애플리케이션 전체 |
| 공유 | 안 함 | 모든 트랜잭션이 공유 |
| 기본 | 항상 켜짐 | 꺼짐(설정 필요) |
| 목적 | 동일성·더티 체킹 | 실제 성능 향상 |
1차 캐시로 성능이 좋아지는 것은 부수적이다. 트랜잭션이 끝나면 사라지므로 캐시 히트율이 높지 않다. 진짜 캐싱은 2차 캐시나 Redis가 담당한다.
크기 주의
대량 처리에서 영속성 컨텍스트에 엔티티가 계속 쌓이면 OOM이 난다.
for (int i = 0; i < 1_000_000; i++) {
em.persist(new Item());
if (i % 1000 == 0) { em.flush(); em.clear(); } // 주기적으로 비운다
}