재고관리 시스템에서 발생할 수 있는 동시성 이슈와 해결 방법에 대해 공부해보자 ❕
참고 강의
재고시스템으로 알아보는 동시성이슈 해결방법 강의 | 최상용 - 인프런
최상용 | 동시성 이슈란 무엇인지 알아보고 처리하는 방법들을 학습합니다., 동시성 이슈 처리도 자신있게! 간단한 재고 시스템으로 차근차근 배워보세요. 백엔드 개발자라면 꼭 알아야 할 동
www.inflearn.com
1. 문제 상황
총 100개의 재고 수량을 하나씩 감소시켜 최종 수량이 0이 되기를 기대하는 코드이다. 그러나 멀티 스레드를 실행해 재고 수량을 감소시켰을 때, 다음과 같이 기대값과 다른 값이 나오게 된다.
- 그 이유는 *Race Condition이 일어났기 때문이다.
- 스레드1이 데이터를 가져가 갱신하기 전에, 스레드2가 조회하면서 갱신되기 전의 데이터를 읽어 스데르1의 작업이 누락된 것이다.
* Race Condition ?
둘 이상의 스레드가 공유 자원에 액세스하여, 동시에 자원을 변경하려고 할 때 발생하는 문제
Q. ExecutorService, CountDownLatch ?
ExecutorService
: 비동기로 실행하는 작업을 단순화하여 사용할 수 있게 도와주는 Java API- `submit()`는 Runnable 인터페이스와 Callable 인터페이스를 인자로 받을 수 있으며, Future 객체를 반환한다.
CountDownLatch
: 다른 Thread에서 수행중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스counter
필드를 가지며, 이 값이 0이 될 때까지 스레드의 실행을 대기할 수 있다. 멀티 스레드 작업에서 각 스레드 작업이 완료되면countDown()
을 호출,await()
는 스레드 작업이 완료될 때까지 대기한다.
^동시에 여러 사용자가 접근하는 서비스에서 데이터의 일관성을 유지하는 것은 필수적이다. 위와 같은 문제를 해결하기 위해 하나의 스레드의 작업이 완료된 이후에 다른 스레드가 공유 자원에 접근하도록 하는 설정이 필요하다.^
2. 해결방법1 - synchronized
synchronized는 자바에서 지원하는 기능이며, 하나의 프로세스 안에서 락이 보장된다.
synchronized 실습
- 접근 제어자 뒤에
synchronized
를 붙인다.
테스트 코드를 실행해보면 실패하는 것을 확인할 수 있다. 이는 `@Transactional` 어노테이션 때문이다.
- Spring의
@Transactional
이 설정되어 있다면, synchronized 메서드가 랩핑되어 실행되므로 실제 트랜잭션 커밋 전에 다른 스레드에서 해당 메서드가 실행될 수 있다. 따라서@Transactional
을 제거해줘야 한다.
synchronized 문제점
앞서 말했듯이 ^Java의 `synchronized`는 하나의 프로세스 안에서만 락이 보장된다.^ 서버가 여러 대라면 데이터 접근이 여러 곳에서 가능해진다는 것이다. 따라서 서버1이 재고를 조회하고 갱신하기 전에, 서버2가 재고를 조회한다면 둘 다 접근이 가능해지고 결국 Race Condition이 발생하게 된다.
→ 실제 운영 서비스는 여러 대의 서버를 사용하기 때문에 synchronized는 거의 사용되지 않는다.
3. 해결방법2 - DB Lock
DB를 통해 해결하는 방법으로는 Pessimistic Lock, Optimistic Lock, Named Lock이 있다.
3-1. Pessimistic Lock (비관적 락)
- 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법
- *Shared Lock 또는 *Exclusive Lock을 걸어, 다른 트랜잭션에서는 락이 해제되기 전에 데이터를 가져갈 수 없다.
- 충돌이 발생할 것이라고 ‘비관적’으로 가정하기 때문에, 데이터에 대한 변경이 자주 발생하고 충돌 가능성이 높은 환경에 적합하다.
- 데드락이 발생할 가능성이 있다.
* Shared Lock, Exclusive Lock ?
- Shared Lock (공유 락)
- 읽기 잠금
SELECT … FOR SHARE
의 쿼리에서 걸리는 Lock- Shared Lock을 통해 Lock을 걸었다면, 다른 곳에서 Shared Lock은 걸 수 있으나 Exclusive Lock은 걸 수 없다.
- Exclusive Lock (배타 락)
- 쓰기 잠금
SELECT … FOR UPDATE
,UPDATE
,DELETE
등의 쿼리에서 걸리는 Lock- Exclusive Lock을 통해 Lock을 걸었다면, 해당 트랜잭션이 종료될 때까지 다른 곳에서 Shared Lock, Exclusive Lock을 걸 수 없다.
Q. A트랜잭션에서 SELECT ... FOR UPDATE
을 걸어서 데이터를 조회하고 아직 트랜잭션이 종료되지 않은 상황이다. 이 때 B트랜잭션에서 Lock없이 SELECT
로 조회한다면 ?
B트랜잭션은 잠금을 걸지 않고 단순 조회하므로 락 없이 SELECT
를 실행할 수 있다. 반면, B트랜잭션이 공유락(SELECT … FOR SHARE
)으로 조회한다면 A트랜잭션이 종료될 때까지 대기하고 데이터를 조회한다.
Pessimistic Lock 실습
JPA는 동시성 제어 메커니즘을 지원해 Pessimistic Lock, Optimistic Lock을 쉽게 설정할 수 있다.
- 쿼리 메서드에
@Lock
어노테이션을 이용해 쉽게 Pessimistic Lock을 걸 수 있다. - 실제 쿼리를 확인해보면
for update
가 추가되어 실행되는 것을 확인할 수 있다.
JPA 비관적 락 - LockModeType
PESSIMISITIC_WRITE
- 일반적으로 비관적 락이라고 하면 해당 옵션을 의미한다.
- 다른 곳에서 해당 데이터의 접근을 막는다.
- 베타락(FOR UPDATE)을 사용해 락을 건다.
PESSIMISTIC_READ
- 데이터를 반복 읽기만 하고 수정하지 않을 때 사용
- 공유락(FOR SHARE)을 사용해 락을 건다.
PESSIMISTIC_FORCE_INCREMENT
- 버전 정보를 사용하는 방법
- 하이버네이트의 경우, *NOWAIT를 지원하는 데이터베이스에서 FOR UPDATE NOWAIT 옵션을 적용하고, 지원하지 않는다면 베타락(FOR UPDATE)를 적용
* NOWAIT ?
MySQL에서는 버전 8.0부터 읽기 일관성을 위한 read lock에 두가지 옵션이 제공된다.
- NOWAIT : 쿼리 실행 후 읽으려는 row에 락이 걸려있으면, 기다리지 않고 실패 처리
- SKIP LOCKED : 쿼리 실행 후 읽으려는 row에 락이 걸려있으면, skip하고 다음 row를 읽는다.
장단점
장점
- Lock을 통해 업데이트를 제어하기 때문에 데이터 정합성을 보장할 수 있다.
- 충돌이 빈번하게 일어난다면 Optimistic Lock보다 성능이 좋을 수 있다.
단점
- 별도의 Lock을 잡기 때문에 성능 감소가 있을 수 있다.
3-2. Optimistic Lock
- 실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법
- version 필드를 추가해, 데이터를 읽을 때와 반영할 때 버전값을 비교한다. 내가 읽은 버전이 아닌 다른 버전이라면 애플리케이션에서 데이터를 다시 읽은 후 작업을 수행
- 충돌이 발생할 것이라고 ‘낙관적’으로 가정하기 때문에, 데이터에 대한 변경이 드물게 발생하고 충돌 가능성이 낮은 환경에 적합하다.
예시
server1과 server2에서 공유자원에 접근하는 상황이다.
server1
SELECT * FROM stock WHERE id = 1
-- (1) version : 1
UPDATE quantity = 98, version = version + 1
FROM stock
WHERE id = 1 and version = 1
-- (3) version : 2
server2
SELECT * FROM stock WHERE id = 1
-- (2) server1이 작업을 반영하기 전에 조회 => version : 1
-- (server1이 이미 작업을 반영시켜 version : 2로 변경됨)
UPDATE quantity = 98, version = version + 1
FROM stock
WHERE id = 1 and version = 1
-- (4) 여전히 version은 1이며, 저장된 version과 값이 달라 해당 작업은 무시
- server1 조회 후 작업을 반영하기 전, server2가 조회한다면 server2의 작업을 반영할 때 version의 값이 일치하지 않아 작업을 실행하지 않는다.
Optimistic Lock 실습
- Entity에 version 컬럼 추가.
@Version
어노테이션을 붙여준다.- 이때 javax.persistence.Version을 사용해야 한다.
@Version
은 처음 조회된 값과 commit될 때의 값이 다르다면, 충돌이 발생한 것으로 판단하고 예외를 발생시킨다.- `@Version`은 int, Integer, short, Short, long, Long, java.sql.Timestamp 타입에 적용할 수 있다.
- 쿼리 메서드에
@Lock
어노테이션을 이용해 쉽게 Optimistic Lock을 걸 수 있다. 실제 쿼리는 락없이 version 필드를 통해 정합성을 확인한다.
JPA 낙관적 락 - LockModeType
NONE
- default 옵션으로, 조회한 엔티티를 수정하는 시점에 다른 트랜잭션으로부터 변경되지 않도록 보장
- 엔티티 수정하는 시점에 엔티티의 버전을 증가하고, 이때 조회 시점과 버전이 일치하지 않는다면 예외를 발생
OPTIMISTIC
- NONE은 엔티티를 수정할 떄만 버전을 체크하지만, 해당 옵션은 엔티티를 조회만 해도 버전을 체크한다.
- 조회 시점부터 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않음을 보장
- 애플리케이션 레벨에서 REPEATABLE READ 격리 수준을 지원
OPTIMISTIC_FORCE_INCREMENT
- 엔티티가 물리적으로 변경되지 않았지만, 논리적으로 변경되었을 경우 버전을 증가하고 싶을 때 사용
- ex) 게시물과 첨부파일이 1:N 관계로 있을 때, 게시물에 첨부파일이 하나 추가된 상황에서 논리적으로 게시물의 버전을 변경시킬 수 있다.
- 낙관적 락을 사용할 때는, 버전이 일치하지 않는다면 예외가 발생된다. 따라서 특정 ms 이후에 다시 조회 및 수정하는 작업을 진행해야 한다.
- 이 역할을 위한 Facade 클래스를 생성했다.
장단점
장점
- 별도의 Lock을 걸지 않아 Pessimistic Lock보다 성능 상 이점이 있다.
단점
- 개발자가 실패 로직을 직접 작성해줘야 한다.
→ 만약 충돌이 빈번하게 일어날 것으로 예상된다면 Pessimistic Lock을, 그렇지 않다면 Optimistic Lock을 설정하는 것이 좋다.
3-3. Named Lock
- 이름을 가진 메타데이터 Lock
- 실제 데이터에 락을 거는 것이 아닌, 별도의 공간에 락을 걸어서 사용한다.
- Pessimistic Lock은 row나 테이블 단위로 락을 걸지만, Named Lock은 메타데이터에 락을 건다.
- 트랜잭션이 종료될 때 락이 자동으로 해제되지 않아, 별도의 명령어로 락을 해제해주거나 선점시간이 끝나야 해제된다.
Named Lock 실습
MySQL에서 Named Lock을 사용한다면, `get_lock()` 명령어를 통해 Lock을 획득하고, `release_lock()` 명령어를 통해 반환한다.
- nativeQuery옵션을 설정해 get_lock(), release_lock() 작성
(^참고로 실무에서 네이티브 쿼리를 사용할 때, JPA와 같은 DataSource를 사용하지 말고 분리해서 사용해야 한다. 같은 DataSource를 사용하면 커넥션 풀이 부족해지는 현상으로 인해 다른 서비스에도 영향을 줄 수 있기 때문이다.^)
- 재고를 감소시키는 로직 앞뒤로, Named Lock을 획득하고 반환하도록 한다.
- 역시 이를 위한 Facade 클래스를 생성했다.
장단점
장점
- Named Lock은 주로 분산락을 구현할 때 사용한다.
- Pessimistic Lock은 timeout을 구현하기 힘들지만 Named Lock은 timeout을 구현하기 쉽다.
단점
- 트랜잭션 종료시에 Lock 해제 및 세션 관리를 잘해줘야하고 실제 구현할 때는 구현 방법이 복잡해질 수 있다.
Q. 동시성 제어 메커니즘과 트랜잭션 격리 수준의 차이점 ?
- 락은 특정 엔티티에 대한 동시 접근을 막기 위해 사용하는 반면, 트랜잭션 격리 수준은 트랜잭션 동안의 일관된 데이터 읽기를 고려하기 위해 적용한다.
- 락은 공유 자원인 데이터베이스에 동시성을 떨어뜨리기 때문에 성능상 매우 비효율적이다.
- 따라서 대부분의 데이터베이스는 트랜잭션 격리 수준을 구현할 때 락을 사용하지 않는다고 한다. MySQL은 트랜잭션 내부에서 일관된 읽기를 구현하기 위해 락 대신 *MVCC을 사용한다.
* MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) ?
데이터베이스에서 동시성 제어를 위해 사용하는 방법 중 하나로, Lock을 걸지 않고 동시에 데이터를 읽고 쓸 수 있도록 한다. 이는 스냅샷을 이용하여 원본의 데이터와 변경중인 데이터를 동시에 유지해 여러 버전을 관리하기 때문에 가능하다.
- 각각의 트랜잭션이 접근할 때마다 스냅샷을 만든다.
- 각 트랜잭션은 접근 후 생성된 스냅샷을 이용하며, 다른 트랜잭션들은 이전 데이터를 계속해서 읽을 수 있다.
- 따라서 트랜잭션 안에서 데이터를 변경할 때 기존 값을 즉시 덮어쓰지 않고 스냅샷을 이용해 새로운 버전의 값을 생성한다.
- 트랜잭션이 데이터를 수정할 때는 새로운 버전을 생성하고, 최종 커밋이 완료되어야만 현재 버전을 데이터베이스에 반영한다.
- 만약 두개의 트랜잭션이 동시에 수정하려는 경우, 먼저 실행된 트랜잭션의 작업이 반영되며 나중에 시작된 트랜잭션은 롤백될 수 있다.
4. 해결방법3 - Redis (권장)
Redis에서 동시성 문제를 해결할 때 사용하는 라이브러리 Lettuce, Redisson을 이용해 해결해보자. Lettuce와 Redisson은 주로 분산락을 구현할 때 사용하는 라이브러리들이다.
4-1. Lettuce
setnx
명령어를 활용하여 분산락 구현- setnx(set if not exist) : key-value를 설정할 때, 값이 없을 때만 set하는 명령어
- spin lock 방식
- 락을 획득하려는 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방식
- 따라서 개발자가 직접 retry로직을 작성해줘야 한다.
- spin lock 방식은 레디스에 부담을 줄 수 있다. 따라서 스레드 슬립을 통해 락 획득에도 약간의 텀을 둬야 한다.
Lettuce 실습
- 스프링 의존성 추가 -
spring-data-redis
(3.3.4 사용)
- RedisTemplate을 사용해 lock, unlock 구현
- lock() : 해당 key를 가진 lock이 존재하지 않는다면 true를 반환하고, 이미 존재한다면 false를 반환한다.
- unlock() : lock에 사용된 데이터를 제거한다.
- Facade 클래스를 만들어, 재고 감소 로직 앞뒤로 lock & unlock을 실행한다.
장단점
장점
- 구현이 간단하다
spring-data-redis
를 이용하면 Lettuce를 기본으로 사용하기 때문에 별도의 라이브러리를 사용하지 않아도 된다.- Lettuce를 활용한 방법은 Named Lock과 거의 비슷하지만, Named Lock과는 달리 세션 관리에 신경을 안 써도 된다는 장점이 있다.
단점
- spin lock 방식이기 때문에 동시에 많은 스레드가 lock 획득 대기 상태라면 redis에 부하가 갈 수 있다.
4-2. Redisson
- pub-sub 기반으로 Lock 구현 제공
- 채널을 하나 만들고, 락을 점유 중인 스레드가 락 반환 시 락 획득을 대기중인 스레드에게 알려, 이후 대기중인 스레드들이 락 획득을 시도하는 방식
- 대부분의 경우, 별도의 retry로직을 작성하지 않아도 된다.
- Lettuce는 계속 락 획득을 시도하는 반면에 Redisson은 락 해제가 되었을 때 한번 혹은 몇번만 시도하기 때문에 레디스에 부하를 줄여주게 된다.
ex) 채널에 스레드1이 점유하고 있는 상태에서 스레드2가 락 점유를 시도하려고 한다면 대기시킨다. 스레드1이 락을 해제할 때 채널에 메세지를 보내고 이를 받은 스레드2는 락 획득을 시도한다.
Redisson 실습
- Redisson은 락 관련된 클래스들을 라이브러리에서 제공하기 때문에, 우리가 별도의 레포지터리를 작성하지 않아도 된다.
- 로직 실행 전후로 락 획득, 해제만 작성해주면 된다.
RedissonClient
를 사용해 쉽게 lock 획득, 해제가 가능하다.
장단점
장점
- pub-sub방식으로 구현이 되어있기 때문에 lettuce와 비교했을 때 redis에 부하가 덜 간다.
- 락 획득 재시도를 기본으로 제공한다.
단점
- 별도의 라이브러리를 사용해야한다.
- lock을 라이브러리 차원에서 제공해주기 때문에 사용법을 따로 공부해야 한다.
→ 따라서 재시도가 필요하지 않은 경우에는 Lettuce를 활용하여 구현하고, 재시도가 필요한 경우에는 Redisson을 활용해 구현한다.
5. DB vs Redis
DB
- 이미 사용중인 데이터베이스가 있다면 별도의 비용없이 사용 가능하다.
- 어느 정도의 트래픽까지는 문제없이 활용이 가능하다.
- Redis보다는 성능이 좋지않다.
Redis
- 활용중인 Redis가 없다면 별도의 구축비용과 인프라 관리비용이 발생한다.
- DB보다 성능이 좋다.
→ 실무에서 비용적 여유가 없거나 DB로 처리가 가능할 정도의 트래픽이라면 DB을 활용하고 / 비용적 여유가 있거나 DB로는 처리가 불가능할 정도의 트래픽이라면 Redis를 도입한다.
6. 마무리
내가 참여했던 프로젝트들은 소규모 사용자를 기반으로 해서 동시성 문제를 심도 있게 다루지 못한 부분이 있었다. 대규모 시스템에서는, 특히 재고 관리와 같은 데이터 정합성이 중요한 기능에서 동시성 이슈가 필수적으로 고려되어야 한다. 앞으로는 이번에 학습한 동시성 제어 기법과 트랜잭션 관리 방식을 적용해, 다수의 사용자가 동시에 접근하더라도 데이터 무결성을 유지할 수 있는 시스템을 구축하고자 한다.
참고자료 😃
https://www.inflearn.com/course/동시성이슈-재고시스템
https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock
https://lsj31404.tistory.com/84
https://mangkyu.tistory.com/53
'Spring' 카테고리의 다른 글
[Spring] Log4J, Logback, Log4J2 개념 (+Logback실습) (1) | 2024.11.21 |
---|---|
[Spring] SpringBoot에서 Redis 적용하기 (+부하 테스트) (8) | 2024.11.08 |
[Spring] Apache POI (+ Multipart, Spring 구현) (0) | 2024.05.14 |
[Spring] V2. OAuth2.0으로 소셜로그인 구현하기 (spring-security-oauth2-client 사용) (0) | 2024.03.23 |
[Spring] V1. OAuth2.0으로 소셜로그인 구현하기 (0) | 2024.03.16 |