본문 바로가기
Study

Java 동시성 이슈를 해결할 수 있는 방법 - Lock

by 긍고 2025. 7. 31.
728x90
반응형

개요


최근 동시성 처리에 대한 관심이 생겨 자바에서 동시성 이슈를 처리하는 방법에 대해 공부해 보았고, 일부 내용을 정리해 두려고 한다. 각 방법별로 테스트를 위해 간단한 재고 감소 로직을 구현해 테스트하였다.

 

재고 감소 테스트 코드


이번 글에서는 동시성을 해결할 수 있는 방법 별로 실제 동시성이 해결되는지를 확인하기 위해. 간단한 재고 감소 코드를 작성하여 테스트하였다. 재고 감소를 위해 Stock이라는 엔티티를 생성한 뒤, 주어진 id에 해당하는 재고를 quantity만큼 감소시키는 아래 메서드를 작성하여 테스트하였다.

@Transactional
public void decrease(Long id, Long quantity) {
	Stock stock = stockRepository.findById(id).orElseThrow();
    stock.decrease(quantity);
    stockRepository.saveAndFlush(stock);
}

 

먼저 100개의 재고가 있다고 가정을 한 뒤, 위 메서드를 멀티쓰레드 환경에서 100번 수행했다고 가정했을 때 의도한 대로 동작하는지를 테스트해보았다.

위와 같이 테스트코드를 작성하고 수행하면, 의도와 달리 재고가 0이 아님을 확인할 수 있다. 이유는 둘 이상의 쓰레드가 동시에 값에 접근하여 변경하려 하는 Race Condition이 발생했기 때문이다. 이러한 문제를 해결하기 위해서는 하나의 쓰레드 작업이 완료된 후에 다른 쓰레드가 접근할 수 있도록 해야 한다.

 

Java synchronized 키워드를 이용한 해결법


자바에서는 이러한 케이스에 대해 대응할 수 있도록 synchronized 키워드를 제공한다.

@Transactional
public synchronized void decrease(Long id, Long quantity) {
	Stock stock = stockRepository.findById(id).orElseThrow();
    stock.decrease(quantity);
    stockRepository.saveAndFlush(stock);
}

해당 키워드가 붙은 메서드는 한 번에 하나의 쓰레드만 접근할 수 있다. 따라서 기존의 decrease 메서드에 해당 키워드를 붙인뒤 동일한 테스트 코드를 실행하면 아래와 같은 결과를 확인할 수 있다.

한 번에 하나의 쓰레드만 접근할 수 있는 키워드를 붙였지만 실패한 이유는 @Transactional 어노테이션 때문이다. 해당 어노테이션이 붙으면 Spring AOP를 이용하여 해당 객체에 대한 프록시를 만들게 되는데 이를 코드로 표현하면 아래와 같다.

public void proxyDecrease(Long id, Long quantity) {
	// 1) transaction start
    
    // 2) synchronized 영역 시작
	Stock stock = stockRepository.findById(id).orElseThrow();
    stock.decrease(quantity);
    stockRepository.saveAndFlush(stock);
    // 3) synchronized 영역 종료
    
    // 4) transaction commit
}

 

기존 decrease 메서드를 호출하면 상단의 proxyDecrease를 호출하게 되는데, 이때 트랜잭션이 커밋되기 전(3~4번 사이)에 다른 쓰레드가 공유자원에 접근하여 감소되지 않은 동일한 값을 조회할 수 있다.

 

따라서 위와 같이 synchronized 키워드를 사용하려면 @Transactional 어노테이션을 제거한 뒤 수행하면 의도한 대로 동작하는 것을 확인할 수 있다.

다만 이렇게 synchronized를 활용한 해결책은 프로세스 하나에서만 보장되므로, 여러 대의 서버로 운영되는 실서비스 환경에서는 실질적인 해결책이 되지 못한다. 아래는 여러 대의 서버로 운영되는 환경에서도 동시성을 보장할 수 있는 방법들이다.

 

DB Lock을 이용한 해결 방법


서비스 프로세스 단에서 동시에 같은 데이터를 조회하는 것이 문제가 된다면, DB단에서 아예 데이터 자체에 대한 접근에 대한 차단을 하면 된다. 이를 DB에 Lock을 건다고 표현하는데 그 방법에도 여러 가지가 있다.

Pessimistic Lock

실제 데이터에 Lock을 거는 방법이며, 다른 트랜잭션에서는 이 Lock이 해제되기 전에는 조회가 불가능하다. 아래에서 설명할 Optimistic Lock 보다는 충돌이 빈번한 환경에서 성능이 좋기는 하나, 데이터에 대한 접근을 제한하므로 전체적인 성능은 저하되며 자칫 데드락에 걸릴 위험이 있다.

DB Lock이라고 해서 DB단에서 따로 처리를 해주어야 하는 건 아니고 위와 같이 @Lock 어노테이션을 통해 Pessimistic Lock을 걸 수 있다. 이때 실행되는 쿼리를 보면 아래와 같이 for update 라는 구문이 있는데 이 부분이 Lock을 걸면서 데이터를 가져오는 부분이다.


Optimistic Lock

직역하면 낙관적 Lock으로써 데이터에 직접 락을 거는 것이 아닌, 조회와 업데이트 시 version이 일치하는지를 확인하고 일치할 때만 업데이트를 수행하는 방식이다. 별도 Lock을 잡지 않으므로 충돌이 빈번하지 않을 시에는 앞서 설명한 Pessimistic Lock보다 성능상 이점이 있지만, 재시도 로직을 개발자가 직접 작성해 주어야 한다.

 

이 방식은 쿼리에 @Lock을 추가하는 것 외에 엔티티에 version을 추가해주어야 한다.

또한 @Lock 어노테이션에는 LockMode를 OPTIMISTIC으로 주어야 한다.

이 방식을 사용할 경우, 버전이 불일치할 때 재시도하는 로직을 구현해주어야 하므로, 재시도 로직을 포함하여 Facade로 구현 후 테스트에 해당 Facade 객체(OptimisticLockStockFacade)를 활용해 준다.

위와 같이 Facade를 만들고 테스트를 수행하면, 쿼리에 version이 함께 조회되는 것을 확인할 수 있다.


Named Lock

Pessimistic lock과 비슷하지만 데이터 자체에 락을 걸기보다는 이름을 가진 메타데이터에 락을 거는 방식이다. 해당 lock이 해제될 때까지 다른 세션은 lock을 획득하지 못하며, 트랜잭션이 종료된다고 lock이 자동해제되지는 않기 때문에 해제를 해주거나 선점 시간을 적절히 설정해주어야 한다.

 

보통 분산락을 구현할 때 사용하며, timeout 구현하기가 쉽지만, 락 해제등의 구현이 추가되므로 구현 방법의 복잡도가 올라가는 단점이 있다.

쿼리 작성 시, 위와 같이 get_lock을 통해 주어진 key에 대한 Lock을 걸고 작업을 마친 뒤에는 release_lock을 통해 해당 락을 릴리즈 해준다. 이 또한 작업 종료 시 lock 해제를 반드시 해주어야 하므로 Optimistic lock과 같이 Facade객체를 구현하여 사용한다.

 

Redis를 이용한 해결 방법


위와 같이 DB에 락을 걸면 결국 비용이 큰 DB IO가 일어나게 된다. 그래서 in-memory로 동작하는 Redis를 활용하여 락을 거는 방식으로 보다 성능을 향상 시킬 수 있다.

 

Lettuce를 이용한 구현 방법

기본적으로 스프링부트에서는 Lettuce를 레디스에 대한 디폴트 라이브러리로 사용 중이다. 해당 라이브러리의 setnx 명령어를 통해 분산락을 구현할 수 있다. 이 방식은 DB의 named lock과 비슷하게 구현할 수 있다.

먼저 위와 같이 lock, unlock 메소드를 만들어 준다. TTL은 기존 named lock과 동일하게 3초로 설정하였다. 이 케이스의 경우, 락을 획득하기까지 계속해서 레디스에 락 획득을 요청하고(spin lock) 이후 기능 종료 시 lock을 반환해야 하므로 Facade 객체를 만들어 준다.

위와 같이 구현한다면 별도의 라이브러리 추가 없이 Redis를 활용한 분산락을 구현할 수 있으나, Sleep을 주었다고는 하나 계속해서 레디스에 락을 요청하는 spin lock 방식이므로 레디스에 부하를 유발할 수 있다는 단점이 있다.


Redisson을 이용한 구현 방법

Redis를 이용해서 분산락을 구현할 수 있도록 지원하는 라이브러리 중 Pub/Sub 기능을 활용한 Redisson 라이브러리가 있다. 해당 방식은 채널을 만들고 lock을 해제하는 쓰레드가 이벤트를 publish 하면 해당 채널을 구독(subscribe) 하는 쓰레드가 그것을 감지하여 락을 획득할 수 있는 방식이다.

 

실제 아래와 같이 한쪽에서 이벤트를 ch1이라는 채널에 publish 하면 이미 구독 중인 쪽에서 해당 이벤트를 수신할 수 있는 것을 확인할 수 있다.

이는 계속해서 Redis에 부하를 주는 스핀락 방식과 다르게 부하를 줄일 수 있다. 하지만 이 또한 lock에 대한 획득과 릴리즈 처리는 해주어야 하므로 아래와 같이 Facade 객체를 만들어 사용할 수 있다.

참고로 위의 tryLock 내부의 waitTime은 쓰레드가 락을 획득하기 위해 기다리는 시간이며, leaseTime은 락을 획득한 뒤 자동으로 락을 해제하는 시간에 대한 설정값이다.

 

waitTime이 너무 짧다면 충분히 락을 획득할 수 있음에도 예외가 발생해 요청을 처리하지 못하며, 너무 길다면 사용자 경험이 떨어질 수 있고 병목으로 이어질 수 있다.

 

leaseTime이 너무 짧다면 아직 로직이 수행 중인 중에 락이 해제되므로 동시성 이슈가 발생할 수 있고, 반대로 너무 길다면 데드락과 비슷한 상황이 발생할 수 있다.

 

Redis를 활용한 락 구현 방식에 대한 부분은 아래의 포스트를 통해 좀 더 자세히 알 수 있다.

2024.10.03 - [Java] - 결제 동시성 이슈 해결 - 분산락과 Redisson

 

결제 동시성 이슈 해결 - 분산락과 Redisson

개요회사 차세대 프로젝트에서 발송 요청 시 가결제 동시성 이슈로 인한 사용자 가결제 금액 - 잔액간 불일치 이슈가 발생했다. 발생한 가결제 이슈 프로세스를 정리하면 아래와 같다. 사용자가

joon2974.tistory.com

 

정리


동시성 이슈가 발생할 수 있는 상황에 대해서 여러 개념을 활용한 해결 방안에 대해 정리해 보았다. DB를 사용한 방식은 어느 정도 트래픽까지는 커버가 가능하지만 결국 IO 비용이 발생하기 때문에 성능이 좋지는 못하며, Redis를 이용한 방식은 전자보다는 성능은 좋지만 이미 Redis가 구축되어 있는 상황이 아니라면 구축에 드는 비용까지 추가되는 단점이 있다. 

 

또한, 이 글에서 살펴본 방식은 모두 결국은 특정 데이터에 대한 Lock을 거는 방식이기 때문에 트래픽이 증가한다면 해당 구간은 반드시 병목구간이 될 수밖에 없다.

 

따라서 높은 트래픽에서의 환경이라면 위와 같은 Lock을 거는 방식에 매몰되어서는 안 될 것 같다. 예를 들어 재고 개수는 Single Thread로 동작하는 Redis에 저장 관리하여 동시성을 관리하고, 그 뒤에 따르는 재고 차감내역 저장이나 후속처리 같은 부분은 이벤트를 발행하여 비동기로 처리하되, 예외 발생 시 재고를 복원하는 fallback로직을 구현하는 등 Lock에 의존하지 않고 성능을 개선할 수 있는 방식을 찾는 게 필요해 보인다.

 

다음에는 위처럼 Lock에 의존하지 않고 동시성을 처리할 수 있는 방식에 대해서 고민해 보고 정리해보려고 한다.

728x90

댓글