본문 바로가기
Java/트러블 슈팅

synchronized vs Pessimistic Lock vs Distributed Lock

by oneny 2023. 12. 21.

synchronized vs Pessimistic Lock vs Distributed Lock

DB의 무결성을 보장하기 위해서는 다양한 방법이 있다. 물론 synchronized는 적합한 방법은 아니지만 왜 적합하지 않은지에 대해 살펴보고, 그 뒤 비관적 락(Pessimistic Lock)과 분산락(Distributed Lock)을 통해 성능 비교를 해보자.

 

재고 차감 로직을 통한 DB 부정합 문제 살펴보기

위 ProductService는 주문 관련 비즈니스 로직 중 하나로 "상품 조회 -> 상품의 재고 차감 가능 여부 확인 -> 재고 차감"의 순서로 이루어지는 로직이다. 해당 비즈니스 로직을 통해서 어떻게 DB 부정합 문제가 발생하게 되는지 테스트 코드를 작성해보자.

 

위 테스트 코드는 한 번에 100개의 스레드가 동시에 위에서 살펴본 비즈니스 로직을 통해 DB에 재고 차감을 요청하는 로직이다. 그리고 마지막에 100개의 요청이 모두 완료된 후 해당 상품을 다시 조회했을 때 재고 상태를 확인하는데 위 로직대로라면 당연히 재고가 100라면 0개가 되어야 한다고 예상할 수 있다.

 

하지만 예상과는 달리 0개에는 한참 못미치는 75개의 재고가 남아있는 것을 확인할 수 있다. 왜 이러한 부정합 문제가 발생한 것일까? 간단히 얘기하자면 MySQL의 InnoDB 스토리지 엔진이 기본적으로 REPEATABLE READ 격리 수준을 사용하지만 FOR UPDATE나 FOR SHARE 절을 가지지 않는 SELECT 쿼리는 잠금 없는 읽기가 지원되기 때문에 Lost Update와 같은 이상 현상이 발생할 수 있어 다른 트랜잭션에서 작업한 내용을 날려버리는 문제가 발생할 수 있다. 자세한 내용은 아래 블로그를 통해서 공부한 적이 있기 때문에 관련 링크만 남기고 이를 해결하기 위한 방법에 대해서 살펴보려고 한다.

 

상품 구매 트랜잭션으로 보는 부정합 문제

트랜잭션 이상 현상에 따른 정합성 문제 트랜잭션과 잠금 트랜잭션과 잠금 트랜잭션 트랜잭션은 꼭 여러 개의 변경 작업을 수행하는 쿼리가 조합됐을 때만 의미 있는 개념은 아니다. 트랜잭션

oneny.tistory.com

 

synchronized

먼저 synchronized에 대해서 간단히 설명하자면 멀티스레딩 환경에서 여러 스레드가 하나의 메서드에 접근하려는 경우 경합이 일어나면서 컨텍스트 스위칭으로 인해 몇몇 스레드의 작업 내용이 날아가버리는 문제가 발생할 수 있기 때문에 synchronized 키워드로 메서드에 선언하거나 매서드 내 블록단위로 지정하여 해당 영역을 하나의 스레드만 접근할 수 있도록 만들어 동시성을 제어할 수 있게 지원하는 기능이다. 그러면 syncrhronized를 적용해보자.

 

위처럼 전체 메서드에 synchronized를 선언하여 해당 decrease 메서드는 이제 하나의 스레드만 접근이 가능하다. 그러면 이제 100개의 재고를 100개의 스레드가 동시에 요청했을때 재고가 0개로 남는지 테스트를 다시 돌려보자.

 

synchronized 키워드를 사용해서 재고가 0개가 될 것이라고 예상했지만 여전히 동시성 문제를 해결해주지 못하는 것으로 확인됐다. 왜 이런 문제가 발생했을까? 이는 위 synchronized 키워드 위에 작성된 @Transactional 어노테이션에 주의할 필요가 있다.

 

@Transactional을 사용하면 위처럼 프레임워크에서 프록시 패턴을 사용하여 비즈니스 로직 부분 외의 트잭션 처리 관련 로직이 추가된 트랜잭션을 지원하는 프록시 객체(위는 JDK Dynamic Proxy를 사용했는데 실제는 CGLIB를 사용한다)를 사용하게 된다. 위에서 살펴볼 부분은 synchronized 시점과 commit 시점이 다르다는 것이다. 즉, 하나의 스레드가 synchronized 작업이 끝나 DB에 commit을 할 때 다른 스레드가 decrease 메서드에 접근하여 상품을 조회하는 것이 더 빠르다면 Lost Update 이상 현상을 여전히 일어나 동시성 문제를 해결해주지 못한다. 이 외에도 분산 환경에서 synchronized를 사용하는 경우에는 이 synchronized는 하나의 서버가 다른 서버에서 synchronized로 락이 걸린지 확인할 수 없기 때문에 이러한 이유를 추가로 동시성을 해결해주지 못한다.

 

Pessimistic Lock

비관적 락도 위에 공유한 상품 구매 트랜잭션으로 보는 부정합 문제에서 다룬 적이 있기 때문에 간단히 얘기하자면 트랜잭션이 데이터에 접근할 때 해당 데이터에 락을 설정하여 다른 트랜잭션이 동시에 접근하지 못하게 하는 방식이다. 이는 데이터의 일관성을 유지하고 동시성을 제어하여 데이터의 무결성을 보장하는데 사용된다. 이 방식 외에 낙관적 락 방식도 있지만 이 방식을 구현하지 않는 이유는 아래와 같다.

 

낙관적 락을 구현하지 않은 이유

낙관적 락을 구현하지 않은 이유는 낙관적 락은 DB에서 락을 거는 것이 아닌 어플리케이션에서 락을 구현하는 것이기 때문에 많은 사용자 요청이 왔을 때 충돌이 빈번하게 일어나는 경우에 더 많은 요청을 DB에 보내게 되므로 충돌이 잦을 수 있는 상황에는 맞지 않은 것 같다. 대신 충돌이 적은 기능에 대해서는 낙관적 락이 비관적 락보다 빠르다고 할 수 있다. 하지만 주문 관련 트랜잭션에는 적합하지 않다고 판단했고, 비관적 락을 통해 먼저 구현해보자.

 

SELECT ... FOR UPDATE

위처럼 FOR UPDATE 절은 쓰기 잠금(배타 잠금, Exclusive Lock, X-Lock)을 설정하고, 다른 트랜잭션에서는 그 레코드를 변경하는 것 뿐만 아니라 읽기(FOR SHARE 절을 사용하는 SELECT 쿼리)도 수행할 수 없다. 이제 동시성을 제어할 수 있는지 다시 테스트를 돌려보자.

 

테스트는 성공적으로 통과한 것을 확인할 수 있고, Thread Pool 32개의 100개의 요청을 482ms이 걸렸다. 또한, 비관적 락을 사용하여 Lost Update 이상 현상으로 인한 부정합 문제를 해결할 수 있다.

 

Distributed Lock

더 나은 방식이 있는지 살펴보던 중 비관적 락을 이외의 분산락을 통해 동시성을 제어하는 방법도 있다는 것을 확인했다. 분산락이란 위 방식과 비슷하게 여러 스레드가 동시에 동일한 자원에 접근할 때 데이터의 결합이 발생하지 않도록 원자성(atomic)을 보장하는 기법이다.

 

위 사진을 보면 하나의 Redis에서 subscribe 명령어를 통해 ch1이라는 채널을 구독하고, 다른 레디스 터미널에서 publish 명령어를 통해 ch1 채널에 hello 메시지를 보내줬다. 그러면 ch1 채널을 구독하고 있는 곳에서 Hello 메시지를 받는 것을 확인할 수 있다.

이러한 방식으로 Redis에서 Redisson을 통해서 하나의 채널을 만들고 pub-sub 기반으로 하나의 스레드가 락을 점유하고 있다 해제했을 때 채널에 메시지를 보내줌으로써 구독하고 있는(락 획득을 해야 하는) 스레드들이 메시지를 받아 락 획득을 시도하는 방식으로 분산락을 구현할 수 있다.

 

위 코드는 Redisson을 사용하여 분산 락을 구현한 클래스이다. redissonClient.getLock(id.toString())를 통해 상품 ID에 해당하는 락을 얻는다. 그리고 lock.tryLock(10, 1, TimeUnit.SECONDS)를 호출하여 최대 10초 동안 락을 얻으려고 시도한다. 만약 10초 동안 락을 획득하지 못하면 false를 반환되며 락 획득에 실패했다고 알려준다. 그리고 두 번째 파라미터인 1초가 지나면 락이 만료되어 사라지기 때문에 어플리케이션에서 락을 해제해주지 않더라도 다른 스레드 혹은 어플리케이션에서 락을 획득할 수 있다. 그러면 분산 락을 사용하여 재고 차감은 얼마나 걸리는지 테스트를 돌려보자.

 

분산락을 사용했을 경우 오히려 비관적 락을 사용했을 때보다 더 오래걸린 것을 확인할 수 있다. 물론 로컬 환경이라 정확한 환경을 구축하지는 않았지만 대략 556ms가 걸린 것을 확인할 수 있다. 그러면 시간이 더 적게 드는 비관적 락이 좋을까?

 

비관적 락 vs 분산 락

우선, 비관적 락 즉, MySQL의 락에 대한 요청은 Blocking I/O로 처리하고, Redisson은 Non-blocking I/O로 처리한다. 이 말은 Redisson을 사용하게 되면 Non-blocking 방식으로 락을 관리함으로써, 유저 영역에서는 락 획득 요청에 대한 응답을 대기하지 않고 계속해서 다음 요청을 처리할 수 있다. 이는 락 획득이나 해제와 관련된 작업이 비동기적으로 처리되기 때문이다. 반면에 Blocking 방식에서는 커널 영역에 락을 요청하면, 해당 락을 획득할 때까지 대기해야 한다. 이러한 대기 과정에서는 유저 영역에서는 다음 요청을 처리할 수 없으며, 응답이 올 때까지 블록된다. 따라서 Blocking 방식은 Non-blocking 방식에 비해 응답 속도가 상대적으로 느리다.

또한, 비관적 락의 단점으로는 어플리케이션 단에서 타임아웃을 설정할 수 없다는 것이다. 따라서 DB의 트랜잭션 중 락이 걸렸을 시 롤백될 때까지 대기하는 시간인 innodb_lock_wait_timeout 시스템 변수을 통해 설정할 수 있는데 이 시스템 변수는 해당 서비스만이 아니라 전체 타임아웃이 설정되므로 다른 서비스 기능에 영향을 미칠 수 있다.