핵심 한 줄

영속성 컨텍스트는 EntityManager가 트랜잭션 동안 들고 있는 엔티티 작업 공간이다. 1차 캐시 · 스냅샷 · 쓰기 지연 SQL 저장소로 이뤄지고, 트랜잭션과 생명주기를 공유한다. 그래서 같은 트랜잭션 안에서 save() 직후 findById()를 해도 DB로 SELECT가 안 나간다 — 답이 이미 1차 캐시에 있기 때문이다.


1. JpaRepository는 결국 EntityManager다

Spring Data JPA의 JpaRepository는 인터페이스고, 런타임 구현체는 SimpleJpaRepository다. 이 구현체는 내부에 EntityManager를 필드로 들고 있고, 우리가 부르는 메서드는 전부 거기로 위임된다.

  • save()em.persist() / em.merge()
  • findById()em.find()

그리고 그 EntityManager트랜잭션 단위로 공유된다. @PersistenceContext가 주입하는 건 실제 EM이 아니라, “지금 이 트랜잭션의 EM으로 위임하라”는 공유 프록시다. 결과적으로 한 트랜잭션 안의 모든 리포지토리 호출은 같은 영속성 컨텍스트를 본다.

한 줄 직관

리포지토리는 EntityManager를 감싼 얇은 포장지다. 메서드 이름만 다를 뿐, 같은 EM·같은 영속성 컨텍스트를 공유한다.


2. save 후 findById가 SELECT를 안 날리는 이유

범인은 1차 캐시(first-level cache) 다.

@Test
void save_and_findById() {
    User saved = userRepository.save(new User("juho"));
 
    assertThat(saved.getId()).isNotNull();
 
    User found = userRepository.findById(saved.getId()).orElseThrow();
    assertThat(found).isEqualTo(saved); // 같은 인스턴스
}

persist 시 엔티티는 @Id를 키로 1차 캐시(Map)에 등록된다. 이후 같은 트랜잭션에서 같은 id로 find하면, EntityManager는 DB에 가기 전에 1차 캐시를 먼저 조회한다. 키가 있으면 그 인스턴스를 그대로 반환한다 → DB SELECT 없음.

덤으로 동일성(identity) 도 보장된다. 같은 id를 두 번 조회하면 == 비교가 참인 같은 인스턴스가 나온다.

SELECT 생략 ≠ INSERT 생략

여기서 생략되는 건 조회 SELECT다. 저장 자체의 INSERT가 언제 나가는지는 별개 문제이며, id 생성 전략에 따라 다르다(5. IDENTITY vs SEQUENCE — 쓰기 지연이 다른 이유).

한 줄 직관

find는 DB가 아니라 1차 캐시에 먼저 묻는다.


3. 트랜잭션 경계 = 1차 캐시 범위

왜 하필 “같은 트랜잭션”이 조건일까? 영속성 컨텍스트의 생명주기가 트랜잭션 생명주기와 일치하기 때문이다(Spring 기본 = 트랜잭션 범위 영속성 컨텍스트).

트랜잭션 시작 → EntityManager 바인딩 → (작업) → 커밋/롤백 시 flush 후 EM close → 1차 캐시 폐기

1차 캐시가 살아있는 구간 = 트랜잭션 구간이다. 트랜잭션 밖(예: OSIV를 끈 상태의 컨트롤러)에서는 영속성 컨텍스트가 없으니 캐시 효과도, 지연 로딩도 기대할 수 없다.

한 줄 직관

1차 캐시는 트랜잭션과 함께 태어나서 트랜잭션과 함께 죽는다.


4. REQUIRES_NEW로 트랜잭션을 분리하면

REQUIRES_NEW는 기존 트랜잭션을 보류하고 새 트랜잭션을 연다. 새 트랜잭션 = 새 EntityManager = 새 1차 캐시다. 그래서:

  • 바깥 트랜잭션에서 1차 캐시에 올려둔 엔티티는 안쪽에서 보이지 않는다. 안쪽에서 같은 id를 조회하면 DB SELECT가 나간다.
  • 같은 row라도 두 개의 인스턴스가 생긴다 → 동일성이 깨진다.

같은 데이터, 다른 인스턴스

바깥과 안쪽이 같은 row를 각자 들고 작업하면, 한쪽의 변경이 다른 쪽 스냅샷에 반영되지 않아 dirty checking이 엇갈리거나 마지막 flush가 앞선 변경을 덮을 수 있다. 트랜잭션을 굳이 분리할 때 항상 따져봐야 할 비용이다.


5. IDENTITY vs SEQUENCE — 쓰기 지연이 다른 이유

쓰기 지연(transactional write-behind) 이란, persist 시 바로 INSERT를 보내지 않고 쓰기 지연 SQL 저장소에 모았다가 flush 시점에 한꺼번에 내보내는 것이다. 여기서 JDBC 배치 INSERT도 가능해진다.

이 동작이 id 생성 전략에 따라 갈린다. 핵심은 “INSERT를 하기 전에 id를 알 수 있느냐” 다. Hibernate는 엔티티를 1차 캐시에 넣으려면 키(id)가 먼저 있어야 하기 때문이다.

  • SEQUENCEpersist 시 DB 시퀀스를 호출해 id만 미리 받아온다. id를 손에 쥐었으니 엔티티를 1차 캐시에 등록하고 INSERT는 flush로 미룰 수 있다 → 쓰기 지연 O, 배치 O.
  • IDENTITY — id는 INSERT가 실행돼 AUTO_INCREMENT가 매겨져야 정해진다. id를 받으려면 어쩔 수 없이 persist 즉시 INSERT를 날린다 → 그 엔티티에 대한 쓰기 지연·JDBC 배치가 사실상 불가능하다.

IDENTITY는 INSERT 배치가 안 된다

hibernate.jdbc.batch_size를 켜도 IDENTITY 전략 엔티티는 INSERT가 배치되지 않는다. 대량 삽입 성능이 중요하면 SEQUENCE(또는 TABLE) 전략을 고려한다.

한 줄 직관

id를 미리 알 수 있으면(SEQUENCE) 쓰기를 미룰 수 있고, INSERT해야만 id를 알면(IDENTITY) 미룰 수 없다.


6. 영속성 컨텍스트 3요소 자료구조

영속성 컨텍스트 안은 크게 세 가지로 구성된다.

요소자료구조역할
1차 캐시Map (키 = 타입 + @Id, 값 = 엔티티)동일성 보장 · 조회 SELECT 생략
스냅샷영속 시점 필드 값 복사본flush 때 현재 값과 비교 → dirty checking
쓰기 지연 SQL 저장소INSERT/UPDATE/DELETE (Hibernate ActionQueue)flush 때 순서대로 실행

한 줄 직관

Map으로 “있나 없나”, 스냅샷으로 “뭐가 바뀌었나”, 큐로 “언제 내보낼까”를 관리한다.


7. 한눈에 정리

질문
save 후 findById가 SELECT 안 하는 이유1차 캐시(Map)에서 id로 히트
왜 “같은 트랜잭션”이어야 하나영속성 컨텍스트 = 트랜잭션과 생명주기 공유
REQUIRES_NEW로 분리하면새 EM·새 1차 캐시 → SELECT 발생, 인스턴스 분리
IDENTITY가 쓰기 지연 안 되는 이유id를 INSERT 후에야 알 수 있어 즉시 INSERT
SEQUENCE가 쓰기 지연 되는 이유id를 시퀀스로 미리 확보 → INSERT 지연·배치 가능
영속성 컨텍스트 3요소1차 캐시(Map) · 스냅샷(복사본) · 쓰기 지연 큐

한계 / 확인 필요

  • flush 시점: 커밋 직전 · JPQL 실행 전 auto-flush · 명시적 flush() — 정확한 트리거 정리
  • OSIV(Open Session In View) on/off가 영속성 컨텍스트 범위에 미치는 영향
  • merge vs persist 의 1차 캐시 동작 차이
  • 2차 캐시(L2) 와 1차 캐시의 관계

관련 노트