⚠️ 문제 상황

  1. 로그인 시 user entity의 lastLoginAt 필드를 update 후 repository.save()
  2. hibernate의 PostUpdateEventListener 구현한 EntityUpdateEventListener에서 에러 발생
    • Reflection을 사용할 때, stackTrace의 모든 경로를 찾는데, 동적 클래스 로딩으로 생성된 클래스는 JVM 메소드 영역에 저장되며 GC 대상이 되기 때문에 GC에 의해 제거되어 Class.forName 에서 클래스를 찾지 못하여 발생한 exception이라고 예상됨.
      @Component
      class EntityUpdateEventListener(
          private val logService: LogService,
      ) : PostUpdateEventListener {
          private fun isScheduledMethodInvoked(): Boolean {
              Thread.currentThread().stackTrace.map { stackTraceElement ->
                  runCatching {
                      val methods = ReflectionUtils.getUniqueDeclaredMethods(Class.forName(stackTraceElement.className))
                      methods.map { method ->
                          if (method.isAnnotationPresent(Scheduled::class.java)) {
                              return true
                          }
                      }
                  }
              }
              return false
          }
      
          override fun onPostUpdate(
              event: PostUpdateEvent
          ) {
              if (isScheduledMethodInvoked()) {
                  return
              }
      
              ...
      
              }
      }
  3. 재 로그인 시, pessimistic lock 에러 발생함. hikariCP에서 maxLifeTime의 default 값인 30분이 지나도 에러는 유지됨.

 

org.springframework.dao.PessimisticLockingFailureException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.PessimisticLockException: could not execute statement
...
Caused by: org.hibernate.PessimisticLockException: could not execute statement
...
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
...

 

🔎 원인 분석

1. 문제 시점 분석

  • 서버 restart 직후는 정상동작
    • 그러나 4번째 로그인 시도 시 에러 발생 후 재 로그인 시 PessimisticLockingFailureException이 발생한다.
  • 에러 시점 분석
    • EntityUpdateEventListener 에서 에러가 발생하고, 이후에 database에서 SELECT ... FOR UPDATE 로 조회 시 pending으로 lock을 잡고 있다.
SELECT *
FROM users
WHERE id = {USER_ID}
FOR UPDATE;

2. 문제 코드 분석

  • EntityListener를 Hibernate의 공식문서에 명시된 방법으로 추가했을 때는 정상 동작함. [공식 문서]

        1. Entity에 @PostUpdate 추가하는 방법

@PostUpdate
fun postUpdate() {
    throw Exception()
}

 

       2. Entity에 @EntityListeners 추가하는 방법

@EntityListeners(TestUserListener::class)
class UserEntity(

 

 

  • PostUpdateEventListener 를 구현한 Listener를 global로 (억지로) 추가했을 때만 문제가 생김
@Component
class HibernateListenerConfig(
    private val entityUpdateEventListener: EntityUpdateEventListener
) {
    @PersistenceUnit
    private val emf: EntityManagerFactory? = null

    @PostConstruct
    private fun init() {
        val sessionFactory = emf!!.unwrap(SessionFactoryImpl::class.java)
        val registry = sessionFactory.serviceRegistry.getService(
            EventListenerRegistry::class.java
        )

        registry.getEventListenerGroup(EventType.POST_UPDATE).appendListener(entityUpdateEventListener)
    }
}

 

3. 원인 분석

  1. 첫번째 가설
    • 가설
      • update query가 commit 되지 않아서 lock을 잡고 있는 것 아닌가? 어떤 이유로 commit을 실패하나?
    • 검증
      • EntityListener 가 동작하는 시점 분석
        • UserService.save()
          → JpaTransactionManager.doCommit()
          → JdbcResourceLocalTransactionCoordinatorImpl.commit()
          → JdbcCoordinatorImpl.beforeTransactionCompletion()
          → DefaultFlushEventListener.onFlush()
          → EntityUpdateAction.postUpdate()
          → PostUpdateEventListener.onPostUpdate()
      • 분석하면, repository.save() 실행 시, update 쿼리를 생성하고 실행한다. 이후에 commit을 찍는 작업을 진행하는데, 이를 JpaTransactionManager 가 수행한다.
        // JdbcResourceLocalTransactionCoordinatorImpl.java 281 lines
        ...
        @Override
        public void commit() {
        ...
            JdbcResourceLocalTransactionCoordinatorImpl.this.beforeCompletionCallback();
            jdbcResourceTransaction.commit();
            JdbcResourceLocalTransactionCoordinatorImpl.this.afterCompletionCallback( true );
            }
            catch (RollbackException e) {
                throw e;
            }
            catch (RuntimeException e) {
                try {
                    rollback();
                }
                catch (RuntimeException e2) {
                    log.debug( "Encountered failure rolling back failed commit", e2 );
                }
                throw e;
            }
        ...
      •  
      • 위 코드를 보면, 실제 transaction에 commit을 찍기 전에 어떤 작업이 있다는 것을 확인해볼 수 있다. 예상하듯이, beforeCompletionCallback 은 entity가 flush 될 때 동작을 핸들링하는 DefaultFlushEventListener 를 호출하고, 이로 인해 PostUpdateEventListener 에 정의한 onPostUpdate() 함수가 실행된다.

        그렇다면 jdbcResourceTransaction.commit(); 코드는 정말 database에 commit 요청하는 함수가 맞는가?
        해당 함수의 구현 코드를 확인하면 알 수 있다.
      • // AbstractLogicalConnectionImplementor.java 83 lines
        @Override
        public void commit() {
            try {
                log.trace( "Preparing to commit transaction via JDBC Connection.commit()" );
                getConnectionForTransactionManagement().commit();
                status = TransactionStatus.COMMITTED;
                log.trace( "Transaction committed via JDBC Connection.commit()" );
            }
            catch( SQLException e ) {
                status = TransactionStatus.FAILED_COMMIT;
                throw new TransactionException( "Unable to commit against JDBC Connection", e );
            }
        
            afterCompletion();
        }
      •  
      • 위 코드에서 getConnectionForTransactionManagement().commit(); 코드가 commit 담당으로 보인다. 해당 코드의 구현체를 확인해보자.
      • // ConnectionImpl.java 795 lines
        @Override
        public void commit() throws SQLException {
        ...
            this.session.execSQL(null, "commit", -1, null, false, this.nullStatementResultSetFactory, null, false);
        ...

        위 코드에서 알 수 있듯이, commit SQL을 요청하는 것을 확인할 수 있다.
    • 정리
      JPA에서 commit을 찍을 때 3가지 단계를 거친다.
      1. 사전작업
        commit 전, 수행되어야 하는 작업을 실행한다.
        ex) PostUpdateEventListener 실행
      2. commit
        database에 commit SQL을 실제로 요청한다.
      3. connection 정리
        리소스를 release 하는 등의 작업을 한다
    • 해석
      사전작업, 즉 EntityEventListener 등에서 에러가 발생하면 commit 단계까지 도달하지 못하고, transaction을 종료하지 못한다.

      라고 결론을 짓기에는, JPA와 Hibernate가 이렇게 허술하게 코드를 짜지 않을 것 같아서 찜찜하다..
      그래서 좀 더 찾아본 결과, JdbcResourceLocalTransactionCoordinator에서 에러가 발생하면 try/catch로 에러를 잡아 rollback() 함수를 실행하는 것을 확인할 수 있다. 그럼 어디가 문제인걸까?
  2. 두 번째 가설
    • 가설
      • RuntimeException이 아니라, Exception 클래스로 에러가 발생하여 에러 핸들링을 하지 못하나? 위 코드에서도 RuntimeException만 핸들링 하는 것으로 보이네!
    • 검증

      실제 코드에서 발생하는 에러는 ClassNotFoundException으로, RuntimeException을 상속하지 않는 클래스이다.

      따라서 try/catch에 잡히지 않기 때문에 rollback이 되지 않고 에러를 뱉어서 commit까지 가지 않는 것이다. 그래서 lock을 계속 잡고 있는 것이다.

      그럼 여기서 한가지 의문이 든다. 왜 Entity에 PostUpdate 혹은 Listener를 추가했을 때 동일하게 ClassNotFoundException 으로 exception을 발생시켜도 문제가 되지 않는가? (실제로 해봤는데 정상 동작한다!)

      그 이유는 JPA에서 exception을 랩핑하여 전파하기 때문이다. 코드를 확인해보자.
      // ListenerCallback.java 52 lines
      @Override
      public boolean performCallback(Object entity) {
          try {
              callbackMethod.invoke( listenerManagedBean.getBeanInstance(), entity );
              return true;
          }
          catch (InvocationTargetException e) {
              //keep runtime exceptions as is
              if ( e.getTargetException() instanceof RuntimeException ) {
                  throw (RuntimeException) e.getTargetException();
              }
              else {
                  throw new RuntimeException( e.getTargetException() );
              }
          }
          catch (RuntimeException e) {
              throw e;
          }
          catch (Exception e) {
              throw new RuntimeException( e );
          }
      }

      여기서 주목할 점은 Exception 클래스도 RuntimeException으로 변환하여 다시 throw 한다는 것이다.
      그럼 모든 exception이 RuntimeException으로 변환될 것이고, 변환된 exception들은 모두 정상 동작하기 때문에 문제가 없는 것이다.

✏️ 해결 방법

PostUpdateEventListener의 onPostUpdate() 함수를 예외처리 문으로 감싸서 에러를 방지하도록 하면 문제는 쉽게 해결된다.

override fun onPostUpdate(
	event: PostUpdateEvent
) {
    runCatching {
    	...
    }
}

 

번외

그렇다면 왜 Lock이 계속 유지될까? 에러 상황에서 언급했듯이, hikariCP에서 maxLifeTime의 default 값인 30분이 지나도 lock은 계속 유지되고 있었다. connection이 생존시간을 넘어가면 자연스럽게 lock도 풀려야하지 않나?

 

이는 hikariCP 동작 방식을 보면 이해할 수 있다. hikari는 실행 중인 connection은 동작이 완료될 때까지 종료하지 않는다. 위 상황처럼 에러가 발생해 connection을 제대로 종료하지 못한 경우에는 connection이 계속 유효하다고 판단한다.

따라서 lock이 계속 유지되는 상황이 발생하는 것이다.

 

그럼 hibernate 버그 아닌가?

개인적인 생각으로는 코틀린이기 때문에 발생했다고 생각한다. java에서는 RuntimeException을 상속하지 않는 Exception들은 checked Exception으로 반드시 예외처리가 되어야 한다. 그러나 kotlin에서는 checked Exception과 uncheckedException의 구분이 없기 때문에 핸들링 하지 않았고, 해당 에러가 발생할 수 있었던 것이다.

문제가 됐던 Class.forName 함수 코드를 확인해보면 ClassNotFoundException을 상위 메서드로 던지고 있다.

public static Class<?> forName(String className)
            throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

 

java 였으면 에러 핸들링이 없어서 컴파일 단계에서 에러가 발생 할 것이다.

'JPA' 카테고리의 다른 글

무서운 @OneToMany  (0) 2024.08.16
@UpdateTimestamp의 비밀  (1) 2023.04.30

문제상황

  1. Mysql 8.0를 사용하고 있었고, B table에 A 테이블에 대한 FK 걸려있었다. 즉 A가 부모, B가 자식 관계이다.
  2. @OneToMany 를 사용하는 A Entity에서 B Entity에 CascadeType.ALL 설정으로 A Entity를 Aggregate Root로 사용하고 있다.
  3. 동시에 같은 A Entity를 update 시도하한다. 두 요청 모두 A Entity의 자식은 B Entity를 변경하는 작업이다.
  4. 이 때 DeadLockException 발생한다.
org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement
...
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:123)
    at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
    at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:953)

 

 

  • 예시 코드
class A(
    var name: String,

    @OneToMany(mappedBy = "a", cascade = [CascadeType.ALL], orphanRemoval = true)
    val bList = MutableList<B>,
) {
    fun update(name: String, bList: List<B>) {
        this.name = name
        this.bList.clear()
        this.bList.addAll(bList)
    }
}
class B(
    val name: String,

    @ManyToOne
    @JoinColumn(name = "a_id")
    val a: A,
)
@Service
class AService {
    @Transactional
    fun updateById(id: Int, name: String, bNameList: List<String>): A {
        val a = aRepository.findById(id)!!
	
        a.update(
            name = name,
            bList = bNameList.map { B(it, a) }
        )
				
        return aRepository.save(a)
    }
}

 

  • 테스트코드
@SpringBootTest
class ATest(
    private val aService: A,
    private val aRepository: ARepository,
) : BehaviorSpec({
    Given("A entity가 주어지고") {
        val A = aRepository.save(A("test", mutableListOf()))
	
        When("동시에 A entity를 수정 시도하면") {
            val try1 = CoroutineScope(Dispatchers.IO).async {
                aService.updateById(a.id, "update1", listOf("b1", "b2"))
            }
            val try2 = CoroutineScope(Dispatchers.IO).async {
                aService.updateById(a.id, "update2", listOf("b3", "b4"))
            }
						
            listOf(try1, try2).awaitAll()
						
            Then("동시성 이슈 발생함") {
                ...
            }
        }
    }
})

 

원인 분석

결론부터 말하자면, FK 때문에 발생하는 DeadLock이다.

MySQL에서는 FK가 걸린 데이터를 조회하면 FK에 해당하는 테이블의 row에 S-Lock을 건다. FK 데이터의 일관성 유지를 위함인데, 이것이 문제 발생의 원인이 된다. [공식 문서]

 

일반 조회 상황이나, 자식 테이블의 데이터만 수정하는 경우에는 문제가 없는데, 부모 데이터까지 같이 수정하는 경우에 문제가 발생한다. 어떻게 Deadlock이 발생할 수 있는지 아래 흐름으로 자세히 보자.

 

  1. 1번 Thread에서 aRepository.save(a) 코드 실행할 때, B entity를 insert 먼저 시도
    • MySQL에서는 자식 table에 insert 시 FK에 해당하는 부모 row에 S Lock 을 검
  2. 2번 Thread에서도 aRepository.save(a) 코드 실행할 때, B entity를 insert 시도함.
    • 동일하게 S Lock 을 걸기 때문에 문제 X
  3. 1번 Thread에서 A Entity에 update 시도 함
    • 본인이 건 S Lock을 X Lock 으로 바꾸려고 시도하지만, 2번 Thread가 건 S Lock 으로 인해 대기
  4. 2번 Thread에서 A Entity에 update 시도 함
    • 본인이 건 S Lock을 X Lock 으로 바꾸려고 시도하지만, 1번 Thread가 건 S Lock 으로 인해 대기
  5. DeadLock 발생

 

결국에는 FK가 있는 데이터만 수정하는 것이 아닌, 부모 데이터까지 수정하는 것이 문제인 것이다.

그러나 JPA에서는 ID 생성 전략을 IDENTITY로 설정하여 DB에 위임할 경우, save 호출 시 insert는 불가피하다. 따라서 부모 entity를 aggregate root로 설정할 경우, 자식 entity가 먼저 save 되고 부모 entity는 transactional이 종료될 때 저장 된다.

 

즉, FK가 걸려있는 entity에서 OneToMany 설정에 cascade.ALL 설정이 들어가 있으면 반드시 발생할 수 있는 케이스이기 때문에 주의가 필요하다.

해결 방법

1. A Entity 조회 단계에서 X Lock 을 걸고 조회하기

  • JPA의 @Lock 활용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM A e WHERE e.id = :id")
fun findByIdWithLock(id: Int): A?

 

실제로 나간 조회 쿼리를 보면, FOR UPDATE 를 사용하여 조회 시 X LOCK 을 명시적으로 획득하는 것을 확인할 수 있다.

SELECT
    ...
FROM
    A
WHERE
    a.id = ? for update

 

OneToMany 사용시 이렇게 컨벤션을 가져가자고 하는 것도 어색하고, 개발자가 빠뜨리고 실수할 확률이 높아 보인다. 또한 X Lock을 잡아놓는 것이기 때문에 잘못 사용하면 성능에 큰 영향을 미칠 수 있다.

따라서 이 방법은 추천하지 않는다.

2. FK 제거하기

지금 상황을 회사 DBA 분께 설명하고, 어떻게 하는 것이 좋은지 조언을 구하였다.

질문
위 상황에서 FK로 인해 DeadLock이 발생하고 있습니다. 여러 블로그 글에서 FK를 제거하라는 솔루션을 많이 제안하는데, RDB에서 FK를 제거하는 것이 맞는 판단인지 모르겠습니다. RDB에서 FK라는 연관관계가 없다면 RDB의 의미가 많이 퇴색되어 RDB의 철학과 멀어지는 것 아닌가 하는 생각도 드는데 DBA분께서는 어떻게 생각하시나요? 다른 곳에서는 어떻게 FK를 다루나요?

 

DBA 답변
실제 많은 서비스에서는 데이터 무결성이 매우 중요해서 성능을 포기해도 되는 상황이 아니면 대부분 외래키를 사용하지 않습니다. 데이터 모델링 단계에서도 실제 물리 모델링시에는 관계를 표현만하고 외래키를 제거하는 것이 거의 표준처럼 이뤄집니다... 제가 있던 환경에서는 스키마 변경가능성, 락 발생, 성능과 DML 작업시 편의성을 위해 외래키를 없애는 것이 표준이었고 추가적으로도 락 범위를 최소화시키기 위해 MSA 구조의 전체 DB가 read-commited 격리레벨을 사용합니다. 락이 그만큼 무서운 것 같네용 RDBMS를 사용하면서 FK를 사용하지 않는 것이 조금 어색해보이긴 하지만... 물리레벨의 관계는 아니더라도 application 단에서 논리적 관계를 맺는 것도 데이터 무결성은 소스코드단에서 더 신경써야겠지만 넓은 의미에서는 여전히 논리적인 관계는 존재하기 때문에 RDBMS 철학과 크게 벗어나지는 않는다고 생각합니다.

 

이 질문으로 생각보다 많은 것을 깨달았고 사고가 조금 바뀌는 계기가 되었다. 결국 트레이드 오프이고, 성능이 일반적인 서비스에서 제일 중요한 가치가 될 것이기 때문에, RDB를 쓰기 때문에 FK는 포기못해! 이런 생각은 위험한 생각일 수 있겠다고 느꼈다.

또한 물리적인 관계 뿐만 아니라 코드로 생기는 논리적인 관계도 RDB 철학과 벗어나지 않는다는 말씀에, 철학과 개념에 나 스스로 가두는 느낌을 받았다. 좀 더 실용적이고 유연한 사고를 가지도록 생각을 바꿔야겠다.

 

FK 얘기가 나온 김에 좀 더 찾아보았는데, 이런 비슷한 고민을 한 사람들과 회사들이 많았다. 재밌었던 것은, Github에서는 FK를 어디에서도 사용하지 않는다고 한다. 정확하는 MySQL용 스키마 이관 도구인 gh-ost 의 얘기라 github 전체를 대변하지는 않는다. [관련 글] 

 

비고

  • 실제로 해보니 thread1은 성공하고 thread2는 실패하는데?
    맞다. 하나의 쓰레드는 성공하고 다른 하나는 deadlock 에러를 뱉으며 실패할 것이다.
    mysql에서 deadlock 감지를 통해 문제되는 transaction 하나를 kill하기 때문에, 하나는 deadlock에서 풀려나서 성공하고, 나머지 하나는 kill되어 exception이 발생하는 것이다.

 

  • 자매품으로 서브쿼리가 있다.
    JPA가 아닌, MyBatis를 사용하는 다른 프로젝트에서도 동일하게 발생 했었다. INSERT 쿼리에 서브 쿼리가 들어가 있었고, 서브쿼리는 조회한 데이터에 S LOCK을 잡는다. 
    그리고 transaction가 끝나지 않고 S LOCK을 잡은 데이터에 update를 시도하며 발생하였다.

 

  • 해당 이슈 원인 분석에 도움이 많이 된 글이 있다. 한 번씩 읽어보면 도움이 될 것이다. [블로그 글]

'JPA' 카테고리의 다른 글

JPA EntityEventListener의 위험성  (0) 2024.08.16
@UpdateTimestamp의 비밀  (1) 2023.04.30

문제상황

JPA를 사용하는 곳에서는 BaseEntity를 사용하는것이 일반적일 것이다.
나도 사용하고 있었고, 모든 데이터는 생성일과 수정일을 가져야한다고 생각하여 다음과 같이 구성했다.

@MappedSuperClass
abstract class BaseEntity {
    @CreationTimestamp
    var createdAt: LocalDateTime = LocalDateTime.now()

    @UpdateTimestamp
    var updatedAt: LocalDateTime? = null
}

생성일과 수정일을 자동으로 기록해준다고 해서 "역시 하이버네이트네" 라는 생각을 하며 별 의심없이 `@CreationTimestamp`, `@UpdateTimestamp`를 사용했다.

 

그런데 웬걸? 생성할때도 updatedAt 에 값이 들어간다...!

Hibernate 공식문서에서는 다음과 같이 설명한다 (6.1.7 Final version)

 

The @UpdateTimestamp annotation is an in-VM INSERT strategy. Hibernate will use the current timestamp of the JVM as the insert and update value for the attribute.

 

 

생성과 수정은 엄연히 다른것임을... 하이버네이트에 화가 나서 하나하나 뜯어보기로 했다

 

@UpdateTimestamp 해부

어노테이션의 코드를 보면 다음과 같다.

@ValueGenerationType(generatedBy = UpdateTimestampGeneration.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ FIELD, METHOD })
public @interface UpdateTimestamp {
}

 

그럼 결국 UpdateTimestampGeneration 을 봐야한다는 얘기네? 그럼 또 봐야지

public class UpdateTimestampGeneration implements AnnotationValueGeneration<UpdateTimestamp> {

    private ValueGenerator<?> generator;

    @Override
    public void initialize(UpdateTimestamp annotation, Class<?> propertyType) {
        generator = TimestampGenerators.get(propertyType);
    }

    @Override
    public GenerationTiming getGenerationTiming() {
        return GenerationTiming.ALWAYS;
    }

    @Override
    public ValueGenerator<?> getValueGenerator() {
        return generator;
    }

    @Override
    public boolean referenceColumnInSql() {
        return false;
    }

    @Override
    public String getDatabaseGeneratedReferencedColumnValue() {
        return null;
    }
}

 

보면 getGenerationTimimg()을 가지고 값의 generation 타이밍을 결졍한다. ALWAYS 로 되어있네...? 그럼 GenerationTiming enum 클래스를 확인해보자

public enum GenerationTiming {
    NEVER {
        @Override
        public boolean includesInsert() {
            return false;
        }

        @Override
        public boolean includesUpdate() {
            return false;
        }
    },
    INSERT {
        @Override
        public boolean includesInsert() {
            return true;
        }

        @Override
        public boolean includesUpdate() {
            return false;
        }
    },
    ALWAYS {
        @Override
        public boolean includesInsert() {
            return true;
        }

        @Override
        public boolean includesUpdate() {
            return true;
        }
    };
...
}

 

NEVER, INSERT, ALWAYS 세 가지 뿐이다. 잡았다! 생성할때는 INSERT, 수정할때는 ALWAYS를 사용해서 생성할때 createdAt, updatedAt 모두 생성되고, 수정할때는 updatedAt만 생성되는거군!

 

음... 그럼 인터페이스는 어떻게 되어있는지 알겠고, 실제로 어떻게 동작하는 것인지 궁금해진다. Interceptor가 Update 쿼리 생성 전에 가로채서 updatedAt에 값을 넣는것인가?

 

동작 원리

우선, UpdateTimestampGenerationinitialize()는 서버가 뜰 때 PropertyBinder에 의해서 호출된다.

// PropertyBinder의 instantiateAndInitializeValueGeneration 함수 내부
    AnnotationValueGeneration<A> valueGeneration = (AnnotationValueGeneration<A>) generationType.newInstance();
    valueGeneration.initialize(
        annotation,
        buildingContext.getBootstrapContext().getReflectionManager().toClass( property.getType() ),
        entityBinder.getPersistentClass().getEntityName(),
        property.getName()
    );

    return valueGeneration;

 

PropertyBinder 는 최종적으로 EntityBinder에 의해 호출되고, EntityBinderEntityManagerFactoryBuilderImpl에 의해서 호출된다. 그래서 그게 무슨뜻인데?

 

서버가 뜰 때 EntityManager가 미리 EntityBinder를 통해 entity에 붙어있는 어노테이션들을 다 읽어서 entity를 바인딩 해 놓고, 어노테이션에서 정의해 둔 대로 동작하게 하겠다는 뜻이다.

 

초기 세팅은 알겠고, 그래서 엔티티 변경이 되었을때 어떻게 동작하는데?

 

Entity에서 변경이 일어나면, Repository.save() 혹은 Transaction commitflush() 가 호출된다. 그럼 DefaultFlushEntityListener에서 flush 동작을 가로채서 onFlushEntity() 함수에서 ActionQueue에 EntityUpdateAction을 추가한다. 그리고 ActionQueue에 있는 EntityAction 들을 순차적으로 실행한다. 이 때 추가한 EntityUpdateAction이 실행되는데,

// EntityUpdateAction의 execute() 함수 내부
...
    persister.update(
        id,
        state,
        dirtyFields,
        hasDirtyCollection,
        previousState,
        previousVersion,
        instance,
        rowId,
        session
    );
...

 

위 코드에서 실질적으로 수정이 일어난다. persister.update() 내부를 들여다보면, entityModel의 hasPreUpdateGeneratedValues 즉 update 전에 generated 된 value가 있는지 확인하는 곳이 있다. 이정도 열여봤으면 이해를 얼추 다 한 것 같다.

 

정리하자면, entity에 추가한 어노테이션들은 서버 Boot 시 EntityBinder에 의해 바인딩되고, flush가 발생했을 때 ActionQueue에 UpdateAction이 들어가고, UpdateAction을 실행하면서 updatedAt이 변경되고, update 쿼리가 발생한다.

 

해결방법

1. Hibernate(>v6.2)부터 @CurrentTimestamp 를 사용하여 원하는 상황에서 Timestamp를 업데이트 할 수 있도록 기능을 제공한다.


Hibernate도 3가지 상황(NEVER, INSERT, ALWAYS)만을 고려한 점이 마음에 들지 않았는지 CurrentTimestamp에서는 INSERT와 UPDATE를 분리하였다.

@ValueGenerationType(generatedBy = CurrentTimestampGeneration.class)
@Retention(RUNTIME)
@Target({ FIELD, METHOD, ANNOTATION_TYPE })
public @interface CurrentTimestamp {
/**
 * Determines when the timestamp is generated. But default, it is updated
 * when any SQL {@code insert} or {@code update} statement is executed.
 * If it should be generated just once, on the initial SQL {@code insert},
 * explicitly specify {@link EventType#INSERT event = INSERT}.
 */
EventType[] event() default {INSERT, UPDATE};

/**
 * Determines when the timestamp is generated. But default, it is updated
 * when any SQL {@code insert} or {@code update} statement is executed.
 * If it should be generated just once, on the initial SQL {@code insert},
 * explicitly specify {@link GenerationTiming#INSERT timing = INSERT}.
 *
 * @deprecated This was introduced in error
 */
@Deprecated(since = "6.2") @Remove
GenerationTiming timing() default GenerationTiming.ALWAYS;

/**
 * Specifies how the timestamp is generated. By default, it is generated
 * by the database, and fetched using a subsequent {@code select} statement.
 */
SourceType source() default SourceType.DB;
}

 

2. JPA에서 제공하는 @PreUpdate를 사용하자


BaseEntity와 같은 근간이 되는 클래스에서 Hibernate에 의존성을 가지고 있는건 안전하지 않다고 생각한다. 따라서 표준인 JPA를 사용하는것은 어떤가.
JPA @PreUpdate를 사용하게 되면, flush() 가 발생헀을 때 FlushEntityEventListener가 받아서 CallbackRegistry 를 호출한다. 그럼 EntityCallback 이 @PreUpdate의 함수를 실행하게 된다. 동작방식이 EntityAction이냐, EntityCallback이냐의 차이만 있을 뿐 거의 동일하다.

 

여기서 주의할점은 Callback 방식이 Action 방식보다 먼저 실행된다! (코드상 앞에 존재한다)

 

3. JPA의 EventListener를 사용하자!

 

EventListener는 @PreUpdate와 동작 원리가 완전히 동일하다. 한가지 다른점은 EventListener가 EntityBinder에 의해 먼저 바인딩 되어 flush() 호출 시 EventListener 가 먼저 호출된다는 점이다.


여러 Entity에서 공통으로 사용될 Listener를 정의하고 싶다면 사용해도 좋을 것 같다.

 

피드백

팀원들에게 이 정보들을 공유한 결과 2가지의 질문이 나왔다.

  1. Spring Data JPA의 @CreatiedDate@LastModifiedDate 를 사용하는건 어떤가?

    사용하는 프로젝트는 멀티 모듈 구조를 가지고 있었고, BaseEntity는 CORE 모듈에 존재했다. 그러므로 data-jpa 의존성을 가지게 되면 datasource를 갖지 않는 모듈에서 core 모듈을 import할 때 exclude를 해야하는 번거로움이 생긴다.
    그리고 두 어노테이션 모두 spring data가 제공하는 AuditingListener를 사용하는 것이라서 Listener를 사용하는것과 동일한 방법이다.
  1. @Column(insertable=false) 를 같이 사용하는건 어떤가?

    이 설정은 Insert 쿼리에서 해당하는 필드를 제거하는 설정이다.
    따라서 영속성 컨텍스트에는 updatedAt이 적용되지만, 쿼리에는 해당 컬럼이 빠지게 되어 DB에는 반영이 되지 않는다. 그럼 다음과 같은 상황에서 문제가 발생한다.이 경우 DB에는 반영이 되지 않지만 return값은 updatedAt이 존재한채로 return값이 내려간다.
    이런 혼동을 주는것보다는 Entity Callback을 사용하는게 더 좋아보인다. (영속성 컨텍스트와 DB 간의 간극이 생기면서부터 버그 발생 위험이 높아진다고 생각한다.)

 

결론

Hibernate의 @UpdateTimestamp는 생성시에도 값이 적용된다.
JPA가 제공하는 기능들 (@PreUpdate, EventListener) 를 사용하여 내 입맛대로 사용하자!

'JPA' 카테고리의 다른 글

JPA EntityEventListener의 위험성  (0) 2024.08.16
무서운 @OneToMany  (0) 2024.08.16

+ Recent posts