들어가며
동시에 같은 DB Table row 를 업데이트 하는 상황을 방어하는 것, 선착순 문제 등과 같은 동시성 이슈와 관련된 고민을 하다가 인프런에 올라온 재고시스템으로 알아보는 동시성이슈 해결방법을 완강하였다. 이를 복습하는 차원에서 직접 재고 시스템을 구현해보며 정리해보았다.
동시성(Concurrency)이란?
동시성의 사전적 의미는 빠르게 전환하며 여러 작업을 수행하여 동시에 여러 작업이 실행되는 것처럼 보이는 것을 의미한다.
하지만 구체적으로 DB에서의 동시성은 조금 다르게 해석할 수 있다.
동시성은 여러 요청이 동시에 동일한 자원(data)에 접근하고 수정하려는 것을 의미한다.
선착순으로 상품을 나누어주는 이벤트를 진행한다고 생각해보자.
이벤트가 막바지에 다다라 상품 재고가 딱 한 개 남은 상황.
이벤트 참여자 A와 B는 이벤트에 참여하려고 한다.
그리고 둘 다 이벤트 참여에에 성공한다면 어떤 일이 발생할까?
위와 같이 동시성이 문제가 되는 상황이 있다. 이러한 이유로 개발자는 동시성을 신경 쓰고 적절한 제어를 해주어야 한다.
다음 요구사항을 구현해보면서 동시성 이슈를 해결하는 방법에 대해서 알아보자.
요구사항
- Quantity(재고)가 100개인 Stock(상품)이 있다.
- 사용자가 요청할 때마다 재고를 1씩 감소하여 0으로 만드는 테스트를 작성한다.
- 위 테스트를 통과시키는 비즈니스 로직을 작성한다.
테스트 환경
- Java 11
- Spring Boot 2.7.7
- Spring Data JPA
- JUnit5
재고 감소 로직 생성하기
Stock Entity 생성
- decrease 메소드는 재고 감소 도메인 로직을 담은 메소드이다.
StockService 작성

재고 감소 테스트 코드 작성

- 테스트를 돌려보았을 때 통과하는 것을 알 수 있다.
- 이 테스트가 통과하는 이유는 재고 감소 요청이 현재 하나씩 들어오고 있는 상황이기 때문이다.
요청이 동시에 여러 개씩 들어온다면?
그렇다면 요청이 동시에 여러 개씩 들어오면 어떻게 될까?
동시에 사용자 100명이 동시에 재고 감소 요청을 하게 되었을 때의 테스트 케이스를 작성해보자.

- 동시에 여러 개의 요청을 보내야되기 때문에 멀티 스레드를 이용한다.
- 100개의 요청을 보내기 때문에 threadCount를 100으로 초기화한다.
ExecutorService
는 비동기로 실행하는 작업을 단순화하여 사용할 수 있게끔 도와주는 자바의 API이다. 즉, 병렬 작업 시 여러 개의 작업을 효율적으로 처리하는 데에 사용된다.
- 100개의 요청이 끝날 때까지 기다려야 하므로
CountDownLatch
을 사용한다.
CountDownLatch
: 어떤 스레드가 다른 스레드에서 작업이 완료될 때까지 기다릴 수 있도록 해주는 클래스
문제점
여기서 발생하는 문제점은 Racecondition 이다.
Racecondition이란 여러 개의 프로세스 혹은 스레드가 공용 자원을 병행적으로(concurrently) 읽거나 쓰는 동작을 할 때 공용 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 같지 않고 달라지는 상황이다. 즉, 동시에 변경할려고 할 때 발생하는 문제이다.

- 우리가 예상하는 재고 감소에 대한 흐름도는 위와 같을 것이다.
- 스레드1이 갱신한 값을 스레드2가 가져가서 갱신하는 것을 예상했을 것이다. 하지만 이렇게 실행되지 않을 수 있다.

- 실제로는 스레드1이 데이터를 가져가서 갱신하기 전에 스레드2가 값을 가져갈 수 있다.
- 그리고 스레드1이 먼저 데이터를 갱신하고 스레드2가 다음으로 갱신하지만 둘 다 재고가 5인 상태에서 1을 줄인 값을 갱신하기 때문에 갱신이 누락된다.
- 이렇게 두 개 이상의 스레드가 공유 데이터가 접근할 수 있고 동시에 변경하려고 할 때 발생하는 문제가 Racecondition이다.
- 이런 문제를 해결하기 위해서는 하나의 스레드 작업이 완료된 후에 다른 스레드가 데이터에 접근할 수 있도록 하면 된다.
해결방법
1. Synchronized 활용
데이터에 1개의 스레드만 접근하도록 하면 Racecondition 문제를 해결할 수 있다.
자바에서 문제를 해결하는 방법으로는 Synchronized를 이용하는 방법이 있다.

- synchronized를 메소드 선언부에 붙여주면 해당 메소드는 하나의 스레드만 접근 가능하다.

- 테스트 케이스를 실행해보았을 때 synchronized를 활용했음에도 실패하였다.
- 그 이유는 스프링의
Transactional
애노테이션의 동작 때문이다.
- 스프링에서는
Transactional
애노테이션을 사용하면 우리가 만든 클래스를 래핑한 클래스를 새로 만들어서 실행한다.

- 위와 같이 StockService를 필드로 가지는 클래스를 새롭게 만들어서 해당 메소드를 실행한다.
- StockService 메소드가 시작될 때 트랜잭션을 호출하고 StockService의 메소드를 호출한 후에 정상적으로 수행이 되면 트랜잭션을 종료한다.
- 트랜잭션 종료 시점에 데이터베이스를 업데이트하는데, 여기서 문제가 발생한다.
- decrease 메소드가 완료되었고 실제 데이터베이스에 업데이트 하기 전에, 다른 스레드가 decrease 메소드를 호출할 수 있다.
- 그러면 다른 스레드는 갱신하기 전의 값을 가져가서 이전과 동일한 문제가 발생한다.


- 이 문제를 해결하기 위해서
Tranasctional
애노테이션을 제거하고 테스트 케이스를 돌려보자.
- 정상적으로 성공하는 것을 알 수 있다.
Synchronized 사용의 문제점
자바의 Synchronized는 하나의 프로세스 안에서만 동작이 보장된다. 서버가 한 대일때는 데이터 접근을 서버 한 대만해서 괜찮지만, 서버가 두 대 이상일 경우에는 그 데이터 접근을 여러 대에서 할 수 있다.

- 서버1이 10:00에 데이터를 가져가서 갱신 작업을 시작할 수 있다. 서버2도 마찬가지로 10:00과 10:05 사이에 데이터를 가져가서 갱신 작업을 시작할 수 있다.
- Synchronized는 각 프로세스 안에서만 동작이 보장되기 때문에 결국 여러 스레드에서 동시에 데이터에 접근할 수 있게 되면서 Racecondition이 발생한다.
- 그래서 실제 운영 서비스는 서버를 두 대 이상 사용하기 때문에 자바의 Synchronized를 사용하지 않는다.
2. Database 활용
2-1. Pessimistic Lock (비관적 락, Exclusive Lock) 활용
- 데이터 Lock을 걸어서 정합성을 맞추는 방법
- 정합성: 데이터가 서로 모순 없이 일관되게 일치해야 함
- Pessimistic Lock를 걸게 되면, 다른 트랜잭션에서는 Lock이 해제되기 전에 데이터를 가져갈 수 없다.
- 하지만 사용할 때 데드락이 걸릴 수 있기 때문에 주의해서 사용해야 한다.

- 서버가 여러 대 있을 때 서버1이 먼저 데이터를 가져가게 되면, 서버2와 서버3은 서버1이 Lock을 해제하기 전까지 데이터를 가져갈 수 없다.
- 데이터는 Lock을 가진 스레드만 접근 가능하기 때문에 문제를 해결할 수 있다.
Pessimistic Lock 구현

- native query를 사용해서 쿼리는 작성한다.
- spring data jpa에서는 Lock 애노테이션을 통해 Pessimistic Lock을 구현할 수 있다.


- Pessimistic Lock를 활용한 재고 감소 로직을 작성하고 테스트 케이스를 실행했을 때 정상적으로 성공하는 것을 알 수 있다.

- 위 이미지에서 쿼리 부분을 보면,
for update
부분이 Lock을 거는 부분이다.
Pessimistic Lock의 장단점
- Pessimistic Lock의 장점
- 충돌이 빈번하게 일어나는 작업인 경우에는 Optimistic Lock보다 성능이 좋을 수 있다.
- 또한 Lock을 통해 업데이트를 제어하기 때문에 데이터 정합성을 어느정도 보장한다.
- Pessimistic Lock의 단점
- 별도의 Lock을 잡기 때문에 데드락이 걸릴 수 있고 성능 감소가 있을 수 있다.
2-2. Optimistic Lock (낙관적 락) 활용
- 실제로 Lock을 사용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법이다.
- version column을 만들어서 해결하는 방법
- 먼저 데이터를 읽은 후에 업데이트를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트를 한다.
- 만약에 내가 읽은 버전에서 수정 상황이 발생하면 업데이트 상에서 다시 읽은 후에 작업을 수행해야 한다.

- 서버1과 서버2가 Optimistic Lock을 활용하여 공유 자원을 접근할 때의 이미지는 위와 같다.
- 서버1과 서버2가 version이 1인 로우를 읽어왔다고 가정하자.
- 읽고난 후에 서버1이 먼저 업데이트 쿼리를 날리게 된다. 업데이트 쿼리를 수행할 때 where 조건에 version = 1인 것을 명시해주면서 업데이트를 진행한다.
- 그러면 실제 데이터는 version이 2가 된다. 왜냐하면 서버1의 업데이트 쿼리에서 version을 1 증가해주었기 때문이다.
- 마찬가지로 서버2에서도 where 조건에 version = 1인 것을 명시해주는데, 실제 데이터의 version이 2이기 때문에 업데이트를 실패하게 된다.
- 이렇게 되면 서버2의 애플리케이션 상에서 다시 데이터를 조회한 후에 업데이트를 수행하는 로직을 포함시켜야 한다.
Optimistic Lock 구현

- Stock 엔티티에 버전 컬럼을 추가하고, 버전 애노테이션을 붙여준다.

- Lock 애노테이션을 붙여주고 native query를 작성한다.

- id와 감소 시킬 수량을 가진 재고 감소 로직을 작성한다.

- 재고 감소 업데이트를 실패했을 때 재시도를 해주어야 하기 때문에 facade 패키지를 만들고, OptimisticLockStockFacade 클래스를 만들었다.
- 재고 감소 로직을 수행하다가 실패를 하게 되면 50ms를 기다리고 다시 시도하는 로직을 작성한다.
- 정상적으로 업데이트가 된다면 break를 활용한다.

- 테스트 케이스를 작성하고 돌려보았을 때 성공하는 것을 알 수 있다.
Optimistic Lock 장단점
- 장점
- 별도의 Lock을 잡지 않으므로 성능 상의 장점이 있다.
- 단점
- 업데이트를 실패했을 때 재시도 로직을 개발자가 직접 작성해야 한다.
- 충돌이 빈번하게 일어난다면 Pessimistic Lock을 이용하는게 더 나을 수도 있다.
3. Redis 활용
Key, Value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터 베이스 관리 시스템인 Redis를 활용할 수 있다. Redis는 싱글스레드이기 때문에 여러 서버에서 동시에 접근하여도 순차적인 처리를 보장해줄 수 있다.
분산락을 구현할 때 사용하는 대표적인 라이브러리는 Lettuce와 Redisson이다.
3-1. Lettuce 활용
- setnx 명령어를 활용하여 분산락 구현
- setnx는 set if not exists의 약어로 key가 없을 때만 set을 하는 특징을 가지고 있는 명령어이다.
- key와 value가 set 됐다면 1, set 되지 않았다면 0을 반환하는 특성을 사용하여 atomic하게 Lock 획득 여부를 결정한다.
Lettuce의 setnx 명령어를 활용하여 분산락을 구현하는 방식은 spin lock 방식이다. spin lock이란 락을 획득하려는 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방법이다. 그러므로 retry 로직을 개발자가 직접 작성해야 한다.

- 스레드1이 key가 1인 Lock을 획득할 수 있다.
- 스레드2는 key가 1인 Lock을 획득할 수 없다. Redis에 이미 key가 1인 데이터가 있기 때문에 false를 리턴한다.
- 스레드2는 일정 시간 이후에 Lock 획득을 재시도한다.
Lettuce 구현


- Redis Lettuce를 활용하는 방식도 실행 전후로 Lock 획득과 해제를 해주어야 하기 때문에 facade 클래스를 만들어준다.

- 테스트 케이스를 작성하고 실행해본 결과 성공한다.
Lettuce 장단점
- 장점
- 단점
- spin lock 방식이기 때문에 redis에 부하를 줄 수 있다. 그렇기 때문에 스레드 슬립을 활용해서 Lock 획득 재시도 간에 텀을 두어야 한다.
3-2. Redisson 활용
- pub-sub 기반으로 Lock 구현 제공
- 채널을 하나 만들고 Lock을 점유중인 스레드가 Lock을 획득하려고 하는 스레드에게 해제를 알려주고 안내를 받은 스레드가 Lock 획득 시도를 하는 방식
- 이 방식은 Lettuce와 다르게 spin lock이 아니기 때문에 retry 로직을 작성하지 않아도 된다.
Redission 구현

- 로직 실행 전후로 Lock 획득과 해제는 해주어야 하므로 facade 클래스를 만들어준다.
- redisson client에서 키를 활용해서 Lock 객체를 가져오게 한다.
- 그 후에 몇 초동안 Lock 획득을 시도할 것인지, 몇 초동안 점유할 것인지 설정을 해준 후에 Lock 획득을 시도한다.
- 락 획득을 실패하게 된다면, Lock 획득 실패했다는 로그를 남기고 return 한다.
- 정상적으로 Lock 획득을 성공했다면 재고 로직을 실행한다.
- 마지막으로 Lock을 해제한다.

- 테스트 케이스를 작성하고 성공한 것을 알 수 있다.
Redission 장단점
- 장점
- 단점
- Lettuce 방식에 비해 구현이 복잡하고 별도의 라이브러리를 사용해야 한다는 부담감이 있다.
Lettuce 활용 vs Redisson 활용
- Lettuce
- 구현이 간단하다.
- spring data redis를 이용하면 Lettuce가 기본이기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
- 그러나 spin lock 방식이기 때문에 동시에 많은 스레드가 Lock 획득 대기 상태라면 redis에 부하가 갈 수 있다.
- Redission
- Lock 획득 재시도를 기본으로 제공한다.
- pub-sub 방식으로 구현이 되어 있기 때문에 Lettuce와 비교했을 때 redis에 부하가 덜 간다.
- 별도의 라이브러리를 사용해야 한다.
- Lock을 라이브러리 차원에서 제공해주기 때문에 사용법을 공부해야 한다.
두 라이브러리를 비교해보았을 때 재시도가 필요하지 않은 Lock은 Lettuce를 활용하고 재시도가 필요한 경우에는 Redission을 활용하는 것이 좋을 것 같다.
MySQL 활용 vs Redis 활용
- MySQL
- 만약 이미 MySQL을 사용하고 있다면 별도의 비용 없이 사용 가능하다.
- 어느 정도의 트래픽까지는 문제없이 활용이 가능하다.
- Redis 보다 성능이 좋지 않다.
- Redis
- 사용중인 Redis가 있다면 별도의 구축 비용과 인프라 관리 비용이 발생한다.
- MySQL보다 성능이 좋다.
마치며
동시성 이슈의 문제점, Racecondition의 문제점을 해결하기 위한 다양한 방법을 알아보았다. 각각의 장단점을 보았을 때 개인 프로젝트를 한다면 Database 단에서 해결할 수 있는 방식을 채택할 거 같다. 그리고 나중에 큰 트래픽을 경험할 수 있을 때 Redis를 활용해서 공부한 진가를 발휘하고 싶다.