Hibernate Flush 동작 정리

devty·2025년 6월 11일

SpringBoot

목록 보기
8/11

Flush란 무엇인가?

  • Flush의 정의와 역할
    • 영속성 컨텍스트(1차 캐시): 애플리케이션이 조회·변경한 엔티티 객체를 메모리에 보관하는 공간
    • Flush: 이 메모리상의 변경사항(INSERT, UPDATE, DELETE)을 SQL로 변환해 데이터베이스에 보내는 과정
      • 단순히 “변경을 내보낸다”가 아니라, Dirty Checking → ActionQueue 구성 → JDBC 배치/실행 순으로 진행
  • Dirty Checking & ActionQueue
    • Dirty Checking
      • 엔티티가 관리 상태(Managed)가 되면, Hibernate는 원본 스냅샷을 보관
      • 트랜잭션 중간에 엔티티 필드가 바뀌면 “원본 vs 현재”를 비교해 변경된 속성을 감지
    • ActionQueue
      • 감지된 INSERT/UPDATE/DELETE 작업을 SQL 액션 단위로 큐에 등록
      • Flush 시 이 큐를 순회하며 SQL을 발행—INSERT → UPDATE → DELETE 순서로
  • 언제 자동으로 Flush되는가? (FlushMode.AUTO)
    1. 조회성 쿼리 실행 직전
      • entityManager.createQuery(…), JPAQueryFactory.fetchOne() 등 JPQL·QueryDSL 쿼리 메서드를 호출하기 바로 전에
      • “이 쿼리가 참조할 테이블에 더티 엔티티가 있나?” 검사 후, 있으면 자동 flush
    2. 트랜잭션 커밋 직전
      • @Transactional 메서드 정상 종료 → Spring이 커밋 준비 단계에서 flush() 호출
      • 남아 있는 모든 Action을 밀어내고야 실제 DB 트랜잭션을 커밋
  • 명시적 Flush 호출
    • entityManager.flush() 또는 session.flush()를 직접 호출하면
      1. 즉시 Dirty Checking 수행
      2. ActionQueue에 쌓인 SQL 액션을 배치/개별 실행
    • “이 지점까지의 변경은 반드시 DB에 반영해야 한다”는 강제 동기화 용도로 사용

ActionQueue가 관리하는 SQL 실행 순서

  • Hibernate는 내부에 ActionQueue 라는 큐를 두고, 다음 순서로 SQL을 발행
    1. OrphanRemovalAction
    2. EntityInsertAction
    3. EntityUpdateAction
    4. QueuedOperationCollectionAction
    5. CollectionRemoveAction
    6. CollectionUpdateAction
    7. CollectionRecreateAction
    8. EntityDeleteAction
  • 한 마디로 정리하면 INSERT → UPDATE → DELETE으로 처리
  • 따라서 코드 흐름과 상관없이 ActionQueue가 이 순서대로 작업을 처리

주요 Flush 모드 비교

Flush 모드쿼리 실행 전 Flush커밋 전 Flush설명
AUTO (JPA 기본)OO조회성 쿼리 전·커밋 전 자동 플러시
COMMITXO커밋 시에만 플러시
ALWAYS (Hibernate)OO모든 쿼리 전 무조건 플러시
MANUAL (Hibernate)XX오직 flush() 호출 시에만 플러시
  • Spring Data JPA에서 @Transactional(readOnly = true)를 사용한다면?
    • @Transactional(readOnly = true) 내부에서 FlushMode.MANUAL로 설정
    • 다만 이미 진행 중인 트랜잭션이 @Transactional(readOnly = false)라면 상위 모드가 유지되니 주의

문제 1 : UPDATE+INSERT 순서 오류 재현

  • book.title 컬럼에 UNIQUE 제약조건이 걸려 있다 가정합니다.
  • 서비스
    @Service
    class UserService(
        private val userRepo: UserRepository,
        private val em: EntityManager
    ) {
      @Transactional
      fun removeAndRecreate(username: String) {
        // 1) 기존 사용자 조회
        val existing = userRepo.findByUsername(username)
            ?: throw IllegalArgumentException("No user: $username")
    
        // 2) 삭제 → DELETE Action 등록
        userRepo.delete(existing)
    
        // 3) 곧바로 같은 username 으로 새 사용자 저장 → INSERT Action 등록
        userRepo.save(User(username = username))
    
        // **의도**: DELETE → INSERT 순
        // **실제**: INSERT → DELETE 순으로 ActionQueue가 실행 → UNIQUE 위반
      }
    }
  • 로그
    INSERT INTO app_user (username) VALUES ('alice');    # ③ save()의 INSERT Action
    /* DELETE FROM app_user WHERE id = ? */             # ② 기존 delete()

문제 1에 대한 해결책

  • sequenceDiagram
  • 중간에 flush() 호출
    @Transactional
    fun renameAndRecreateFixed(originalTitle: String) {
      val book = bookRepo.findByTitle(originalTitle)!!
    
      // 1) 제목 변경 → UPDATE Action
      book.title = "Renamed"
    
      // 2) 즉시 DB 반영
      em.flush()
    
      // 3) 안전하게 INSERT
      bookRepo.save(Book(title = originalTitle))
    }
    • flush() 호출 시점에 ActionQueue를 실행 → UPDATE가 먼저 DB에 반영
    • 그 이후 INSERT가 정상 수행되므로 UNIQUE 위반 발생 안 함
  • 비즈니스 로직 구조 개선
    @Transactional
    fun cloneBookWithSameTitle(originalTitle: String) {
      val old = bookRepo.findByTitle(originalTitle)!!
      // 새 객체를 완전히 분리된 상태로 복제
      val copy = Book(title = old.title)
      bookRepo.save(copy)
    }
    • 단일 UPDATE
      • 제목을 교체하거나, 필요한 필드만 수정
    • 엔티티 복제 패턴
      • 새 객체를 만들 때 기존 객체를 복제(clone)하여 생성
    • DELETE 없이 UPDATE+INSERT
      • 복제 후 SAVE, 혹은 ‘삭제+저장’ 로직을 완전히 분리

문제 2 : 상위 트랜잭션이 readOnly=false인 경우

  • 서비스
    @Service
    class UserService(
        private val userRepo: UserRepository,
        private val profileService: ProfileService
    ) {
        /**
         * 상위 트랜잭션: readOnly = false, FlushMode.AUTO
         */
        @Transactional
        fun updateUsername(userId: Long, newName: String) {
            // 1) 사용자 읽기 (Dirty 체크 대상)
            val user = userRepo.findById(userId)
                .orElseThrow { IllegalArgumentException("No user: $userId") }
            user.name = newName  // UPDATE Action 등록
    
            // 2) 프로필 조회 (readOnly = true) 호출
            //    하지만 같은 TX 에 합류하므로 FlushMode.AUTO 유지 → 조회 직전에 flush() 발생
            val profile = profileService.getProfileForAudit(userId)
            println("Fetched profile: $profile")
    
            // 3) 동일 TX 내에서 다른 변경
            userRepo.save(user)  // INSERT/UPDATE Action 추가
            // 트랜잭션 커밋 시점에도 남은 ActionQueue flush → 하지만 이미 flush가 먼저 일어남
        }
    }
    
    @Service
    class ProfileService(
        private val profileRepo: ProfileRepository
    ) {
        /**
         * 의도: readOnly=true → FlushMode.MANUAL
         * 실제: 상위 TX가 AUTO 이므로 AUTO 유지
         */
        @Transactional(readOnly = true)
        fun getProfileForAudit(userId: Long): Profile {
            // 이 조회 직전에 자동으로 flush()가 호출된다!
            return profileRepo.findByUserId(userId)
                ?: throw IllegalArgumentException("No profile for user: $userId")
        }
    }
  • 흐름 설명
    1. updateUsername() 호출
      • Spring이 readOnly=false인 트랜잭션(AUTO 모드) 시작
    2. userRepo.findById() → SELECT
    3. user.name = newName → Dirty 체크 대상, 영속성 컨텍스트에 UPDATE 보류
    4. profileService.getProfileForAudit() 호출
      • 메서드에 readOnly=true가 붙어 있지만, 같은 트랜잭션(PROPAGATION_REQUIRED)에 합류
      • Spring은 하위 세션의 FlushMode를 MANUAL로 설정하려 하나, 이미 AUTO인 상위 트랜잭션이 우선 적용되어 변경되지 않음
      • 곧바로 조회성 쿼리 실행 직전(JPQL/QueryDSL) flush()가 발생
        UPDATE user SET name = 'newName' WHERE id = :userId;
        SELECT * FROM profile WHERE user_id = :userId;
    5. updateUsername()로 복귀 후 userRepo.save(user) → 또 다른 Action 등록
    6. 트랜잭션 커밋 시점에도 남은 Action이 flush되지만, 이미 4번에서 flush가 일어나므로 조회 전후의 순서가 바뀔 수 있음
  • 로그
    -- updateUsername TX start (FlushMode=AUTO)
    SELECT * FROM user WHERE id = ?  // findById
    # Dirty: user.name 변경
    
    -- getProfileForAudit 호출, same TX (intended MANUAL, but remains AUTO)
    # 조회 직전 자동 flush()
    UPDATE user SET name='newName' WHERE id=?  // flush before SELECT
    SELECT * FROM profile WHERE user_id=?  // readOnly method
    
    -- 돌아와서 save()
    # (추가 Action)
    UPDATE user SET name='newName' WHERE id=?  // may be skipped if already flushed
    
    -- commit TX
    -- End of transaction

문제2에 대한 해결책

  1. sequenceDiagram
  • getProfileForAudit 에 propagation = REQUIRES_NEW 추가
    /**
     * 완전 별도 트랜잭션, readOnly=true → FlushMode.MANUAL
     */
    @Transactional(
        readOnly     = true,
        propagation  = Propagation.REQUIRES_NEW
    )
    fun getProfileForAudit(userId: Long): Profile {
        return profileRepo.findByUserId(userId)
            ?: throw IllegalArgumentException("No profile for user: $userId")
    }
    • 이 메서드는 항상 새 트랜잭션을 띄워 readOnly=true로 동작하므로, 상위 쓰기 트랜잭션의 자동 Flush 영향을 받지 않고 순수 조회만 수행
    • 이를 통해 프로필 조회 과정에서 불필요한 DB 동기화를 완전히 분리해 안전하게 사용

참고자료

profile
지나가는 개발자

0개의 댓글