개발자 포포

[Hibernate] 쓰기 지연(write-behind) 동작 순서와 예외 케이스

popo.se 2025. 1. 5. 21:17

Hibernate의 쓰기 지연

하이버네이트의 “쓰기지연” 을 제공합니다. 쓰기지연은 다음과 같이 동작합니다.

  • 하이버네이트는 영속성 컨텍스트에 변경이 발생했을때, 즉시 쿼리를 DB로 날리지 않고, ActionQueue 라는 내부 구조에 쿼리 수행정보를 저장
  • flush 시점에 ActionQueue 저장된 SQL 쿼리들을 실행

이를 통해 애플리케이션과 DB 간 효율적인 네트워크 통신과 최적화된 실행의 장점이 있습니다.

  • jdbc batch를 이용해 쿼리들을 건건이 DB에 보내지 않고 합쳐서 전송
  • 동일 엔티티, 동일 필드에 대한 여러 변경사항이 하나의 트래젝션 내에서 이뤄진 경우, 중간에 불필요한 update 쿼리는 제외하고 마지막 상태에 대한 update 쿼리만 전송

이런 쓰기 지연에는 내부적으로 동작 순서가 있습니다.

동작 순서에 대해 고려하지 못한다면 의도와는 다른 결과가 나올 수 있으므로 반드시 알아두시는 것이 좋습니다.

쓰기 지연의 동작 순서

쓰기 지연의 동작 순서는 하이버네이트 문서에서 확인하실 수 있습니다.

Execute all SQL (and second-level cache updates) in a special order so that foreign-key constraints cannot be violated:\
  1. Inserts, in the order they were performed
  2. Updates
  3. Deletion of collection elements
  4. Insertion of collection elements
  5. Deletes, in the order they were performed

 

문서의 스펙을 따라 실제 ActionQueue 에 저장되는 종류별 액션 구현체들은 다음 순서로 실행됩니다.

처리 순서

순서 Action 타입 설명

순서 Action 타입 설명
1 EntityIdentityInsertAction 기본 키가 즉시 할당되는 삽입 작업 (IDENTITY 생성 전략)쓰기 지연 없이 바로 SQL 실행됨(DB를 활용한 AI 키 가져와야 하므로)
2 EntityInsertAction 엔티티 삽입 작업
3 EntityUpdateAction 엔티티 업데이트 작업
4 CollectionRemoveAction 컬렉션의 기존 요소를 삭제
5 CollectionRecreateAction 컬렉션의 새로운 요소를 삽입
6 OrphanRemovalAction 고아 객체 제거 (연관 관계가 끊어진 엔티티 삭제)
7 EntityDeleteAction 엔티티 삭제 작업
8 QueuedOperationCollectionAction 지연된 컬렉션 작업 처리 (예: extra lazy 큐 작업)
9 BulkOperationCleanupAction 대량 작업 후 정리 작업 (캐시 정리 등)

 

대표적인 문제 케이스

이러한 순서로 인해 대표적으로 문제가 발생할 수 있는 케이스는,

특정 unique 필드를 가진 레코드에 변경사항이 있을때 update 처리하지 않고, hard delete 후 다시 삽입하는 형태로 구현한 케이스입니다. 구현할 때는 순차적으로 "삭제 → 삽입" 으로 동작하도록 하더라도 실제 쿼리는 "삽입 → 삭제" 순으로 동작해버리기 때문에 삭제 시점에 레코드가 동일 unique 값을 사용하여 duplicate 오류가 나는 것이죠.

 

예제와 함께 살펴보겠습니다.

Runner 엔티티와 Runner의 러닝 기록을 저장하는 RunnerRecord 엔티티가 아래와 같이 있습니다.

@Entity
class Runner(
    @Column
    val name: String,
) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0
    }
@Entity
class RunnerRecord(
    @Column(name = "runner_id", unique = true)
    val runnerId: Long,
    @Column(name = "year_month")
    var yearMonth: String,
    @Column(name = "max_speed_per_hour")
    var maxSpeedPerHour: Int,
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0
    }

 

러너의 기록을 업데이트하는 경우 update 하지않고 레코드 삭제 후 다시 삽입하는 방식으로 구현하도록 해보겠습니다.

var runner: Runner? = null
var runnerRecord: RunnerRecord? = null
var newRunningRecord: RunningRecord? = null

// 첫번째 트랜젝션
platformTransactionManager.tx {
    println("---트랜젝션1 시작---")
    runner = RunnerFixtures.runner()
        .run { runnerRepository.save(this) }
    runnerRecord = RunnerRecordFixtures.runnerRecord(runnerId = runner!!.id)
        .run { runnerRecordRepository.save(this) }

    newRunningRecord = RunningRecordFixtures.runningRecord(runnerRecordId = runnerRecord!!.id)
    runnerRecord!!.addRunningRecord(newRunningRecord!!)
    println("---트랜젝션1 끝---")
}

// 두번째 트랜젝션
platformTransactionManager.tx {
		println("---트랜젝션2 시작---")
    runnerRecordRepository.delete(runnerRecord!!) // (1)기존 레코드 삭제
    RunnerRecordFixtures.runnerRecord(runnerId = runner!!.id)
        .run { runnerRecordRepository.save(this) } // (2)새로운 레코드 삽입
		println("---트랜젝션2 끝---")
}

코드 순서상으로는 (1) → (2) 로 실행되니 문제가 없을 것 같지만 실제로는 쓰기 지연 매커니즘에 따라 flush 시점에 (2) →(1) 순으로 실행하게 되어 duplicate 오류가 발생합니다.

 

[참고] 로그

---트랜젝션1 시작---
Hibernate: insert into runner (name,id) values (?,default)
Hibernate: insert into runner_record (max_speed_per_hour,runner_id,year_month,id) values (?,?,?,default)
---트랜젝션1 끝---
---트랜젝션2 시작---
Hibernate: select rr1_0.id,rr1_0.max_speed_per_hour,rr1_0.runner_id,rr1_0.year_month from runner_record rr1_0 where rr1_0.id=?
Hibernate: insert into runner_record (max_speed_per_hour,runner_id,year_month,id) values (?,?,?,default)
2025-01-05T20:05:32.188+09:00  WARN 69933 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 23001, SQLState: 23001
2025-01-05T20:05:32.188+09:00 ERROR 69933 --- [    Test worker] o.h.engine.jdbc.spi.SqlExceptionHelper   : Unique index or primary key violation: "CONSTRAINT_INDEX_2 ON PUBLIC.RUNNER_RECORD(RUNNER_ID)"; SQL statement:
insert into runner_record (max_speed_per_hour,runner_id,year_month,id) values (?,?,?,default) [23001-148]

 

관련된 추가 고민거리 : 그런데, 근본적으로 update를 해야했던 것 아닐까?

  • 일반적으로는 연관관계에 있는 레코드에 대한 부가정보를 저장하는 경우, update로 갱신하는 것이 더 적절할 수 있습니다. (위의 케이스도 사실은 update를 하는 것이 더 자연스럽습니다. 새로운 레코드로 인식되는 것 보다는 기존 레코드가 갱신되는 관점이 더 합리적인 케이스입니다.)
  • 하지만, “1)연관관계가 연쇄적으로 이어져 처리가 복잡하거나”, “2)지금처럼 단일 필드에 대한 갱신뿐 아니라 업데이트시 많은 변경을 동반하여 케이스별 업데이트를 위한 복잡한 로직이 필요한 경우” 등 상황에 따라 심플하게 모두 hard delete 후, insert를 택할 수도 있습니다. 코드 복잡성을 줄이고 개발난이도를 낮추는 관점에서는 보다 이게 합리적인 선택일 수 있습니다.
  • 그래서 위 기본 원칙을 인지하고 상황에 따라 적절한 방법을 적용하는 것이 중요합니다.

전체 코드는 아래 repository에서 확인하실 수 있습니다.

https://github.com/popotec/playground