하이버네이트 flush 순서에 주의하자
대상독자
하이버네이트 동작원리를 잘 모르고 사용했을 때 어떤 일이 발생할 수 있는지 궁금하신 분들
개요
애플리케이션을 개발하던 중 하이버네이트의 동작원리를 잘 몰랐기 때문에 버그가 발생하게 되었습니다.
하이버네이트의 동작원리를 알아보면서 버그를 재현해보고 어떻게 해결할 수 있는지 정리해보고자 합니다.
하이버네이트란?
하이버네이트(Hibernate)는 자바 애플리케이션과 관계형 데이터베이스 간의 상호작용을 간소화하기 위한 객체-관계 매핑(ORM) 도구입니다.
자바 클래스와 데이터베이스 테이블을 매핑하여, 자바 객체를 데이터베이스의 레코드로 변환하고 그 반대의 작업도 처리합니다.
이를 통해 개발자는 직접 SQL 코드를 작성할 필요 없이 데이터베이스와의 복잡한 상호작용을 처리할 수 있습니다.
하이버네이트를 사용하면 CRUD(생성, 읽기, 업데이트, 삭제)와 같은 데이터베이스 작업에 관련된 반복적인 코드를 줄이고, 더 중요한 비즈니스 로직에 집중할 수 있습니다.
또한 지연 로딩, 캐싱, 데이터베이스 독립성 등 다양한 고급 기능을 지원하여, 애플리케이션 개발에 편리함을 제공합니다.
하지만 편리한 기능들을 제공하기 때문에 내부 동작들의 원리를 잘 알지 못하면 곤란한 상황이 종종 발생하게 됩니다.
실제 사례 소개
@Entity
@Table(
name = "test_entity",
uniqueConstraints = [UniqueConstraint(columnNames = ["name"])]
)
class TestEntity(
@Id
val id: UUID = UUID.randomUUID(),
@Column(nullable = false)
val name: String = "1",
)
name을 유니크 제약조건으로 가지는 TestEntity를 하나 정의해 보았습니다.
@Component
@Transactional
class MakeApplicationBug(
private val testEntityRepository: TestEntityRepository,
): ApplicationRunner {
override fun run(args: ApplicationArguments) {
`이미 적재가 되어 있는 상태 재현`()
존재하는경우삭제()
새롭게적재()
}
private fun `이미 적재가 되어 있는 상태 재현`() {
testEntityRepository.save(TestEntity(name = MY_NAME))
}
private fun 존재하는경우삭제() {
val testEntity = testEntityRepository.findByName(name = MY_NAME)
if(testEntity != null){
testEntityRepository.deleteById(testEntity.id)
}
}
private fun 새롭게적재(){
testEntityRepository.save(TestEntity(name = MY_NAME))
}
companion object{
val MY_NAME = "junuu"
}
}
위 코드는 어떻게 동작하게 될까요?
위에서 아래로 코드라인을 읽어가게 되면 큰 문제가 없을 것 같습니다.
하지만 실제로 애플리케이션을 구동해보면 insert를 하던 중 DataIntegrityViolationException가 발생하게 됩니다.
왜 이런일이 발생하는 걸까요?
Hibernate와 flush
위 문제를 이해하기 위해서는 먼저 flush에 대해 알 필요가 있습니다.
Hibernate에서 flush는 메모리에 있는 영속성 엔티티(즉, Hibernate가 관리하는 객체)의 상태를 데이터베이스와 동기화하는 과정을 말합니다.
쉽게 말해, 메모리에서 이루어진 변경 사항(삽입, 업데이트, 삭제)을 데이터베이스에 반영하는 작업입니다.
Flush는 언제 발생할까?
하이버네이트가 flush 옵션에 대한 여러개의 선택지를 제공합니다.
이때 FlushMode.AUTO 가 기본으로 설정되어 동작되며 트랜잭션이 커밋하는 등의 상황에서 하이버네이트가 자동으로 flush를 수행합니다.
그리고 이 Flush의 내부동작에는 저희가 간과한 내용이 있습니다.
Hibernate does not execute the SQL statements in the order of their associated entity state operations.
SQL 문이 실행되는 순서는 이전에 정의된 엔티티 상태 연산의 순서가 아니라 ActionQueue에 의해 지정됩니다.
그렇기 때문에 예상했던 순서로 SQL 문이 실행되지 않았고 유니크 제약조건을 위반했다는 예외를 만나게 됩니다.
ActionQueue의 실행순서
ActionQueue는 아래와 같은 순서로 쿼리를 수행합니다.
1. OrphanRemovalAction
2. EntityInsertAction or EntityIdentityInsertAction
3. EntityUpdateAction
4. QueuedOperationCollectionAction
5. CollectionRemoveAction
6. CollectionUpdateAction
7. CollectionRecreateAction
8. EntityDeleteAction
8개의 조건이 복잡해보이지만 단건 SQL에 대해서 요약해 보자면 아래와 같은 순서로 실행됩니다.
1. INSERT
2. UPDATE
3. DELETE
실제로 애플리케이션에서는 아래와 같은 쿼리를 구성하였습니다.
INSERT
DELETE
INSERT
하지만 내부동작에 의해 정렬되면 아래와 같이 쿼리가 실행됩니다.
INSERT
INSERT << 이때 유니크 제약조건으로 실패 발생
DELETE
대안책은 어떤것이 있을까?
이제 근본원인을 찾았으니 문제를 해결해 볼 수 있을 것 같습니다.
첫 번째로 delete를 호출하고 강제로 flush를 애플리케이션에서 호출해 볼 수 있습니다.
그렇다면 두번째 INSERT를 호출하기 전에 flush를 통해 delete 쿼리가 전송되므로 문제가 발생하지 않습니다.
두 번째로는 조금 더 근본적인 고민을 해볼 수 있습니다.
우리는 왜 DELETE를 하고 INSERT를 하려고 할까요?
다시 생각해보고 꼭 필요한 이유가 아니었다면 UPDATE로 해결할 수 있지 않을까? 라는 생각도 해볼 수 있습니다.
마무리
하이버네이트는 편리하지만 감추어져 있는 동작들이 있어 간혹 당황스럽기도 합니다.
앞으로 코드리뷰 할 때 위와 같은 형태의 코드를 만나면 다음과 같이 제안해볼 수 있을 것 같습니다.
하이버네이트가 flush 시 쿼리의 순서가 변경될 수 있어 delete 보다 insert가 먼저 실행될 수 있습니다.
update를 활용하거나 flush를 강제로 호출해보면 어떨까요?
참고자료