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:\
|
문서의 스펙을 따라 실제 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에서 확인하실 수 있습니다.
'개발자 포포' 카테고리의 다른 글
[Redisson] default codec 사용은 지양하자 (1) | 2025.02.16 |
---|---|
[Hibernate] Lazy Loading이 포함된 Response 유의 (2) | 2024.09.17 |
[Hibernate] Collection 타입은 PersistentBag 타입으로 래핑된다. (0) | 2024.09.17 |
[Hibernate] orphanRemoval 옵션 사용시 Collection 참조를 변경하지 말자 (1) | 2024.09.17 |
[Spring] Spring Batch Chunk 단위 처리 결과를 ThreadLocal로 저장해보기 (0) | 2024.09.17 |