remove 다음에 persist 했는데 INSERT가 먼저 나갔다 — Hibernate flush가 호출 순서를 버리는 이유

seonwoo_jung·약 10시간 전

1. remove 다음 줄에 persist 했는데, 로그엔 INSERT가 먼저 찍혀 있었다

UNIQUE 제약이 걸린 slug 컬럼을 가진 엔티티가 있었다. 옛 row를 지우고 같은 slug로 새 row를 넣으려고 이렇게 썼다.

em.remove(old);      // slug = "hello" 인 row 삭제
em.persist(fresh);   // 같은 slug = "hello" 로 새 row

코드 순서는 분명히 삭제가 먼저다. 그런데 커밋 순간 터진 예외는 SQLIntegrityConstraintViolationException: unique constraint SLUG_UQ. 로그의 SQL 순서를 보니 INSERT가 DELETE보다 먼저 나가 있었다. "내가 부른 순서대로 SQL이 나간다"는 가정이 틀린 것이다. 왜 Hibernate가 내 호출 순서를 무시하는지, 무슨 규칙으로 재배열하는지 소스와 문서를 따라가 봤다.

2. 핵심 개념 — persist는 SQL이 아니라 "액션"을 만든다

한 문장으로 요약하면 이렇다.

persist/merge/remove는 SQL을 즉시 실행하지 않고 세션의 ActionQueue타입별 액션으로 쌓아두었다가(transactional write-behind), flush 시점에 호출 순서가 아니라 액션 타입의 고정 우선순위로 실행한다.

em.persist(entity)를 불러도 대개 INSERT가 바로 나가지 않는다. Hibernate는 이를 EntityInsertAction 객체로 만들어 ActionQueue에 넣어둔다. removeEntityDeleteAction, dirty checking으로 감지된 필드 변경은 EntityUpdateAction이 된다. 실제 SQL은 flush — 트랜잭션 커밋, 명시적 flush(), JPQL 실행 직전의 auto-flush — 가 큐를 훑을 때 한꺼번에 나간다. 쓰기를 모아두면 JDBC 배칭으로 왕복을 줄일 수 있는데, 이 "미뤄뒀다 몰아 쓰기"가 write-behind다.

3. 내부 동작 — flush 때의 고정 실행 순서

ActionQueue는 액션을 종류별 리스트로 나눠 보관하고, executeActions()는 그 리스트들을 정해진 순서대로 비운다. 이 순서는 액션을 큐에 넣은 시각과 무관하다. Hibernate ORM 소스(org.hibernate.engine.spi.ActionQueue)의 실행 순서는 대략 이렇다.

executeActions() 실행 순서 (액션 타입 우선순위)
  1. OrphanRemovalAction            orphanRemoval 고아 객체 제거
  2. AbstractEntityInsertAction     엔티티 INSERT   ← 앞쪽
  3. EntityUpdateAction             엔티티 UPDATE
  4. QueuedOperationCollectionAction
  5. CollectionRemoveAction         컬렉션 원소 삭제
  6. CollectionUpdateAction         컬렉션 갱신
  7. CollectionRecreateAction       컬렉션 재생성
  8. EntityDeleteAction             엔티티 DELETE   ← 맨 뒤

즉 코드에서 remove → persist 순으로 불러도, flush 때는 2번(INSERT)이 8번(DELETE)보다 먼저 실행된다. 내 unique 위반의 원인이 정확히 이것이었다.

코드 호출 순서          ActionQueue (타입별 버킷)          flush 실행 순서
remove(old)    ──┐      deletions:  [del old]              INSERT fresh   (2)
persist(fresh) ──┘      insertions: [ins fresh]     →      ...
                                                           DELETE old     (8)

왜 하필 이 순서인가 — 참조 무결성

부모를 자식보다 먼저 넣고, 삭제를 맨 뒤로 미루면 FK 참조가 항상 성립한다. 자식 INSERT가 나갈 때 그것이 가리키는 부모 row는 같은 flush의 앞 단계에서 이미 존재하고, 부모 DELETE는 이 부모를 참조하던 자식들의 정리가 모두 끝난 뒤에 실행된다. 공식 소스의 이 배치는 "가능한 한 제약 위반 없이 한 배치로 밀어넣기 위한" 설계로 이해된다.

컬렉션 액션이 엔티티 UPDATE(3) 뒤·엔티티 DELETE(8) 앞에 낀 것도 같은 논리다. @OneToMany 조인 컬럼을 끊는 CollectionRemoveAction(5)이 자식 DELETE(8)보다 먼저 실행돼, 자식 row가 지워질 땐 이미 부모 쪽 참조가 정리돼 있다. 반대로 새 원소를 넣는 CollectionRecreateAction(7)은 엔티티 INSERT(2) 다음이라 참조 대상이 존재한다. 정리하면 "만들기(2)는 앞, 끊기·지우기(5,8)는 뒤" 라는 한 방향 규칙이 엔티티와 컬렉션 양쪽에 관통한다.

4. INSERT 버킷 안의 2차 정렬 — order_inserts와 배칭

같은 INSERT 버킷 안에서도, hibernate.order_inserts=true이면 액션들을 엔티티 타입별로 묶고 FK 의존성으로 위상정렬한다(내부 InsertActionSorter). 이유는 JDBC 배칭이다. PreparedStatement batch는 같은 SQL 문자열이 연속될 때만 쌓인다. A, B, A, B처럼 타입이 번갈아 오면 batch가 매번 깨져 왕복이 늘고, A, A, B, B로 정렬하면 batch_size만큼 묶여 한 번에 나간다. order_updates도 UPDATE에 같은 역할을 한다. 이 두 설정이 User Guide의 "JDBC Batch Settings"에서 jdbc.batch_size와 함께 묶여 있는 이유가 이것이다.

batch_size=50, Author 10건 + Book 10건을 번갈아 persist
 order_inserts=false:  [A][B][A][B]...   SQL 문자열이 매번 바뀜
                       → executeBatch 20번, 배치 이점 사실상 0
 order_inserts=true:   [A×10][B×10]      같은 SQL 연속
                       → executeBatch 2번 (Author 1묶음, Book 1묶음)

주의할 점: order_inserts는 이미 큐에 쌓인 액션을 flush 직전에 재배열할 뿐이라, INSERT가 지연 가능할 때만 의미가 있다. 아래 IDENTITY처럼 persist 즉시 INSERT가 나가면 정렬할 큐 자체가 없다.

5. IDENTITY 전략은 이 write-behind를 깨뜨린다

GenerationType.IDENTITY는 식별자를 DB의 auto-increment가 채워야 하고, 영속성 컨텍스트는 1차 캐시 키로 그 식별자가 즉시 필요하다. 그래서 IDENTITY에서는 persist() 순간 INSERT를 바로 실행해 생성된 키를 받아온다(해당 insert 액션이 지연되지 않는다). 결과적으로 IDENTITY로는 insert 배칭이 사실상 불가능하다. 반면 SEQUENCE/TABLE 전략은 키를 미리 확보할 수 있어 INSERT를 flush까지 미루고 배칭할 수 있다 — 배칭이 목적이면 IDENTITY를 피하는 이유다.

6. 정리

  • flush는 내 호출 순서를 버리고 액션 타입 우선순위(INSERT → … → DELETE)로 SQL을 재배열한다. 한 트랜잭션 안에서 delete-then-insert가 필요하면 순서가 뒤집힌다.
  • 우회책은 em.remove(old); em.flush();로 DELETE를 먼저 강제로 내보내는 것이지만, write-behind·배칭 이점을 버리는 코드 스멜이다. 애초에 지우고-다시-넣기보다 기존 row를 UPDATE 하는 편이 인덱스·라운드트립 면에서 낫다(Vlad Mihalcea).
  • batch_size만 켠다고 INSERT가 배칭되지 않는다. IDENTITY에서는 즉시 INSERT라 배칭이 안 되고, 타입이 섞이면 order_inserts 없이 batch가 매번 깨진다.
  • flush ≠ commit. flush는 큐를 SQL로 내보내는 동기화일 뿐, 이후에도 롤백 가능하다.

더 파고들 만한 주제 두 가지: InsertActionSorter의 위상정렬이 순환 FK 의존을 만났을 때의 처리, 그리고 FlushModeType.AUTO가 JPQL 실행 전 어떤 테이블 오염을 감지해 auto-flush를 트리거하는지.

참고 자료

  • Hibernate ORM User Guide — Flushing / JDBC Batch Settings (order_inserts, order_updates, jdbc.batch_size)
  • Hibernate ORM 소스 org.hibernate.engine.spi.ActionQueue (OrderedActions, executeActions)
  • Vlad Mihalcea, "A beginner's guide to Hibernate flush operation order"

0개의 댓글