bedaily.me
Java2026년 6월 25일44분 읽기

Spring Data JPA 3와 Hibernate 6로 이해하는 JPA 영속성 아키텍처

JPA는 명세, Hibernate는 구현체입니다. 같은 무대를 두 이름으로 부르는 두 층을 나란히 놓고 전체 스택·런타임 구조·엔티티 생명주기·영속성 컨텍스트·flush·키 생성·N+1·2차 캐시·쿼리 계층·메서드 대응까지 다이어그램으로 정리합니다. 기준은 Spring Data JPA 3 / Hibernate 6.

JPAHibernateSpring Data JPA영속성ORM

개요

명세와 구현체. 같은 무대를 두 이름으로 부르는 두 층을 나란히 놓고, 어디가 표준이고 어디가 Hibernate가 얹은 것인지 구분해 읽는 글이다. 기준은 Spring Data JPA 3 / Hibernate 6 — JPA 4 / Hibernate 7은 별도로 다룬다.

아래 다이어그램에서 색은 두 층을 가른다.

  • 청록(teal) — JPA: 명세 / 표준 (Jakarta Persistence)
  • 앰버(amber) — Hibernate: 구현체 (ORM Provider)

전체 스택 — 어디에 무엇이 있나

세 가지는 대체 관계가 아니라 층층이 쌓이는 관계다. 호출은 위에서 아래로 흐르고, 각 층은 바로 아래 층에만 의존한다. JPA 층(청록)은 규약일 뿐 실행 코드가 없고, 실제 SQL을 만들고 던지는 일은 Hibernate 층(앰버)이 한다.

내 코드Repository 호출 · Service 로직Spring Data JPAJpaRepository · 메서드 쿼리 · 보일러플레이트 제거JPA (명세 · 인터페이스)EntityManager · @Entity · JPQL — 규약만 정의, 실행 코드 없음Hibernate (구현체 · Provider)Session · 실제 SQL 생성 · 1차 캐시 · dirty checking 실행JDBCDatabase규칙엔진
호출 흐름 — JPA는 규칙, Hibernate는 그 규칙을 따르는 엔진
  • JPA (명세) — 명세일 뿐 동작 코드가 없다. 인터페이스와 애너테이션의 규약만 정의한다. 단독으로는 아무것도 못 돌린다.
  • Hibernate (구현체) — JPA 명세를 구현한 가장 널리 쓰이는 provider. 표준 인터페이스를 제공하면서 표준을 넘어서는 고유 기능도 함께 가진다.
  • Spring Data JPA — JPA를 대체하지 않고 그 위에 얹는 추상화. JpaRepository.save()는 내부에서 상태를 보고 표준 메서드를 고르는, 아래 Hibernate 메서드와는 전혀 다른 물건이다.

핵심 런타임 구조와 명칭 대응

JPA가 정의한 표준 타입은 Hibernate에서 다른 이름의 구현 타입으로 존재한다. Hibernate의 Session은 JPA의 EntityManager를 구현(확장) 한 것 — 표준 개념을 소유한 게 아니라 구현한 쪽이다.

JPA 표준 (명세)Hibernate (구현)EntityManagerFactory앱당 1개 · 무겁다 · thread-safeSessionFactoryimplements EMF생성생성EntityManager요청/작업 단위 · thread-unsafeSessionextends EntityManager보유영속성 컨텍스트 (Persistence Context)JPA 표준 개념 · = 1차 캐시 · EntityManager가 들고 있는 엔티티 보관소
표준 타입 ↔ 구현 타입 — 화살표는 implements / extends

표를 읽기 전에, 이 둘이 무슨 일을 하는 물건인지부터 — 아래 속성은 전부 이 역할에서 따라 나온다.

  • Factory (EntityManagerFactory / SessionFactory) — 부팅 시 1번 생성돼 앱 종료까지 사는 단일 객체. 매핑 메타데이터·쿼리 플랜·2차 캐시 같은 공유 상태를 보유하고, 요청마다 EntityManager를 찍어낸다 → 그래서 무겁고 thread-safe.
  • Manager (EntityManager / Session) — persist·find·merge의 실제 진입점. 트랜잭션 단위로 영속성 컨텍스트(가변 상태)를 쌓는다 → 그래서 가볍고 thread-unsafe.

표준이 정한 이름과 Hibernate가 구현한 이름은 이렇게 짝지어진다.

  • EntityManagerFactory (JPA) ↔ SessionFactory (Hibernate) — 위 Factory 역할. 부팅 시 한 번 만들어 앱당 하나로 산다.
  • EntityManager (JPA) ↔ Session (Hibernate) — 위 Manager 역할. 요청/트랜잭션 단위로 영속성 컨텍스트를 들고 있다.

짚어둘 점 — 영속성 컨텍스트는 JPA 표준 개념이다. Hibernate 고유 구조가 아니다. 1차 캐시·변경 감지·쓰기 지연·엔티티 생명주기가 전부 JPA 명세에 들어 있다. Hibernate는 이 무대를 Session 내부의 PersistenceContext로 구현 했을 뿐이다. Hibernate 7부터 SessionFactory.createEntityManager()가 편의상 Session을 반환하는 것도 Session이 EntityManager의 상위 호환이라는 신호다.

엔티티 생명주기 4상태

JPA의 거의 모든 개념은 이 4상태 사이의 전이(화살표) 위에 얹혀 있다. persist·merge·remove 같은 메서드는 새 기능이 아니라 "어느 상태에서 어느 상태로 가는 화살표"에 붙은 이름일 뿐이고, save/update/merge 중 무엇을 쓸지의 문제도 결국 *"지금 내 객체가 어느 상태에 있느냐"*로 정해진다.

전이를 일으키는 메서드는 모두 앞 절의 Manager(EntityManager = Session)가 제공한다. 다이어그램을 읽기 전에, 각 메서드가 어느 상태 → 어느 상태 화살표이고 그때 무슨 일이 일어나는지 — 특히 SQL이 언제 나가는지 — 한 줄씩 짚어둔다.

  • persist(entity) — 추적 대상으로 올린다. INSERT는 이 순간이 아니라 flush 때 (단 IDENTITY 전략은 즉시) (Transient → Managed)
  • find(...) · JPQL 조회 — 즉시 SELECT해서 캐시에 적재. 단 1차 캐시에 이미 있으면 SQL 없이 그 인스턴스를 그대로 반환 (DB → Managed)
  • merge(entity) — 분리 객체의 값을 복사한 새 managed 객체를 반환. 인자로 넘긴 객체는 여전히 detached → 반드시 entity = em.merge(entity)로 받아야 한다 (Detached → Managed)
  • remove(entity) — 삭제 예정 표시. DELETE도 그 순간이 아니라 flush 때 (Managed → Removed)
  • save(entity) (Hibernate 고유) — persist와 같은 자리. 차이는 생성된 ID를 반환하려고 INSERT를 flush 전으로 앞당기는 경향. detached 객체에 부르면 새 ID로 새 행을 INSERT하는 함정 (Transient → Managed)
  • update(entity) (Hibernate 고유) — 넘긴 객체 자체를 그대로 재부착. 복사본을 안 만들어, 같은 PK의 managed 인스턴스가 이미 있으면 NonUniqueObjectException (Detached → Managed)

분리(detach 계열)는 범위가 다르다 — 하나만, 전체, 매니저 자체:

  • detach(entity) — 넘긴 엔티티 하나만 분리. 나머지는 계속 추적. 이후 필드를 바꿔도 flush 때 UPDATE 안 나감 (Managed → Detached)
  • clear() — 컨텍스트 안 전체 엔티티를 한 번에 분리(컨텍스트 비우기). EntityManager는 살아 있어 이후 다시 사용 가능. 대량 배치 루프에서 1차 캐시가 비대해질 때 중간 비우기용 (Managed → Detached)
  • close() — EntityManager 자체를 닫음. 안의 엔티티 전부 detached, 재사용 불가 (스프링은 보통 트랜잭션 종료 시 자동) (Managed → Detached)
  • 공통 — 분리되면 변경 감지에서 빠지므로, 다시 DB에 반영하려면 merge()로 되돌려야 한다.

(save·update의 함정과 merge와의 깊은 차이 — 재부착이냐 복사냐 — 는 아래 표준 vs 고유 박스에서 마저 다룬다.)

Transientnew — DB에 없음Managed컨텍스트가 추적 중Detached컨텍스트에서 분리Removed삭제 예정 · flush 때 DELETEDB영속성 컨텍스트 밖persistmergedetach · clear · closeremovepersist(flush 전)find · JPQL트랜잭션 종료 시(자동 detached)
4상태 전이 — 실선: 메서드 호출 · 점선: 암묵 전이(DB 적재 · 트랜잭션 종료)
  • Transient — new로 막 만든 객체. 식별자도 없고 컨텍스트가 모른다. persist()를 호출해야 Managed로 넘어간다.
  • Managed — persist() 또는 find()/JPQL 조회로 컨텍스트에 올라온 상태. 필드를 바꾸면 dirty checking이 잡아 flush 시 UPDATE가 자동으로 나간다. 별도 save 호출이 필요 없는 핵심 상태.
  • Detached — 트랜잭션 종료, clear(), close(), detach()로 컨텍스트에서 분리된 상태. 필드를 바꿔도 DB에 반영되지 않는다. 다시 Managed로 되돌리려면 merge()를 호출한다 — 원본이 아닌 새 managed 복사본이 반환되므로, 이후 작업은 반환된 객체로 해야 한다.
  • Removed — remove() 호출로 삭제 예정 표시. 실제 DELETE는 flush 시점에 나간다. 아직 flush 전이면 persist()로 다시 Managed로 되돌릴 수 있다.

표준 vs 고유 — 재부착의 차이 — Detached를 다시 다루는 방식이 갈린다. Hibernate 고유 update()는 넘긴 객체 자체 를 managed로 되돌린다(재부착). 복사본을 만들지 않으므로, 영속성 컨텍스트에 이미 같은 PK가 올라와 있으면 둘이 충돌한다 — 위 인트로의 NonUniqueObjectException이 바로 이 경우다. JPA 표준 merge()는 상태를 복사한 새 managed 객체 를 반환하고 원본은 detached로 남겨, 이 충돌 자체가 생기지 않는다. 그래서 표준은 merge 하나로 정리됐다.

영속성 컨텍스트의 4가지 동작

"객체지향적으로 DB를 다룬다"는 말의 실체. 모두 JPA 표준 동작이며, Hibernate가 Session 내부에서 구현한다.

영속성 컨텍스트① 1차 캐시 — id → 엔티티"Member:1"→Member(name=Lee)"Order:7"→Order(total=...)같은 PK 조회 = 같은 인스턴스 (④ 동일성 보장)② 스냅샷 → 변경 감지적재 시점 값을 스냅샷으로 보관flush 때 현재 값과 비교diff 있으면 → UPDATE 자동 생성set 메서드만으로 수정 완료, save 호출 불필요③ 쓰기 지연 (write-behind) — SQL 버퍼persist/변경/remove → INSERT·UPDATE·DELETE를 모았다가flush 시 한꺼번에 전송 →
EntityManager가 들고 있는 컨텍스트 내부 — 네 동작이 한 공간에서 맞물린다

위 그림이 네 동작이 한 공간에서 맞물리는 모습이라면, 아래는 각 동작을 한 장면씩 떼어 본 것이다.

① 1차 캐시 — 같은 PK 재조회 = DB 안 침 (성능). 컨텍스트가 곧 1차 캐시다. 같은 트랜잭션 안에서 같은 PK를 다시 조회하면 DB를 치지 않고 캐시에서 바로 준다. 내부 자료구조는 Map<식별자(PK), 엔티티> — 키가 PK뿐이다 (다이어그램의 "Member:1" → Member가 곧 이 키 구조). 그래서 PK가 아닌 키로 조회하면(JPQL·unique 컬럼 등) 캐시를 못 타 SELECT는 그대로 나간다. 단, 돌아온 행의 PK가 이미 캐시에 있으면 그 인스턴스를 재사용한다 — ①(DB 절약)은 놓쳐도 ④(동일성)는 지키는 지점이다.

애플리케이션영속성 컨텍스트 · 1차 캐시"Member:1"→Member처음 조회한 PK가 여기 적재된다DB① find(1) 첫 호출SELECT② find(1) 재호출DB 미접근 ✗
① 1차 캐시 — 첫 조회만 DB로, 재조회는 캐시 히트

② 변경 감지 (dirty checking) — 스냅샷 vs 현재 비교로 set만 해도 UPDATE 자동 (편의). 적재 시점 값을 스냅샷으로 떠두고, flush 때 현재 값과 비교해 바뀐 필드의 UPDATE를 만든다. 별도 저장 호출이 없다.

그 스냅샷의 정체 — 컨텍스트는 엔티티마다 EntityEntry를 들고, 그 안의 loadedState(Object[])에 최초 적재 시점 값을 보관한다 → flush 때 현재 값과 이 배열을 비교해 다르면 dirty로 판별한다.

스냅샷 · 로드 시점name = "Lee"현재 값 · set 이후name = "Kim"flush 시 비교diff 검출UPDATE 자동 생성save() 호출 없이member.setName("Kim") 한 줄로 끝 — 명시적 save() 호출이 없다
② 변경 감지 — flush 때 스냅샷과 현재 값을 비교해 UPDATE 생성

③ 쓰기 지연 (write-behind) — SQL을 모았다 flush 때 일괄 전송 (왕복 절감). persist·변경·remove를 즉시 던지지 않고 SQL 버퍼에 쌓아뒀다가, flush 시점에 한꺼번에 내보낸다. JDBC 배치와 묶여 왕복이 준다.

SQL 버퍼 · 쓰기 지연INSERT into orders ...UPDATE member set ...DELETE from cart ...DBflush: JDBC 배치로한꺼번에 전송persist·변경·remove는 즉시 안 나가고 flush 때 한 번에
③ 쓰기 지연 — SQL을 버퍼에 모았다 flush 때 일괄 전송

④ 동일성 보장 — 같은 인스턴스를 돌려줘 == 성립 (①의 부산물). 같은 컨텍스트에서 같은 PK를 두 번 조회하면 캐시가 같은 인스턴스를 돌려줘 a == b가 성립한다. ①의 직접적인 귀결이다.

a = find(1)b = find(1)Member@7f3a1차 캐시 안에 단 1개a == b → true① 1차 캐시의 귀결 — 캐시가 같은 참조를 돌려준다
④ 동일성 보장 — 같은 PK 조회는 같은 인스턴스 참조를 반환

네 동작은 따로 노는 게 아니라 두 축으로 엮인다. ①과 ④는 같은 캐시 메커니즘의 두 얼굴이다 — 하나는 DB 절약, 하나는 인스턴스 동일성. ②와 ③은 flush 시점에 맞물리는 한 쌍이다 — 무엇을 보낼지 정하는 게 ②, 언제 묶어 보낼지가 ③.

Flush 타이밍 — SQL은 언제 나가는가

"왜 이 시점에 쿼리가 나가지?"의 답. flush는 버퍼에 쌓인 변경분을 DB로 동기화하는 행위이고, 트랜잭션 커밋과는 별개다. flush ≠ commit — flush는 SQL을 보낼 뿐, 트랜잭션을 끝내지 않는다.

트랜잭션 시작commitpersist(member)SQL 없음버퍼에 INSERT 적재JPQL 쿼리 실행← AUTO 플러시INSERT 먼저 전송(쿼리 정합성 위해)commit← 마지막 플러시남은 변경분 전송+ DB commit
FlushModeType.AUTO 기준 — persist는 버퍼링, 쿼리·커밋이 트리거

flush를 일으키는 세 가지

  • 트랜잭션 commit — 가장 확실한 트리거. 커밋 직전 항상 flush가 일어난다.
  • JPQL/Criteria 쿼리 실행 — AUTO 모드 기본 동작. 쿼리가 방금 변경한 데이터를 못 보면 결과가 틀어지므로, 쿼리 전에 관련 변경분을 먼저 내보낸다. 네이티브 SQL은 이 자동 flush 보장이 약하므로 주의.
  • 명시적 flush() — em.flush() 직접 호출. 배치에서 메모리 관리나 중간 ID 확보가 필요할 때.

FlushModeType — 자동 flush 범위 조절

📄FlushMode.java
// AUTO (기본) — 커밋 + 쿼리 실행 시 flush
// COMMIT      — 커밋 시에만 flush, 쿼리 실행으로는 안 함
em.setFlushMode(FlushModeType.COMMIT);

// 흔한 함정: persist 후 같은 트랜잭션에서 JPQL 조회
em.persist(member);                           // 버퍼에만
List<Member> list = em.createQuery(           // AUTO면 여기서 자동 flush →
    "select m from Member m", Member.class)   // 방금 persist한 member도 보인다
    .getResultList();
// 반면 find(id)는 1차 캐시에서 바로 꺼내므로 flush와 무관

실무 주의 — flush가 트랜잭션을 커밋하는 게 아니다. flush 후에도 롤백하면 DB는 원복된다. 또 FlushModeType.COMMIT으로 바꾸면 쿼리 직전 자동 flush가 사라져, 방금 변경한 데이터가 같은 트랜잭션 쿼리에 안 잡힐 수 있다 — 성능 최적화로 쓰되 정합성 영향을 반드시 검토할 것.

키 생성 전략 — ID는 언제 확보되는가

앞 절의 원칙은 "persist는 SQL을 flush까지 지연한다"였다. 그런데 IDENTITY 전략은 이 원칙을 깨는 유일한 예외다. 각 전략을 가르는 본질은 "ID를 INSERT 전에 받느냐, INSERT를 해야 받느냐" — 이게 곧 쓰기 지연과 배치 INSERT 가능 여부를 결정한다. 아래는 Spring Boot 3 (Hibernate 6) 기준.

SEQUENCE — ID를 먼저 받는다persist()시퀀스에서 ID 확보(블록 선할당 가능)1차 캐시 적재INSERT는 버퍼로→ flush 때배치 INSERT ✓IDENTITY — INSERT를 해야 ID가 나온다 (예외)persist()★ 즉시 INSERT 실행생성된 ID를 회수해야 하므로ID로 캐시 적재→ 쓰기 지연 ✗배치 INSERT ✗
SEQUENCE는 쓰기 지연·배치 유지, IDENTITY는 persist 즉시 INSERT

5가지 전략

  • IDENTITY (JPA 표준) — DB의 auto_increment / IDENTITY 컬럼에 위임. MySQL AUTO_INCREMENT, PostgreSQL SERIAL. INSERT를 해야 PK가 나오므로 persist 즉시 INSERT가 나가고 JDBC 배치가 막힌다.
  • SEQUENCE (JPA 표준) — DB 시퀀스 객체에서 INSERT 전에 값을 받아온다. Oracle·PostgreSQL 등. 쓰기 지연과 배치 INSERT가 그대로 살아 있어 대량 쓰기에 유리. allocationSize로 블록 선할당 가능.
  • TABLE (JPA 표준) — 별도 키 관리 테이블로 시퀀스를 흉내. 모든 DB에서 동작하지만 테이블 조회·락 오버헤드로 가장 느리다. 사실상 비권장.
  • AUTO (JPA 표준) — JPA 기본값. provider가 dialect를 보고 결정 — Hibernate 6에선 시퀀스 지원 DB면 SEQUENCE를 고른다. 운영에선 명시적 지정이 더 예측 가능하다.
  • UUID (Hibernate) — Hibernate 6부터 @UuidGenerator로 표준화. DB 왕복 없이 애플리케이션에서 PK 생성 — 분산 환경에 적합. 단 랜덤 UUID의 인덱스 단편화는 별도 고려(정렬 가능한 UUIDv7 등).

전략별 비교 — ID 확보 시점이 모든 걸 가른다

전략ID 확보 시점persist 시 SQL배치 INSERT
IDENTITY (JPA)INSERT 직후 (DB가 매김)즉시 INSERT불가
SEQUENCE (JPA)INSERT 전 (시퀀스 조회)버퍼링 (지연)가능
TABLE (JPA)INSERT 전 (테이블 조회)버퍼링 (지연)가능 (느림)
UUID (Hibernate)persist 시 앱에서 생성버퍼링 (지연)가능

allocationSize — SEQUENCE 성능의 핵심

Hibernate 6은 엔티티별 시퀀스를 기본으로 만들고, JPA 표준에 따라 allocationSize 기본값이 50이다(JPA 명세상 2.0부터 쭉 50 — Hibernate 4 이전의 hilo 옵티마이저가 사실상 1처럼 동작했던 것과 혼동하지 말 것). 이 값이 DB 왕복 횟수를 직접 좌우한다.

  • allocationSize = 1 — 풀링 없음. ID가 필요할 때마다 매번 nextval() 호출 → INSERT마다 DB 왕복 1회.
  • allocationSize = 50 (기본) — pooled 옵티마이저 동작. 시퀀스를 한 번 당겨 50개 ID를 메모리에서 소진 → DB 왕복이 1/50로 감소.
📄Member.java
// 엔티티별 시퀀스 + 블록 선할당 (Hibernate 6 기본 방향)
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq")
@SequenceGenerator(name = "member_seq", sequenceName = "member_seq",
                   allocationSize = 50)
private Long id;

주의 — 옵티마이저 정합 — pooled 옵티마이저는 DB 시퀀스의 increment가 엔티티 allocationSize와 같아야 정상 동작한다. 어긋나면 ID 갭이나 충돌이 생긴다. Hibernate가 DDL을 생성하면 자동으로 맞지만, 시퀀스를 수동 생성했거나 import.sql로 초기 데이터를 넣을 때는 직접 맞춰야 한다.

앞 절과의 연결 — 이것이 flush 원칙의 예외 지점이다. SEQUENCE·TABLE·UUID는 ID를 INSERT 전에 확보하므로 persist가 SQL을 버퍼링하지만, IDENTITY만은 ID 회수를 위해 persist 즉시 INSERT를 던진다. 대량 INSERT가 잦은 테이블이라면 이 차이가 곧 성능 차이다.

Fetch 전략과 N+1

연관 엔티티를 언제 로딩하느냐의 문제. 기본 전략을 잘못 잡거나 LAZY를 무심코 순회하면 쿼리가 폭발한다. JPA 실무에서 가장 자주 마주치는 성능 함정.

LAZY vs EAGER

  • LAZY (지연) — 연관 엔티티를 프록시로 두고, 실제 접근하는 순간 쿼리. @OneToMany·@ManyToMany의 기본값.
  • EAGER (즉시) — 엔티티 조회 시 연관까지 함께 로딩. @ManyToOne·@OneToOne의 기본값. 실무에선 모두 LAZY로 두고 필요할 때 fetch join을 권장.

LAZY의 내부 구현 — 프록시와 컬렉션 래퍼

"실제 접근하는 순간 쿼리"는 어떻게 가능한가? Hibernate는 프록시 패턴으로 이를 구현한다. 엔티티 단건(@ManyToOne)에는 ByteBuddy로 생성한 프록시 서브클래스를, 컬렉션(@OneToMany)에는 PersistentCollection 래퍼를 끼운다. 두 경우 모두 "접근 시점에 가로채서 초기화"라는 같은 원리다.

단건 연관 — 프록시 서브클래스

@ManyToOne(fetch = LAZY)로 선언된 필드를 로딩하면 Hibernate는 실제 엔티티가 아니라 해당 클래스를 상속한 프록시 객체를 채워 넣는다. 이 프록시는 PK만 들고 있고, 나머지 필드는 비어 있다. getter를 호출하는 순간 인터셉터가 작동해 DB를 조회하고 실제 값을 채운다.

Team (실제 엔티티)- id: Long- name: String+ getName(): StringTeam$HibernateProxy (프록시)- target: Team = null (미초기화)- interceptor: LazyInitializer+ getName(): → 인터셉터가 가로챔 초기화 안 됐으면 → SELECT → target 채움extendsByteBuddy (런타임)엔티티 클래스를 상속한프록시 서브클래스를 바이트코드로동적 생성 (cglib/javassist 대체)생성
프록시 구조 — ByteBuddy가 엔티티를 상속한 서브클래스를 런타임에 생성

프록시 초기화 흐름

프록시의 메서드가 호출되면 내부의 LazyInitializer가 초기화 여부를 확인한다. 아직 초기화되지 않았으면 Session을 통해 DB를 조회하고, 실제 엔티티 인스턴스(target)를 채운 뒤 결과를 위임한다. Session이 닫힌 뒤 접근하면 LazyInitializationException이 터지는 이유가 바로 이 구조에 있다.

team.getName()LazyInitializerinitialized == false?YesSession 열려 있나?YesSELECT 실행target 채움initialized = trueNoLazyInitializationException이미 초기화target에 바로 위임DB 조회 없음
프록시 접근 시 초기화 흐름 — Session이 살아 있어야 DB 조회 가능

컬렉션 연관 — PersistentCollection 래퍼

@OneToMany 같은 컬렉션 필드는 프록시 서브클래스가 아니라 Hibernate의 컬렉션 래퍼로 감싸진다. List는 PersistentBag, Set은 PersistentSet으로 교체되며, 원리는 같다 — .size(), .iterator() 등 접근 시점에 SELECT가 나간다.

Team 엔티티@OneToMany(fetch = LAZY)List<Member> members실제 타입PersistentBagimplements List (java.util)- initialized: false- session: SessionImpl 참조.size() / .get() / .iterator()초기화 트리거SELECT * FROM memberWHERE team_id = ?DB
컬렉션 래퍼 — 자바 컬렉션을 Hibernate가 래핑하여 접근을 가로챈다
📄LazyProxy.java
// 프록시 확인 — 초기화 전엔 프록시 타입
Team team = member.getTeam();           // Team$HibernateProxy 반환
System.out.println(team.getClass());    // class Team$HibernateProxy$xxx

// getId()는 프록시가 PK를 이미 갖고 있어 초기화 안 됨
team.getId();                           // SELECT 없음

// getName()은 실제 데이터 접근 → 이 시점에 SELECT 발생
team.getName();                         // SELECT * FROM team WHERE id=?

// 컬렉션도 마찬가지 — 접근 시점에 초기화
team.getMembers().size();               // SELECT * FROM member WHERE team_id=?

// 초기화 여부 확인 유틸
Hibernate.isInitialized(team);          // true/false
Hibernate.initialize(team);            // 명시적 강제 초기화

왜 이 구조를 알아야 하나 — LAZY가 프록시라는 걸 모르면 세 가지 함정에 빠진다. ① instanceof 검사가 프록시 타입에 걸려 실패, ② 트랜잭션 밖에서 프록시 접근 시 LazyInitializationException, ③ 프록시를 루프에서 건드리면 N+1. 아래 N+1 절은 ③의 구체적 사례다.

OSIV — 컨트롤러에서 LAZY가 되는 이유

"트랜잭션이 서비스에서 끝났는데 왜 컨트롤러에서 LAZY 접근이 되지?" — Spring Boot는 **OSIV(Open Session In View)**가 기본 활성화(spring.jpa.open-in-view=true)되어 있기 때문이다. OSIV는 HTTP 요청의 시작부터 응답까지 Session을 열어두므로 컨트롤러·뷰에서도 프록시 초기화가 가능하다.

편리하지만 대가가 있다. Session이 요청 전체에 걸쳐 살아 있으면 DB 커넥션도 그만큼 오래 잡힌다. 트래픽이 늘면 커넥션 풀 고갈로 이어진다. 또 어디서든 LAZY 접근이 되니 N+1이 뷰 레이어까지 퍼져도 눈에 안 띈다.

📄application.yml
# 권장 — OSIV 끄기
spring:
  jpa:
    open-in-view: false   # 서비스 계층 밖에서 프록시 접근 시 즉시 예외
                           # → LAZY 접근 지점을 서비스 안에서 명시적으로 관리하게 된다

실무 권장 — OSIV를 끄고, 필요한 연관은 서비스 계층에서 fetch join·EntityGraph·DTO 프로젝션으로 미리 해결한다. "어디서든 접근 가능"보다 "어디서 접근하는지 명확한" 코드가 더 안전하다.

N+1 문제 — 어떻게 터지는가

문제: LAZY를 루프에서 순회[1] SELECT * FROM team → 팀 N개[2] SELECT * FROM member WHERE team_id=1[3] SELECT * FROM member WHERE team_id=2[4] ... team_id=3… 팀마다 1번씩 → 총 1 + N 쿼리team.getMembers() 접근 시마다프록시가 새 쿼리를 던진다해법: fetch join — 1 쿼리[1] SELECT t, m FROM Team t JOIN FETCH t.members한 번의 조인으로 팀+멤버를함께 적재 → 추가 쿼리 0대안: @BatchSize / default_batch_fetch_size→ IN 절로 묶어 1 + 1 쿼리로 축소대안: @EntityGraph 로 페치 경로 지정
1번 + N번 — 부모 1쿼리 뒤 자식마다 1쿼리씩 추가로 터진다
📄NPlusOne.java
// ❌ N+1 발생 — members가 LAZY일 때 루프에서 초기화
List<Team> teams = em.createQuery("select t from Team t", Team.class)
                       .getResultList();           // 쿼리 1번
for (Team t : teams) {
    t.getMembers().size();                          // 팀마다 쿼리 1번씩 → N번
}

// ✅ fetch join — 한 방에
List<Team> teams = em.createQuery(
    "select distinct t from Team t join fetch t.members", Team.class)
    .getResultList();                               // 쿼리 1번으로 끝

해법 정리

  • fetch join — JPQL join fetch로 연관을 한 쿼리에 적재. 가장 직접적. 단, 컬렉션 fetch join은 페이징과 충돌(메모리 페이징 경고)하니 주의.
  • @BatchSize (Hibernate) — LAZY 로딩을 IN (?, ?, …)로 묶어 N번을 1번으로. 전역 설정은 default_batch_fetch_size.
  • @EntityGraph (JPA 표준) — 어떤 연관을 함께 EAGER로 가져올지 그래프로 선언. Spring Data 메서드에 바로 얹기 좋다.
📄EntityGraphExample.java
// ✅ @EntityGraph — Spring Data 메서드에 바로 얹는 JPA 표준 방식
public interface TeamRepository extends JpaRepository<Team, Long> {

    @EntityGraph(attributePaths = {"members"})   // members를 함께 EAGER로 적재
    List<Team> findAll();                        // 쿼리 1번 — 내부적으로 LEFT JOIN

    @EntityGraph(attributePaths = {"members", "members.department"})
    Optional<Team> findById(Long id);            // 2단계 연관까지 한 번에
}

// ✅ @BatchSize — 전역 설정으로 모든 LAZY 컬렉션에 일괄 적용
// application.yml:
//   spring.jpa.properties.hibernate.default_batch_fetch_size: 100
//
// 또는 엔티티별로 직접 지정:
@Entity
public class Team {
    @BatchSize(size = 100)                       // 이 컬렉션만 IN 절로 묶음
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members;
}
// → 팀 N개 조회 후 members 접근 시: WHERE team_id IN (1,2,3,...,100) 1번으로 해결

권장 기본기 — 모든 연관을 LAZY로 깔아 의도치 않은 EAGER 폭발을 막고, 필요한 화면·쿼리에서만 fetch join이나 EntityGraph로 명시적으로 끌어온다. "기본은 안 가져옴, 필요할 때만 함께"가 안전한 출발점이다.

2차 캐시 — 표준이 끝나는 지점

2차 캐시란 무엇인가

앞 절에서 본 1차 캐시(영속성 컨텍스트)는 트랜잭션 하나의 생애와 함께한다. 트랜잭션이 끝나면 그 안에 쌓인 캐시도 전부 사라진다. 같은 엔티티를 다른 트랜잭션에서 다시 조회하면 — 아무리 1초 전에 읽었어도 — DB로 SELECT가 나간다.

2차 캐시(L2 Cache)는 이 한계를 넘는다. 애플리케이션 전체(SessionFactory)가 공유하는 캐시다. 한 트랜잭션이 읽은 엔티티를 2차 캐시에 올려두면, 다른 트랜잭션이 같은 PK를 조회할 때 DB를 치지 않고 캐시에서 꺼낸다. 변경이 드물고 읽기가 잦은 데이터 — 코드 테이블, 카테고리, 설정 값 같은 — 에 효과가 크다.

② 2차 캐시 (L2) — SessionFactory 범위 · 모든 트랜잭션 공유@Cacheable / shared-cache-mode 는 JPA 표준, 실제 저장소는 provider 영역 →Ehcache · Infinispan · RedisEntityManager A① 1차 캐시 (트랜잭션 범위)EntityManager B① 1차 캐시 (트랜잭션 범위)L1 미스 → L2 조회 → DB
L1은 트랜잭션마다 따로, L2는 앱 전체가 하나를 공유

표준과 구현의 경계

JPA는 2차 캐시의 활성화 규약만 정한다. 실제로 데이터를 어디에 어떻게 저장할지는 전부 provider 영역이다.

  • JPA가 정하는 것 — @Cacheable 애너테이션, shared-cache-mode(어떤 엔티티를 캐시할지), Cache API(수동 evict). 여기까지가 표준.
  • provider가 정하는 것 (Hibernate) — 캐시 저장소(Ehcache·Caffeine·Infinispan), 만료 정책(TTL·max-size), 동시성 전략(READ_ONLY·READ_WRITE·NONSTRICT_READ_WRITE·TRANSACTIONAL). 이 설정들은 전부 Hibernate 고유 애너테이션이나 프로퍼티로 들어간다.

내부 조회 흐름 — L1 → L2 → DB

em.find(Member.class, 1L)을 호출하면 Hibernate는 세 단계를 순서대로 탐색한다. 2차 캐시에 저장되는 것은 엔티티 객체가 아니라 분해된 상태값(dehydrated state) — {id=1, name="Lee", age=28} 같은 배열 형태다. 꺼낼 때 이 값으로 새 엔티티 인스턴스를 조립(hydrate)해서 1차 캐시에 넣는다.

find(Member, 1L)① L1 캐시1차 캐시 (트랜잭션)HIT즉시 반환MISS② L2 캐시dehydrated stateHIThydrate → 엔티티 조립새 인스턴스를 L1에 적재DB 왕복 없음MISS③ DBSELECT 실행결과를 양쪽에 적재→ L2에 dehydrated 저장→ L1에 엔티티 적재다음 트랜잭션은 L2에서 바로 꺼냄
find() 호출 시 조회 경로 — L1 히트가 가장 빠르고, L2가 DB 왕복을 막는다

왜 엔티티 객체를 직접 저장하지 않는가 — 2차 캐시에 managed 엔티티를 그대로 넣으면 여러 트랜잭션이 같은 인스턴스를 공유하게 된다. 한쪽이 필드를 바꾸면 다른 쪽도 영향받아 dirty checking이 깨지고, 트랜잭션 격리가 무너진다. 그래서 Hibernate는 **분해된 스칼라 배열(dehydrated state)**로 저장하고, 꺼낼 때마다 새 인스턴스를 조립한다.

설정과 코드 예제

2차 캐시를 켜려면 세 가지가 필요하다: ① 캐시 라이브러리 의존성, ② 프로퍼티 활성화, ③ 엔티티에 캐시 애너테이션.

📄Category.java
// ③ 엔티티에 캐시 선언
@Entity
@Cacheable                                       // JPA 표준 — "이 엔티티는 2차 캐시 대상"
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE,
       region = "categoryCache")                 // Hibernate 고유 — 동시성 전략·리전 지정
public class Category {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    private String name;

    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)  // 컬렉션도 별도로 캐시 선언
    @OneToMany(mappedBy = "category", fetch = FetchType.LAZY)
    private List<Product> products = new ArrayList<>();
}
📄application.yml
# ① 의존성: org.hibernate.orm:hibernate-jcache + org.ehcache:ehcache (또는 Caffeine)
# ② 프로퍼티 활성화
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true        # 2차 캐시 켜기
          use_query_cache: true               # 쿼리 결과 캐시 (선택)
          region.factory_class: jcache        # Hibernate 6 — JCache 표준 팩토리
      jakarta:
        persistence:
          sharedCache.mode: ENABLE_SELECTIVE  # @Cacheable 붙인 엔티티만 캐시
📄L2CacheVerify.java
// 트랜잭션 A — DB에서 읽고 L2에 올린다
Category cat = em.find(Category.class, 1L);   // SELECT 실행, L2에 저장

// 트랜잭션 B — 같은 PK를 다시 읽으면 L2 히트
Category cat2 = em.find(Category.class, 1L);  // SELECT 없음 — L2에서 꺼냄

// 쿼리 캐시 — JPQL 결과도 캐시 가능 (쿼리+파라미터를 키로)
List<Category> list = em.createQuery(
        "select c from Category c where c.name = :name", Category.class)
    .setParameter("name", "Electronics")
    .setHint("org.hibernate.cacheable", true)  // 이 쿼리 결과를 캐시
    .getResultList();

// 수동 evict — 데이터가 변경됐을 때 캐시를 직접 무효화
em.getEntityManagerFactory().getCache()
    .evict(Category.class, 1L);               // JPA 표준 API

동시성 전략 — 4가지 선택지

2차 캐시의 핵심 설정. 읽기·쓰기 빈도에 따라 어떤 전략을 고르느냐가 정합성과 성능의 균형을 결정한다.

빠름 · 단순느림 · 정합성 강함READ_ONLY절대 안 바뀌는 데이터코드 테이블, enum 매핑변경 시도 → 예외NONSTRICT_RW가끔 바뀌는 데이터락 없음, 짧은 불일치 허용commit 후 캐시 갱신READ_WRITE읽기 많고 쓰기도 있음소프트 락으로 동시성 제어가장 많이 쓰는 전략TRANSACTIONAL완전한 트랜잭션 보장JTA + XA 필요설정 복잡, 오버헤드 큼실무 기본 선택:• 절대 안 바뀜 → READ_ONLY (가장 빠름, 걱정 없음)• 바뀌긴 하는데 읽기가 압도적 → READ_WRITE (대부분 여기)• 순간 불일치 괜찮음 → NONSTRICT_READ_WRITE (더 빠름)
동시성 전략별 특성 — 왼쪽이 빠르고 오른쪽이 안전하다

쿼리 캐시 — 엔티티 캐시와 다른 물건

2차 캐시는 사실 두 층이다. 엔티티 캐시는 PK → 상태값을 저장하고, 쿼리 캐시는 JPQL + 파라미터 → PK 목록을 저장한다. 쿼리 캐시가 히트하면 PK 목록을 꺼내고, 각 PK를 엔티티 캐시에서 찾는다 — 둘이 함께 켜져야 효과가 있다.

JPQL 쿼리 실행cacheable = true쿼리 캐시key: JPQL + params 해시val: [1L, 3L, 7L] (PK 목록)엔티티 데이터는 없음각 PK로엔티티 캐시3개 엔티티 조립 완료DB SELECT 0회
쿼리 캐시는 PK 목록만 저장, 실제 데이터는 엔티티 캐시에서 조립

주의 — 쿼리 캐시의 함정 — 쿼리 캐시는 해당 테이블에 어떤 변경이든 일어나면 통째로 무효화된다. Category 테이블에 INSERT 1건이 들어가면 그 테이블을 대상으로 한 모든 쿼리 캐시가 날아간다. 변경이 잦은 테이블에 쿼리 캐시를 걸면 히트율이 0에 가까워져 오히려 오버헤드만 늘어난다.

멀티 인스턴스 환경 — 2차 캐시는 아직 유효한가

컨테이너 오케스트레이션(Kubernetes)과 수평 확장이 기본인 요즘, 2차 캐시의 입지가 예전 같지 않다. JVM 로컬 캐시(Ehcache·Caffeine)는 인스턴스마다 독립이므로, 인스턴스 A가 데이터를 바꿔도 인스턴스 B의 캐시는 갱신되지 않는다. 이 불일치가 실무에서 가장 많이 물리는 지점이다.

인스턴스 A (Pod 1)L2: Category#1 = "전자기기"UPDATE → "가전제품"캐시 갱신 ✓인스턴스 B (Pod 2)L2: Category#1 = "전자기기" ← 옛날 값!변경 사실을 모른다캐시 갱신 ✗ · 불일치동기화 경로 없음DBDB는 "가전제품"이 정답인데B는 캐시에서 "전자기기"를 계속 꺼낸다
멀티 인스턴스에서 로컬 L2 캐시의 정합성 문제

현실적 선택지

방식동작장단점
로컬 캐시 + 짧은 TTLCaffeine·Ehcache로 TTL 30초~5분간단하지만 TTL 동안 불일치 허용 필요
분산 캐시 (Redis·Infinispan)모든 인스턴스가 하나의 캐시 공유정합성 해결, 대신 네트워크 홉 추가
Pub/Sub 무효화변경 시 Redis Pub/Sub·Kafka로 evict 전파정합성 좋지만 인프라 복잡도 증가
2차 캐시 안 쓰기Spring Cache + Redis로 서비스 레이어에서 직접가장 많은 팀이 택하는 방향
📄ServiceLayerCache.java
// 현대적 대안 — JPA 2차 캐시 대신 서비스 레이어에서 Spring Cache 사용
@Service
public class CategoryService {

    @Cacheable(value = "categories", key = "#id")   // Spring Cache → Redis
    public CategoryDto findById(Long id) {
        return categoryRepository.findById(id)
            .map(CategoryDto::from)
            .orElseThrow();
    }

    @CacheEvict(value = "categories", key = "#id")  // 변경 시 명시적 무효화
    public void update(Long id, CategoryUpdateRequest req) {
        Category cat = categoryRepository.findById(id).orElseThrow();
        cat.update(req);
    }
}

현실적 판단 — JPA 2차 캐시가 빛나는 장면은 여전히 있다: 단일 인스턴스, 또는 변경이 거의 없는 코드성 데이터(국가 코드, 권한 테이블, 카테고리 트리). 이런 데이터는 READ_ONLY 전략으로 올려두면 DB 부하를 눈에 띄게 줄인다. 그러나 멀티 인스턴스에서 변경이 잦은 비즈니스 데이터까지 JPA 2차 캐시로 커버하려 하면 — 정합성 관리 비용이 성능 이득을 빠르게 넘어선다. 많은 팀이 JPA 2차 캐시는 끄고, 서비스 레이어에서 Spring Cache(@Cacheable) + Redis로 캐시 경계를 직접 제어하는 방향을 택하는 이유다.

쿼리 추상화 계층

엔티티를 대상으로 질의하는 여러 방법. 표준과 서드파티가 섞여 있고, 결국 모두 SQL로 번역된다.

JPQLJPA 표준 · 엔티티 대상Criteria APIJPA 표준 · 타입 안전@NamedQueryJPA 표준 · 정적 검증QueryDSL서드파티 · 타입 안전Hibernate 쿼리 엔진AST 파싱 → SQL 생성SQLNative QueryJPA 표준 · SQL 직접Hibernate를 거치지 않음JDBC → DB
어떤 방식을 쓰든 최종 목적지는 SQL — 차이는 개발자가 쿼리를 표현하는 방식
  • JPQL (JPA) — 테이블이 아니라 엔티티를 대상으로 하는 표준 쿼리 언어. select m from Member m where m.age > :age.
  • Criteria API (JPA) — 타입 안전한 동적 쿼리를 자바 코드로. 표준이지만 장황해서 실무 선호도는 낮은 편.
  • Native Query (JPA) — SQL 직접 작성. DB 종속 기능이 필요할 때. Hibernate 쿼리 엔진을 거치지 않으므로 자동 flush 보장이 약한 점은 flush 절 참고.
  • @NamedQuery (JPA) — 이름 붙인 정적 쿼리. 부팅 시 문법 검증되는 이점.
  • QueryDSL (서드파티) — JPA 표준이 아닌 서드파티. 타입 안전한 동적 쿼리를 Criteria보다 읽기 쉽게. 내부적으로는 JPQL로 번역돼 결국 표준 위에서 돈다.

메서드 대응표 — 표준 vs 고유

Hibernate가 JPA보다 먼저 나왔기에 고유 메서드가 먼저 있었고, 나중에 JPA 표준 메서드를 추가로 얹었다. 두 계열이 오래 공존했고, Hibernate 6에서는 고유 계열이 deprecated로 표시돼 있다(동작은 그대로, 경고만). 실제 제거는 Hibernate 7부터다.

Hibernate 고유 (6: deprecated)JPA 표준 (권장 대체)핵심 차이
session.save()persist()save는 ID 즉시 반환 위해 INSERT를 앞당기는 경향, persist는 flush까지 지연
session.update()merge()update는 원본을 재부착(PK 충돌 위험), merge는 복사본 반환
session.saveOrUpdate()persist() / merge()신규·기존 자동 분기 → 표준은 상황에 맞게 둘 중 선택
session.delete()remove()명칭 통일
session.get()find()즉시 SELECT, 없으면 null — 동작 동일, 표준 명칭으로
session.load()getReference()DB 조회 없이 프록시(참조)만 — 가장 실용적이던 용도, 그대로 계승

대체 매핑(save→persist, update→merge, get→find, load→getReference)과 실제 "제거"는 Hibernate 7 기준이며 별도로 다룬다. 위 표는 6 기준 deprecated 상태로 읽을 것.

왜 표준은 merge 하나로 갔나

📄MergeVsUpdate.java
// Hibernate update() — 넘긴 객체 그 자체가 managed로 (재부착)
session.update(detached);   // 같은 PK managed 있으면 NonUniqueObjectException

// JPA merge() — 상태를 복사한 새 managed 객체를 반환
Member managed = em.merge(detached);  // 원본은 detached 유지, 충돌 자체가 없음

Spring Data JPA의 save는 다른 물건 — JpaRepository.save()는 위 Session.save()와 이름만 같다. Spring Data가 엔티티 상태(새 것인지 여부)를 보고 내부에서 persist 또는 merge를 고르는 추상화다. 그래서 나중에 Hibernate 7로 올라가 고유 메서드가 제거돼도 repository 코드는 영향받지 않는다 — 영향받는 건 EntityManager.unwrap(Session.class)로 Session을 직접 꺼내 고유 메서드를 부르는 레거시 코드뿐이다.

주간 기술 뉴스레터

Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.