모든 코드는 Github에 공개되어 있으며 각 해결 단계를 커밋으로 분리해두었으니 확인해주세요.
상품이 판매될 때마다 재고를 하나씩 줄여주는 기능을 만들어본다. 이 때 발생할 수 있는 동시성 문제를 하나씩 해결해보자.
문제발생 케이스
하나의 상품이 100개의 재고를 가지고 있을 때 단순하게 생각하면 판매될 때 재고를 -1 한 후 저장해주면 될거라고 생각할 수 있지만, 실무에서는 다양한 케이스가 발생할 수 있다.
대표적으로, 하나의 상품에 주문이 동시에 (ms 차이 수준) 들어온다고 가정을 한다. 이럴 때, Race Condition이 발생하여 최종 재고 값이 이상하게 나올 수 있다.
문제의 상황 테스트 코드 위 예시에서 100갸의 쓰레드가 같은 상품의 재고를 감소시켰을 때 최종 재고는 0을 기대하지만 실제 테스트는 실패하는 것을 확인 할 수 있다.
각 단계별로 해결을 하며 문제점을 살펴봅니다.
1. Syncronized (level 1)
자바-synchronized에-대하여 Java의 기본 Syncronized 키워드를 사용해서 여러 쓰레드가 동시에 해당 접근할 수 없도록 한다. Syncronized를 활용한 개선 커밋
결과
예시처럼 코드를 작성하면 쓰레드 동기화를 통해 해결했다고 생각할 수 있지만, 결과는 실패한다. 이유는 @Transactiional
Proxy 방식 때문에 Proxy가 (Commit)종료되기 전에 다른 쓰레드가 해당 자원에 접근할 수 있기 때문이다. @Transactional 동작원리
해결법
서비스에 붙어있는 @Transactional
을 없앤다.. @Transactional
을 제거하면 Proxy로 동작하지 않기 떄문에 해결 할 수는 있다.
문제점
Syncronized는 하나의 프로세스에서만 동기화를 보장한다. 현대의 어플리케이션은 2대 이상의 서버를 사용하기 때문에 Syncronized 로는 보장할 수 없다.
2. Mysql 을 활용한 다양한 방법 (level 2)
Pessimistic Lock을 활용
- 실제 데이터에 Lock을 걸어서 정합성을 맞추는 방법
- Exclusive Lock을 걸게되면 다른 트랜잭션에서는 Lock이 해제되기 전에는 데이터를 가져갈 수 없음
- Dead Lock 가능성이 있기 때문에 주의필요
1
select stock0_.id as id1_0_, stock0_.product_id as product_2_0_, stock0_.quantity as quantity3_0_ from stock stock0_ where stock0_.id=? for update
select..for update
를 사용하여 해당 row를 다른 세션에서 건들 수 없도록 Lock을 건다.
장점
Optimistic Lock
- 실제 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법
- 먼저 데이터를 읽은 후 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하여 업데이트
- 내가 읽은 버전에서 수정사항이 생겼을 경우 application 단에서 다시 읽은 후 작업을 수행해야 함
장점
Named Lock
- 이름을 가진 Metadata Locking (Table/Row Lock 이 아님)
- 이름을 가진 Lock을 획득한 후 해제할 때 까지 다른 세션은 이 Lock 을 획득할 수 없도록 합니다.
- Transaction이 종료될 때 자동으로 해제되지 않음. 별도의 명령어로 해제/선점 시간이 끝나야 해제됨
JPA Native Query를 이용 Connction Pool 이 부족해질 수 있기 때문에 별도의 Datasource를 이용하기를 추천 - Named Lock을 잡기 위해 1개의 Connection , Transacton을 잡기 위해 1개의 Connection
참고 자료
https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_exclusive_lock https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html
3. Redis 활용 (level 3)
1. Lettuce
setnx
명령어 사용- [[spin lock 방식]]
- retry 로직 필요
장점
- 구현이 간단하다
- spring data redis 를 이용하면 lettuce 가 기본이기때문에 별도의 라이브러리를 사용하지 않아도 된다.
단점
- spin lock 방식이기때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis 에 부하가 갈 수 있다.
- Thread.sleep으로 부하를 줄여줬음
2. Redisson
- Pub-Sub 기반의 Lock 구현 제공
- 채널을 만들고 Lock을 획득한 Thread가 Lock획득을 시도하는 Thread에게 해제되었음을 알려조는 방식
장점
- 락 획득 재시도를 기본으로 제공한다.
- pub-sub 방식으로 구현이 되어있기 때문에 lettuce 와 비교했을 때 redis 에 부하가 덜 간다.
단점
- 별도의 라이브러리를 사용해야한다.
- lock 을 라이브러리 차원에서 제공해주기 떄문에 사용법을 공부해야 한다.
실무에서는 ?
- 실패 시 재시도가 필요하지 않은 lock 은 lettuce 활용
- 실패 시 재시도가 필요한 경우에는 redisson 를 활용
Mysql vs Redis
Mysql
- 이미 Mysql 을 사용하고 있다면 별도의 비용없이 사용가능하다.
- 어느정도의 트래픽까지는 문제없이 활용이 가능하다
- Redis 보다는 성능이 좋지않다.
Redis
- Mysql 보다 성능이 좋다.
- 활용중인 Redis 가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
Reference
https://velog.io/@hgs-study/redisson-distributed-lock https://techblog.woowahan.com/2631/
https://kwonnam.pe.kr/wiki/database/mysql/user_lock