동시성 접근 제어를 위해 DataBase 나 Redis Lock를 이용하는데요. 두 가지 선택지를 직접 구현해보고 각각의 동작 원리를 이해해보는 시간을 가져보았습니다. DB lock 과 Redis lock 의 트레이드오프 지점은 다음과 같이 거론되고 있습니다.
DataBase Lock를 이용하면 추가적인 인프라 구성 없이 동시성 문제를 해결할 수 있다.
하지만 Lock 획득을 위해 대기하는 Connection 이 증가할 수 있고, 이건 높은 트래픽 상황에서 성능 저하로 이어지는 포인트다.
Redis를 이용한 Distributed Lock은 DataBase Connection 증가를 방지할 수 있지만, 별도의 관리가 필요하다.
Redis 관리에는 메모리 최적화, 장애 복구, 데이터 일관성 유지 등의 과제가 부가적으로 필요하다는 의미다.
사실 트래픽이 아주 아주 많아서 다중 서버가 띄워진 환경 + 트랜잭션이 많이 일어나는 서비스라면 Redis 를 이용한 lock 을 사용하는 것이 이론상 바람직해보이긴 하네요...
근데 DataBase Lock 으로도 충분히 버틸 수 있는 상황이라면 아래 구현 내용도 참조할만 할 것 같습니다.
DataBase Lock
JPA 를 활용한 DataBase Lock 방법에는 Optimistic Lock 방식과 Pessimistic Lock 방식이 존재합니다. Optimistic Lock 부터 확인해보았습니다.
Optimistic Lock
ItemRepository.findById 메소드는 JpaRepository에서 기본 정의되어 있는 QueryMethod를 활용한 것이며 JMeter를 활용하여 decrease() 메소드를 1000번 실행시켜보았습니다.
결과는 51% 예외가 발생했습니다.
예외는 ObjectOptimisticLockingFailureException 입니다. 해당 예외는 Spring 애플리케이션에서 Hibernate 를 사용하면서 발생하는데요. 충돌 업데이트를 방지하며 데이터 무결성을 보장하는 이 방식은 낙관적 락을 통한 동시성 제어 매커니즘입니다. ObjectOptimisticLockingFailureException 은 데이터 베이스의 버전이 update 쿼리에서 지정한 버전과 일치하지 않기 때문에 예상한 업데이트가 발생하지 않도록 하는 예외입니다. statement executed: update item set quantity=?, version=? where id=? and version=? 은 id 와 version이 일치하는 행에서 item을 update 하려고 했던 시도입니다. 이 시도에서 결과는 다음과 같습니다.
Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
해석해보면 한개의 행을 업데이트 하기를 예상했지만 실제로는 0개의 행이 업데이트 되었고, 해당 에러 메세지가 시사하는 바는 행이 읽힌 후 업데이트 되기 전에 다른 트랜잭션에 의해 변경되었음을 의미합니다. StaleStateException 예외는 세션의 엔티티 상태가 더 이상 데이터베이스와 일치하지 않는 낙관적 락 실패의 특정 유형입니다.
참고로 Optimistic Lock 는 flush 되는 시점에 version 속성의 값을 비교하는 방식이기 때문에 retry 구현이 추가적으로 수행되어야할 수 있습니다. 비관적 락은 어떻게 다른지 비교해보겠습니다.
Pessimistic Lock
Row Level 로 존재하는 Shared Lock 과 Exclusive Lock 중, Exclusive Lock 으로 Spring Data JPA 로 구현해보았습니다. LockModeType.PESSIMISTIC_WRITE 로 수행하면 됩니다.
QueryMethod 에서는 @Lock 을 통해 해당 쿼리가 실행될 때 Lock 획득하는 쿼리가 추가됩니다. 동일한 세팅값으로 JMeter 를 실행해보았습니다.
비관적 잠금의 경우 예외 발생 0% 로 모든 요청이 성공하였습니다. 해본 김에 QueryMethod annotation 방식 말고 직접 EntityManager 를 이용해보는 방법도 직접 살펴보았습니다.
기본 정의된 QueryMethod 로 돌아가서 findById 조회 이후 해당 entity 를 entityManager.lock() 을 통해 lock 을 획득해보겠습니다.
에러율이 90% 대 까지 올라갔습니다.
StaleObjectStateException 이 발생했네요. 이유는 다음과 같습니다.
Hibernate: select i1_0.id,i1_0.count,i1_0.name,i1_0.version from item i1_0 where i1_0.id=?
Hibernate: select id from item where id=? and version=? for update
Hibernate: update item set count=?,name=?,version=? where id=? and version=?
로그와 같이 엔티티 조회 이후, 다시 update 를 위한 Entity 조회(이 과정에서 lock 획득)가 한번 더 이루어지기 때문에 중간에 lock 이 없는 빈 시간이 존재하게 됩니다. 그 시점 차에 이미 다른 트랜잭션에서 update 가 이루어져 StaleObjectStateException 이 발생합니다. 구현을 수정해보겠습니다.
예외율이 다시 0% 로 떨어졌습니다. query log 는 다음과 같습니다.
Hibernate: select i1_0.id,i1_0.count,i1_0.name,i1_0.version from item i1_0 where i1_0.id=? for update Hibernate: update item set count=?,name=?,version=? where id=? and version=?
여기까지 DataBase 를 활용한 동시성 컨트롤 시도과정이었습니다. 낙관적 락과 비관적 락, 그리고 entityManager 를 통한 락 획득 매커니즘 차이를 확인해볼 수 있었습니다. Redis 도 마찬가지로 과정을 밟아보았습니다. 사실 이 지점부터 알짜배기로 배울 수 있었습니다.
Redis Lock
Redis 는 분산락을 위해 RedLock 알고리즘을 제공하는데 Java 진영에서 사용할 수 있는 라이브러리가 바로 Redission!
implementation 'org.redisson:redisson-spring-boot-starter:3.23.2'
redission 으로 분산락을 구현한 코드는 여기에 더 자세히 살펴보실 수 있습니다.
Redis 도 Optimistic Lock 먼저 테스트 해보았습니다.
마찬가지로 ObjectOptimisticLockingFailureException 예외가 발생했습니다.
바로 Optimistic Lock 적용하지 않은 케이스도 테스트 해보겠습니다.
이 경우 예외는 발생하지 않았지만, 재고가 남아있습니다. 재고 만큼 요청을 했는데도 예외 없이 이런 결과가 어떻게 나온 걸까요?
요청과 달리 재고가 틀어진 이유
요청 1과 요청 2가 있습니다. 요청 2는 요청 1이 선점하던 Lock 이 반납되자 마자 획득하여 로직을 수행합니다. 이때의 데이터는 요청 1이 읽던 데이터와 동일합니다. 트랜잭션이 아직 종료되지않아 commit 이 안됐기 때문입니다. hibernate 에서 StealStateException 이 발생하는 이유입니다. 요청 1의 lock 반납과 트랜잭션 순서를 로그로도 파악해보았습니다.
그럼 어떻게 하지?..
두 개의 해결 방안이 있을 것 같은데요. 먼저 @TransactionalEventListener 를 이용해보겠습니다.
해결방안 1. @TransactionalEventListener
@TransactionalEventListener 를 활용하면 트랜잭션의 수행 결과에 따라 이벤트를 발행하고 싶은 경우 사용할 수 있습니다. 저는 제시한 문제를 해결하기 위해 트랜잭션 종료 뒤, 이벤트를 발행하여 Distributed Lock 을 반납하는 방식으로 이용해보려고합니다.
unlock(RLock rLock) 메소드가 포인트입니다. 트랜잭션이 종료되는 이벤트를 받아 메소드를 수행하여 lock 을 반납합니다. lock 획득을 대기하고있던 요청이 그때 데이터를 읽고 로직을 수행합니다. 테스트를 바로 해보겠습니다
동시성 문제없이 정상적으로 처리할 수 있게 되었는데요, 처리 흐름을 그림으로 파악하자면 다음과 같습니다.
요점은 Transaction 종료 이후, 그러니까 commit 이후에 lock 반납이 이루어져야한다는 것으로 이해하면 되는데요. 이 원리대로라면 Transaction 을 분리하는 방법도 있습니다.
해결방안 2. Spring Transaction 분리
이 방식도 그림으로 파악하자면 다음과 같습니다.
간단하게 Transaction 과 lock 반납 과정에서 동시성을 해결할 수 있는 방법을 확인해보긴했지만, 두 해결 방법 중에 어떤 것을 언제 사용해야하는지 고민이 들었습니다. 원리가 달라서입니다. 일단 해결 방안 1 은 기존 Transaction 에서 애플리케이션 이벤트를 이용하는 방식으로 lock 획득 이후의 transaction 을 기존과 동일한 transaction 으로 포함시킬 수 있습니다.
해결 방안 2의 경우에는 반드시 lock 획득 이후 로직이 항상 새로운 transaction 에서 실행되므로 lock 획득 이전과 lock 획득 이후 로직에 대해서 고려가 함께 필요합니다.
뽀나스. Annotation & Aop 적용
위에서 적용한 코드들을 BolierPlate 로 만들어놓을 수 있을 것 같아 보너스 구현해보았습니다.
Aspect 의 @Around 에서 @RedLock 을 PointCut 으로 설정하여 해당 JoinPoint 가 실행하기 이전에 tryLock 을 통해 락 획득하고 이후에 finally 에서 ApplicationEvent 를 발행하여 락을 반납하는 로직입니다.
마찬가지로 에러율 없이 성공한 것을 볼 수 있습니다.
Hikari Connection Pool & Tomcat Max Worker Threads
그런데, 그런데, 그런데
테스트를 하다가 요청수를 급증시키면 TimeOut 이 발생할 수 있습니다. Hikari DeadLock 발생이 원인이었는데, 왜 그런 걸까요..
위와 같은 순서에 의해서 Hikari DeadLock 상태가 발생하고 분산락 획득 대기를 하고 있는 Thread 의 Connection 들과 lock 획득은 했으나 새로운 Connection 획득을 대기하는 다른 Connection 들에서 TimeOut 이 발생하고 있었습니다.
실제 상황이라면 해당 로직에서 추가의 Connection 을 필요로 할 수도 있습니다. 당장 간단한 해결책으로는 Hikari Database Connection Pool 의 갯수를 Worker Thread pool 의 갯수보다 여유롭게 설정하면 해결됩니다. 이건 그냥 무식하게 생각하는 방향이고, 분산 lock 획득 대기하는 Thread 만 증가하여 의미 없는 Connection 만 증가하고 Database 의 부하만 발생할 수 있는 여지가 있습니다.
Worker Thread Pool 의 Max 개수를 줄여 Connection 을 획득할 수 있는 Thread 의 개수를 제한하면 실제 작업을 실행할 Thread 의 개수도 줄어들어 다른 종류의 요청 처리가 지연됩니다.
결국엔 해당 애플리케이션이 어떤 성격이냐에 따라 알아서 잘 조절해야하는 영역인 것 같습니다. 사실 Redis 와 DB Lock 도 어느 것이 더 났겠느냐 직접 확인하는 과정을 거치긴했지만, 그것도 뭐 양적 판단을 통해 질적으로 고려해야하는 영역입니다. 물론 이런 결정들은 "이게 정석이야" 라는 게 없어서 어렵긴 하지만요, 코카콜라로 정하는 거면 얼마나 좋게요
'생각' 카테고리의 다른 글
SpringKafka Consumer 설정값으로 요리조리 테스트 해보기 (0) | 2024.08.06 |
---|---|
Commit 실패된 메세지 Producer 가 책임질래 Consumer 가 책임질래 (0) | 2024.07.27 |
동시성과 gap-lock (3) | 2024.05.02 |
성능과 일관성을 무게추에 달아보자 On Redis ! (0) | 2024.04.30 |
오늘도 기획자한테 안 된다고 말했다. (0) | 2024.03.30 |