Spring Data JPA 3 × Hibernate 6 — 명세와 구현체로 읽는 영속성 아키텍처
JPA는 명세, Hibernate는 구현체입니다. 같은 무대를 두 이름으로 부르는 두 층을 나란히 놓고 전체 스택·런타임 구조·엔티티 생명주기·영속성 컨텍스트·flush·키 생성·N+1·2차 캐시·쿼리 계층·메서드 대응까지 다이어그램으로 정리합니다. 기준은 Spring Data JPA 3 / Hibernate 6.
개요
명세와 구현체. 같은 무대를 두 이름으로 부르는 두 층을 나란히 놓고, 어디가 표준이고 어디가 Hibernate가 얹은 것인지 구분해 읽는 글이다. 기준은 Spring Data JPA 3 / Hibernate 6 — JPA 4 / Hibernate 7은 별도로 다룬다.
아래 다이어그램에서 색은 두 층을 가른다.
- 청록(teal) — JPA: 명세 / 표준 (Jakarta Persistence)
- 앰버(amber) — Hibernate: 구현체 (ORM Provider)
전체 스택 — 어디에 무엇이 있나
세 가지는 대체 관계가 아니라 층층이 쌓이는 관계다. 호출은 위에서 아래로 흐르고, 각 층은 바로 아래 층에만 의존한다. JPA 층(청록)은 규약일 뿐 실행 코드가 없고, 실제 SQL을 만들고 던지는 일은 Hibernate 층(앰버)이 한다.
- JPA (명세) — 명세일 뿐 동작 코드가 없다. 인터페이스와 애너테이션의 규약만 정의한다. 단독으로는 아무것도 못 돌린다.
- Hibernate (구현체) — JPA 명세를 구현한 가장 널리 쓰이는 provider. 표준 인터페이스를 제공하면서 표준을 넘어서는 고유 기능도 함께 가진다.
- Spring Data JPA — JPA를 대체하지 않고 그 위에 얹는 추상화.
JpaRepository.save()는 내부에서 상태를 보고 표준 메서드를 고르는, 아래 Hibernate 메서드와는 전혀 다른 물건이다.
핵심 런타임 구조와 명칭 대응
JPA가 정의한 표준 타입은 Hibernate에서 다른 이름의 구현 타입으로 존재한다. Hibernate의 Session은 JPA의 EntityManager를 구현(확장) 한 것 — 표준 개념을 소유한 게 아니라 구현한 쪽이다.
- EntityManagerFactory (JPA) ↔ SessionFactory (Hibernate) — 앱당 하나, 무겁고 thread-safe. 부팅 시 한 번 만든다.
- EntityManager (JPA) ↔ Session (Hibernate) — 요청/트랜잭션 단위, 가볍고 thread-unsafe. 영속성 컨텍스트를 들고 있다.
짚어둘 점 — 영속성 컨텍스트는 JPA 표준 개념이다. Hibernate 고유 구조가 아니다. 1차 캐시·변경 감지·쓰기 지연·엔티티 생명주기가 전부 JPA 명세에 들어 있다. Hibernate는 이 무대를
Session내부의PersistenceContext로 구현 했을 뿐이다. Hibernate 7부터SessionFactory.createEntityManager()가 편의상Session을 반환하는 것도 Session이 EntityManager의 상위 호환이라는 신호다.
엔티티 생명주기 4상태
JPA의 거의 모든 개념이 이 상태 전이 위에 있다. save/update/merge 논의도 결국 "어떤 화살표를 타느냐"의 문제였다.
- 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의 managed 인스턴스가 이미 있으면NonUniqueObjectException. JPA 표준merge()는 상태를 복사한 새 managed 객체 를 반환하고 원본은 detached로 남겨, 이 충돌 자체가 생기지 않는다. 그래서 표준은 merge 하나로 정리됐다.
영속성 컨텍스트의 4가지 동작
"객체지향적으로 DB를 다룬다"는 말의 실체. 모두 JPA 표준 동작이며, Hibernate가 Session 내부에서 구현한다.
- 1차 캐시 — 컨텍스트 = 1차 캐시 자체. 같은 트랜잭션 내 같은 PK 조회는 DB를 다시 안 치고 캐시에서 준다.
- 변경 감지 — dirty checking. 적재 시 스냅샷을 떠두고 flush 때 비교해 바뀐 필드만 UPDATE.
set만 호출하면 끝, 별도 저장 호출이 필요 없다. - 쓰기 지연 — SQL을 즉시 안 던지고 버퍼에 모았다가 flush 때 일괄 전송. JDBC 배치와 결합해 왕복을 줄인다.
- 동일성 보장 — 같은 컨텍스트에서 같은 엔티티는
==가 성립. 캐시가 같은 인스턴스를 돌려주기 때문.
Flush 타이밍 — SQL은 언제 나가는가
"왜 이 시점에 쿼리가 나가지?"의 답. flush는 버퍼에 쌓인 변경분을 DB로 동기화하는 행위이고, 트랜잭션 커밋과는 별개다. flush ≠ commit — flush는 SQL을 보낼 뿐, 트랜잭션을 끝내지 않는다.
flush를 일으키는 세 가지
- 트랜잭션 commit — 가장 확실한 트리거. 커밋 직전 항상 flush가 일어난다.
- JPQL/Criteria 쿼리 실행 —
AUTO모드 기본 동작. 쿼리가 방금 변경한 데이터를 못 보면 결과가 틀어지므로, 쿼리 전에 관련 변경분을 먼저 내보낸다. 네이티브 SQL은 이 자동 flush 보장이 약하므로 주의. - 명시적 flush() —
em.flush()직접 호출. 배치에서 메모리 관리나 중간 ID 확보가 필요할 때.
FlushModeType — 자동 flush 범위 조절
// 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) 기준.
5가지 전략
- IDENTITY (JPA 표준) — DB의 auto_increment / IDENTITY 컬럼에 위임. MySQL
AUTO_INCREMENT, PostgreSQLSERIAL. 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이다(5는 1이었다). 이 값이 DB 왕복 횟수를 직접 좌우한다.
- allocationSize = 1 — 풀링 없음. ID가 필요할 때마다 매번
nextval()호출 → INSERT마다 DB 왕복 1회. - allocationSize = 50 (기본) — pooled 옵티마이저 동작. 시퀀스를 한 번 당겨 50개 ID를 메모리에서 소진 → DB 왕복이 1/50로 감소.
// 엔티티별 시퀀스 + 블록 선할당 (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을 권장.
N+1 문제 — 어떻게 터지는가
// ❌ 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 메서드에 바로 얹기 좋다.
권장 기본기 — 모든 연관을
LAZY로 깔아 의도치 않은 EAGER 폭발을 막고, 필요한 화면·쿼리에서만 fetch join이나 EntityGraph로 명시적으로 끌어온다. "기본은 안 가져옴, 필요할 때만 함께"가 안전한 출발점이다.
2차 캐시 — 표준이 끝나는 지점
1차 캐시는 EntityManager(트랜잭션) 범위라 트랜잭션이 끝나면 사라진다. 2차 캐시는 그 범위를 넘어 SessionFactory(애플리케이션) 전체가 공유한다. JPA는 큰 틀만 정하고 실제 구현은 provider에 맡기는 — 표준과 구현의 경계가 가장 선명한 영역.
- JPA가 정하는 것 —
@Cacheable,shared-cache-mode같은 캐시 활성화 규약. - provider가 정하는 것 (Hibernate) — 실제 캐시 저장소·만료·동시성 전략. Hibernate에서 Ehcache·Infinispan 등을 끼워 쓴다. 여기서부터는 구현체 색이 강하다.
주의 — 2차 캐시는 만능이 아니다. 변경이 잦은 엔티티엔 정합성 비용이 크고, 분산 환경에선 노드 간 동기화가 또 다른 숙제다. 읽기 위주의 안정적 데이터에 선별 적용하는 게 정석이다.
쿼리 추상화 계층
엔티티를 대상으로 질의하는 여러 방법. 표준과 서드파티가 섞여 있고, 결국 모두 SQL로 번역된다.
- JPQL (JPA) — 테이블이 아니라 엔티티를 대상으로 하는 표준 쿼리 언어.
select m from Member m where m.age > :age. - Criteria API (JPA) — 타입 안전한 동적 쿼리를 자바 코드로. 표준이지만 장황해서 실무 선호도는 낮은 편.
- Native Query (JPA) — SQL 직접 작성. DB 종속 기능이 필요할 때. 자동 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 하나로 갔나
// 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 핵심 내용을 매주 이메일로 보내드립니다.