"@GeneratedValue(strategy = SEQUENCE)는 INSERT마다 SELECT nextval 왕복이 생겨서 IDENTITY보다 느리다." 나는 오랫동안 이렇게 알고 있었다. 그런데 실제 SQL 로그를 켜보면 엔티티를 50건 저장하는 동안 시퀀스는 한 번밖에 조회되지 않는다. 왕복이 1/50로 줄어드는 이 마법이 어떻게 중복 키 없이 성립하는지, 그리고 반대로 DB 시퀀스를 INCREMENT BY 1로 만들어 두면 왜 갑자기 중복 키가 터지는지를 Hibernate 소스까지 따라가며 정리했다.
핵심 주인공은 allocationSize와 pooled 옵티마이저다. 결론부터 말하면, allocationSize는 성능 튜닝 숫자가 아니라 DB 시퀀스 정의와 반드시 일치해야 하는 계약값이다.
Hibernate의 SequenceStyleGenerator는 allocationSize(내부적으로 incrementSize) 값 하나만 보고 어떤 옵티마이저를 쓸지 결정한다. StandardOptimizerDescriptor 기준으로:
incrementSize <= 1 → NONE (NoopOptimizer, 매 호출 nextval — 진짜 왕복)
incrementSize > 1 → POOLED (기본)
여기서 놓치기 쉬운 사실 하나. Jakarta Persistence 3.2 스펙 §11.1.48에 따르면 @SequenceGenerator의 allocationSize 기본값이 50이다. 즉 아무것도 건드리지 않아도 기본 경로는 "pooled 옵티마이저 + 50개 블록"이다. IDENTITY의 매 행 왕복을 상상하며 SEQUENCE를 피했다면, 정작 기본값은 이미 50배 최적화돼 있었던 셈이다.
pooled 옵티마이저의 아이디어는 한 문장으로 요약된다.
DB 시퀀스가 돌려준 값 하나를 50개짜리 ID 블록의 경계로 재해석해서, 그 블록을 애플리케이션 메모리에서 소진할 때까지 DB를 다시 치지 않는다.
PooledOptimizer.generate()의 뼈대는 이렇다.
if (state.hiValue == null) { // 최초 1회만
state.hiValue = callback.getNextValue(); // DB nextval — 여기서만 왕복
state.value = state.hiValue - incrementSize + 1; // 블록의 하한을 역산
}
else if (state.value > state.hiValue) { // 블록을 다 썼으면
state.hiValue = callback.getNextValue(); // 다음 블록 상한을 새로 받음
state.value = state.hiValue - incrementSize + 1;
}
return state.value++; // 그 외엔 메모리에서만 증가
돌려받은 hiValue가 블록의 상한이고, 실제로 나눠주는 ID는 닫힌 구간 [hiValue - incrementSize + 1, hiValue]이다. 시퀀스가 INCREMENT BY 50으로 50, 100, 150…을 돌려준다면:
nextval=50 → ID 1..50 (50 - 50 + 1 = 1 부터, 메모리에서 소진)
nextval=100 → ID 51..100
nextval=150 → ID 101..150
DB에는 딱 3번 갔는데 150개의 ID를 발급했다. 시퀀스의 현재값(150)은 항상 "이미 발급된 마지막 ID이거나 그 위의 경계"다.
Hibernate에는 pooled-lo라는 변형도 있다(hibernate.id.optimizer.pooled.preferred=pooled-lo로 전환). PooledLoOptimizer는 돌려받은 값을 블록의 하한으로 본다.
if (state.lastSourceValue == null || state.value >= state.upperLimitValue) {
state.lastSourceValue = callback.getNextValue();
state.upperLimitValue = state.lastSourceValue + incrementSize; // 상한 = lo + 50
state.value = state.lastSourceValue; // 하한부터 발급
}
return state.value++;
두 옵티마이저의 개념 차이는 딱 하나, DB가 돌려준 값이 블록의 위 경계냐 아래 경계냐뿐이다. 시퀀스 반환값을 V, allocationSize를 N이라 하면:
| 옵티마이저 | 발급 ID 구간 | DB 값의 의미 |
|---|---|---|
| pooled | [V-N+1, V] | 블록 상한 |
| pooled-lo | [V, V+N-1] | 블록 하한 |
pooled-lo 쪽은 "DB에 적힌 값 = 이 블록에서 가장 먼저 발급될 ID"라서, 외부 시스템이 같은 시퀀스를 읽어 직접 INSERT해도 의미가 겹치지 않는다. 상호운용성이 더 낫다는 이유로 Vlad Mihalcea는 pooled-lo를 권한다고 알려져 있다.
옵티마이저는 한 번의 nextval이 곧 N개의 ID를 예약한 것이라고 가정한다. 그래서 Hibernate가 스키마를 자동 생성할 때 시퀀스를 INCREMENT BY N으로 만든다. 이 두 숫자가 어긋나면 사고가 난다.
가장 흔한 실수는 DBA가 시퀀스를 관습대로 INCREMENT BY 1로 만들어 두고, 엔티티는 기본 allocationSize=50을 그대로 쓰는 경우다. 다중 인스턴스에서:
nextval=1을 받아 ID 1..50을 쓰는 동안,nextval=2를 받아 ID 2..51을 쓴다 → 범위가 겹쳐 중복 키.하나의 nextval이 예약한다고 가정한 개수(N=50)와 DB가 실제로 건너뛰는 폭(M=1)이 다르면, 발급 구간이 오프셋만큼만 벌어져 반드시 교집합이 생긴다. 그래서 스키마 자동 생성에 맡기거나, 손으로 만든다면 반드시 맞춰야 한다.
-- allocationSize=50과 일치시켜야 안전하다
CREATE SEQUENCE order_seq START WITH 1 INCREMENT BY 50;
@Entity
class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_gen")
@SequenceGenerator(name = "order_gen", sequenceName = "order_seq",
allocationSize = 50) // ← 시퀀스 INCREMENT BY와 반드시 동일
Long id;
}
여기서 처음의 통념이 완전히 뒤집힌다. GenerationType.IDENTITY는 auto-increment 컬럼이라 INSERT가 실행돼야 비로소 PK를 안다. 그런데 Hibernate는 엔티티를 영속화하는 시점에 이미 ID가 필요하다(1차 캐시의 키). 따라서 IDENTITY는 INSERT를 뒤로 미룰 수 없고, 그 결과 JDBC 배치 INSERT가 아예 비활성화된다.
반면 SEQUENCE + pooled는 flush 이전에 메모리에서 ID를 전부 알 수 있으므로 여러 INSERT를 배치 하나로 묶을 수 있다. 정리하면:
"SEQUENCE는 왕복이 많아 느리다"는 통념과 정반대로, allocationSize가 있으면 대량 삽입에서는 SEQUENCE가 유리하다.
allocationSize는 성능 옵션이 아니라 DB 시퀀스 INCREMENT BY와 일치해야 하는 계약값이다. 키우려면 시퀀스 정의도 같이 바꿔야 한다.[V-N+1, V]), pooled-lo는 하한([V, V+N-1])으로 읽는다. 그래서 시퀀스 현재값만 보고 "지금 ID가 몇 번대"라고 단정하면 틀린다.allocationSize가 배치 INSERT까지 살려 더 빠를 수 있다.더 파고들 만한 것: 다중 인스턴스에서 pooled 블록 예약이 만드는 ID 구멍(재시작 시 미사용 블록 유실)의 허용 근거, 그리고 레거시 hilo가 왜 외부 시스템과 충돌해 pooled/pooled-lo로 대체됐는지.
org.hibernate.id.enhanced 소스 — SequenceStyleGenerator, PooledOptimizer, PooledLoOptimizer, StandardOptimizerDescriptor@SequenceGenerator