용로그
article thumbnail

들어가며


이번 글에서는 팀 프로젝트의 어드민 페이지에서 데이터 수정에 관련한 동시성 이슈를 알아보려고 합니다. 저희 서비스의 관리자 페이지는 유저들에게 직접적으로 보이는 식품 데이터를 생성/수정/삭제할 수 있는 기능을 제공합니다.

 

어드민 페이지

선착순 시스템 등과 갑자기 많은 트래픽이 몰리진 않겠지만, 유저와 사료를 파는 브랜드에게 직접적인 영향이 가는 영역이기 때문에 한 번 발생하게 된다면 서비스 운영에 치명적으로 작용할 수 있습니다.

 

어떤 문제가 발생할까


그렇다면 만약 2명의 어드민이 동시에 하나의 식품을 수정하게 된다면 어떤일이 발생할까요? 아래는 사료 데이터를 수정하는 메서드 2개를 동시에 실행하는 테스트 코드입니다.

 

어떤 결과가 나올지 짐작이 가시나요?

저는 처음에 낙관적 락과 비관적 락을 모두 사용하지 않은 상태였기 때문에 두 번의 갱신 분실 문제가 생길 거라고 생각했습니다. 즉, request1과 request2 중 랜덤 한 결과로 수정될 거라 예상한 거죠. 그런데 예상치 못한 일이 발생합니다.

 

예상하지 못한 ObjectOptimisticLockingFailureException


ObjectOptimisticLockingFailureException

필자가 사전에 알고 있는 지식으로는 ObjectOptimisticLockingFailureException은 Hibernate에서 낙관적 락과 관련된 에러가 발생했을 때 던지는 예외였습니다. 즉, @Version 어노테이션이 존재해야 해당 예외가 발생할 것이라고 생각했습니다.

 

하지만, 동시성에 관련한 처리를 아무것도 하지 않은 상황에서 해당 에러가 발생하니 매우 혼란스러웠습니다. 동시성 관련 에러는 디버깅으로 문제를 찾기 쉽지 않습니다. 그래서 try-catch 구문을 추가하여 어떤 예외가 발생하고 왜 발생하는 건지 추적해 보았습니다.

 

StaleObjectStateException

catch 구문에 braek point를 찍고 디버깅을 했더니 StaleObjectStateException이 있었습니다. 해당 예외를 알아보았더니 StaleStateExeption 예외를 ObjectOptimisticLockingFailureException으로 wrapping 해서 발생시킵니다. 해당 예외가 발생하는 경우는 다음과 같습니다.

  1. 버전 번호 또는 타임스탬프 확인이 실패할 경우
  2. 다른 세션 또는 트랜잭션이 동일한 데이터를 수정한 경우
  3. 데이터베이스 행이 존재하지 않는 경우 엔터티를 업데이트하거나 삭제하려고 시도하는 경우 예상한 행 수와 실제로 영향을 받은 행 수가 일치하지 않을 경우

StaleObjectStateException 디버깅

이번엔 StaleObjectStateException을 던지는 부분에 모두 break point를 찍어놓고 디버그를 해보았습니다.

 

ModelMutationHelper 클래스의 identifiedResultsCheck 메서드

identifiedResultsCheck라는 메서드에서 발생하는 예외였는데, 해당 메서드의 역할은 다음과 같습니다.

  1. StaleObjectStateException은 Hibernate에서 발생하는 영속성 관련 예외 중 하나로 "stale" 또는 "old" 데이터를 수정하려고 할 때 발생한다.
  2. 변경된 행의 개수가 0이라면 StaleObjectStateException을 던진다. 이는 다른 트랜잭션에서 이미 데이터를 변경했고, 현재 트랜잭션에서는 해당 데이터를 변경할 수 없음을 나타낸다. 즉, 데이터 충돌이 발생한 상황이다.

요약하면 해당 메서드에서 StaleObjectStateException이 발생했고, 다른 트랜잭션에서 이미 해당 데이터를 수정한 후 현재 트랜잭션에서 해당 데이터를 변경하려고 할 때 발생합니다. 이 내용을 보면 트랜잭션 또는 세션이 충돌했다는 에러 메시지와 동일한 역할을 합니다.

 

실제로 어떤 클래스의 어떤 메서드가 identifiedResultsCheck를 호출해서 예외가 터지는지 찾아보았는데, JPA에서 변경감지가 일어날 때 UpdateCoordinatorStandard 클래스의 doStaticUpdate라는 메서드가 실행됩니다.

 

DeleteCoordinator 클래스의 doStaticDelete 메서드

그리고 doStaticUpdate 메서드는 DeleteCoordinator 클래스의 doStaticDelete라는 메서드를 호출하는데, 이때 기존의 엔티티가 존재하지 않는다면 affectedRowCount를 0으로 만든 뒤 identifiedResultsCheck 메서드를 호출하여 StaleObjectStateException을 발생시킵니다.

 

실행 시나리오

JPA 내부적으로 엔티티를 수정하는 게 아니라 추가를 먼저 한 뒤 삭제를 하는 것이었습니다.

 

AdminService

어떻게 다른 트랜잭션에서 데이터가 변경된 걸 감지할 수 있을까


이 때 영속성 컨텍스트는 어떻게 스레드 로컬인데 다른 트랜잭션이 데이터를 변경했는지 알 수 있을까요? 예외 메시지에서 StaleObjectStateException이 발생한 이유는 다른 세션 또는 트랜잭션이 동일한 데이터를 수정한 경우라고 나와있습니다.

 

데이터베이스를 통해 다른 트랜잭션이 수정한 데이터를 알 수 있는 것도 아니고, 영속성 컨텍스트 안에 있는 1차 캐시가 공유되는 것도 아니라면 대체 어떻게 다른 트랜잭션에서 데이터가 변경된 걸 감지할 수 있는 걸까요?

 

 

만약 위와 같은 구조였다면 당연하게도 서로 다른 스레드와 트랜잭션이 영속성 컨텍스트에서 데이터를 바꿨을 때 알 수 있겠지만, 아래처럼 각각의 스레드에서 실행되는 트랜잭션이 서로의 상황을 알아야 해당 예외를 발생시킬 수 있다는 뜻인데 그게 가능한 일이었을까요?

 

스레드와 영속성 컨텍스트의 관계

2차 캐시의 존재

그때 Hibernate의 공유 캐시, 즉 2차 캐시(second level cache, L2 cache)의 존재가 생각났습니다. 2차 캐시는 스레드가 아닌 애플리케이션 범위의 캐시입니다.

 

2차 캐시 활용

따라서 애플리케이션을 종료할 때까지 캐시가 유지되는데, 이를 사용하면 엔터티 매니저를 통해 데이터를 조회할 때 먼저 2차 캐시에서 찾아보고, 없으면 데이터베이스에서 찾습니다. 그런데 2차 캐시는 직접 사용하겠다고 별도의 설정을 해줘야 사용하는데, 필자는 별도의 설정을 하지 않고도 해당 예외가 발생했으니 2차 캐시 문제는 아닌 것 같았습니다.

 

Hibernate Session과 Race Condition

이 질문에 대답하기 위해서는 Hibernate Session을 다시 알아봐야 합니다.

 

Hibernate 공식 문서에서는 다음과 같은 설명이 있습니다.

A Session is not thread-safe. Things that work concurrently, like HTTP requests, session beans, or Swing workers, will cause race conditions if a Session instance is shared. If you keep your Hibernate Session in your HttpSession (this is discussed later in the chapter), you should consider synchronizing access to your Http session. Otherwise, a user that clicks reload fast enough can use the same Session in two concurrently running threads.

 

Hibernate Session은 thread-safe 하지 않으며, 동시 요청이 발생하면 Race Condition이 발생한다고 합니다. 또한 동시에 2개의 요청이 들어오게 되면 하이버네이트 내부적으로는 스레드 당 하나의 세션이 아닌, 하나의 세션을 여러 개의 스레드가 공유할 수 있다고 합니다.

 

이 때 까지는 스레드 당 영속성 컨텍스트와 세션의 라이프 사이클이 무조건 thread-safe 한 줄 알았는데, 세션이 항상 thread-safe 하다는 것은 상당히 잘못된 지식이었습니다.

 

결론적으로 데이터베이스에 flush 될 때 발생하는 문제도 아니고, 2차 캐시를 사용하는 것도 아니고 별도의 공유 저장소가 있는 것도 아닌 하나의 세션에 대한 Race Condition으로 발생한 문제였습니다.

 

실제로 이 문제 삽질을 하면서 국내외 관련 자료가 너무 없어 Hibernate Atlassian에 이슈를 남겨 놓았으며, 하이버네이트의 아버지(?) Gavin King의 답변은 다음과 같습니다. 

세션이 공유되는 문제는 Session을 직접 GET하거나 Entity Manager를 직접 호출하는걸 권장한다.

 

즉, 하이버네이트 측에서도 세션에 대한 동시성 문제를 충분히 인지하고 있지만 이를 방지하기 위해서는 개발자가 직접 관리해주는게 적절하다는 것입니다.

 

마치며


여담으로 ObjectOptimisticLockingFailureException은 Hibernate가 아니라 SpringFramework에서 관리하는 예외라고 하네요. (꼼꼼히 살펴볼걸 ㅋㅋㅋ;;)

 

결론적으로는 동시성 처리를 해주지 않았는데 정상적으로 예외 처리가 된다고 별도의 락 처리를 해주지 않는다면 큰 문제가 생길 수 있다는 것입니다. 이런 동작 방식은 어디까지나 운에 의해 동작하는 것입니다. 개발자가 예측하기 너무 힘들다고 생각해요.

 

이번 글에서는 JPA가 어떻게 다른 트랜잭션의 데이터 변경을 감지했는지, 그리고 변경 감지의 동작 원리를 상세하게 알아보았습니다. 다음 글에서는 동시성 관련 이슈를 직접 해결해 보겠습니다.

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05