추후 소개 할 "메뉴 재고 차감" 로직에 앞서 데이터베이스 관점에서 트랜잭션과 그에 따른 락 처리등에 대해 알아보고자 한다.
메뉴 재고를 차감하는 다양한 시나리오가 있을 것이다. 각각의 서비스마다, 혹은 구현해야 할 기능마다 적합한 재고 차감 시나리오가 존재할 것이며 어울리는 최적의 트랜잭션과 락을 구축하는 것이 중요하다.
Lock wait timeout exceeded + DeadLock
주문 트랜잭션 내부 메뉴 재고 차감 로직에서 유의해야 할 부분은 무엇일까?
이전 포스팅에서도 언급하였다시피 "Lock Timeout"의 문제가 있을 것이다. 만약 주문 과정에 있어 동일한 메뉴에 여러 명의 유저가 접근하게 될 경우 대기시간이 발생할 것이다. 이때 발생할 수 있는 timeout exceeded(타임아웃 초과)를 해결하기 위해 결제 모듈을 트랜잭션으로부터 분리하는 등의 과정을 거쳤지만 이는 100%의 해결법이 될 수 없고 추가로 발생할 수 있는 "Dead Lock(교착 상태)"까지 고려해야 한다.
추가적으로 주문 트랜잭션 수행 중 "재고 수량"에 대해 어느 정도의 "격리 혹은 유연함"을 부여할 것인가에 관해 고민해 볼 필요가 있다. 이 부분에서 가장 중요한 것은 본인이 진행하고 있는 프로젝트의 (그 중에서도 해당 재고 차감 기능) 특징에 대해 정확히 이해하는 것이지 않을까 싶다.
같은 재고 차감이라 할 지라도 프로세스의 특징에 따라 저 마다의 유연함과 격리(엄격함)성이 요구될 것이다.
Test Scenario
테스트를 진행하기에 앞서 도커를 통해 mysql을 구동하여 준다.
docker run --name example_db -e MYSQL_ROOT_PASSWORD=root -p 3308:3306 -d mysql
(port: 3308, container_name: example_db)
그 후 아래와 같이 mysql로 접속할 수 있다.
docker exec -it example_db mysql -u root -p
테이블 생성 및 데이터 삽입
mysql> USE example_db;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
mysql> CREATE TABLE menus (menuId INT PRIMARY KEY, stock INT);
Query OK, 0 rows affected (0.11 sec)
mysql> INSERT INTO menus (menuId, stock) VALUES (1, 10), (2, 10);
Query OK, 2 rows affected (0.02 sec)
Records: 2 Duplicates: 0 Warnings: 0
menus
테이블을 생성한 후 간단한 테스트를 위해 2개의 레코드를 등록한다.
mysql> SELECT * FROM menus;
+--------+-------+
| menuId | stock |
+--------+-------+
| 1 | 10 |
| 2 | 10 |
+--------+-------+
2 rows in set (0.00 sec)
```
두 개의 세션에 각 각 트랜잭션 시작
락을 통한 교착 상태를 테스트 하기 위해 2개의 세션 준비 (터미널로 진행)
각 각의 세션에 트랜잭션 시작
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
첫 번째 세션에서 menuId=1
인 메뉴의 재고 차감
mysql> UPDATE menus SET stock = stock - 1 WHERE menuId = 1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> SELECT * FROM menus;
+--------+-------+
| menuId | stock |
+--------+-------+
| 1 | 9 |
| 2 | 10 |
+--------+-------+
2 rows in set (0.00 sec)
commit (x)
두 번째 세션에서 menuId=2
인 메뉴의 재고 차감
mysql> UPDATE menus SET stock = stock - 1 WHERE menuId = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
commit (x)
자, 이렇게 함으로써 현재 첫 번째 세션은 menuId=1
의 메뉴에 대한 락을 취득하였고 두 번째 세션은 menuId=2
에 대한 락을 취득하게 되었다.
이제부터가 문제를 맞이할 수 있는 상황이다.
현재 임의로 두 세션의 UPDATE 쿼리 수행 후 commit
을 하지 않았다. 이는 Lock Timeout Exceeded
를 야기할 것을 의미한다.
두 번째 세션에서 menuId=1
인 메뉴의 재고 차감
mysql> UPDATE menus SET stock = stock - 1 WHERE menuId = 1;
두 번째 세션에서 위와 같이 menuId=1
인 레코드에 대해 접근할 경우 기존 첫 번째 세션에서 획득한 락으로 인해 대기 상태에 머무는 것을 확인할 수 있을 것이다.
이것이 바로 "동시성(Concurrency) 문제"이다.
현재 첫 번째 세션에서 menuId=1
에 대한 재고 수량 차감(업데이트) 후 commit
을 해주지 않았기 때문에 데이터베이스 수준에서 제시된 락 타임아웃 시간이 지나면 아래와 같은 에러를 마주하게 된다.
Lock wait timeout exceeded; try restarting transaction
첫 번째 세션에서 menuId=2
인 메뉴의 재고 차감 ⚠
mysql> UPDATE menus SET stock = stock - 1 WHERE menuId = 2;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
바로 이 상황에서 "Dead Lock(교착 상태)"이 발생하게 된다.
현재, 락이 풀리지 않은 menuId=1
의 레코드에 대해 두 세션의 동시적 수정 접근이 일어났고 그에 따라 두 번째 선점한 세션에 대한 대기가 발생하였다.
이 상태에서 menuId=2
에 마저 두 세션의 동시적 수정 접근이 일어난 것이다. 즉, 서로가 서로에 대한 락을 획득하려 대기하는 상황에 빠지게 되었고, 우린 이를 "교착 상태"라 부른다.
첫 번째 세션 자동 Rollback / 두 번째 세션 Rollback or Commit
데드락 발생으로 인해 첫 번째 세션은 롤백 처리되고, 두 번째 세션의 경우는 커밋을 할 것인지 롤백을 할 것인지 결정해 줄 수 있다.
Shared Lock && Exclusive Lock
(Pessimistic Lock
)데이터베이스 Lock(락)엔 "Shared Lock(공유 락 _read lock)"과 "Exclusive Lock(쓰기 락 _write lock)"이 존재한다.
각각의 특징에 대해 알아보고 어떤 방법을 사용할지에 대해 고민해보자.
Shared Lock
(공유 락)동일하게 2개의 세션을 열고 각각 트랜잭션을 시작한다. (임의로 첫 번째 세션을: A, 두 번째를 B라 하겠습니다)
A세션의 조회 쿼리에 Shared Lock 획득(for share
)
mysql> select * from menus where menuId=1 for share;
+--------+-------+
| menuId | stock |
+--------+-------+
| 1 | 10 |
+--------+-------+
commit (x)
B세션에서 menuId=1
의 레코드에 업데이트 쿼리 수행
mysql> UPDATE menus SET stock = stock - 1 WHERE menuId = 1;
결과: A세션에서 "Shared Lock"을 획득한 상태에서 commit
을 수행하지 않았으므로 대기에 빠진다.
A세션에서 commit
을 수행한다면 B세션의 업데이트 쿼리는 대기가 풀리며 처리가 된다.
B세션에서 menuId=1
의 레코드에 조회 쿼리 수행
mysql> select * from menus where menuId=1 for share;
+--------+-------+
| menuId | stock |
+--------+-------+
| 1 | 10 |
+--------+-------+
1 row in set (0.01 sec)
다른 세션(트랜잭션)의 Shared Lock은 허용한다.
즉, "Shared Lock(공유 락)"의 경우는 읽기는 허용하며 쓰기는 허용하지 않는다.
Exclusive Lock
(배타 락)Exclusive Lock의 경우 흔히 Write Lock(쓰기 락)이라 불리며 이는 Shared Lock과는 다르게 이름에서 예측할 수 있듯 쓰기는 물론 읽기까지 허용하지 않는다. (쓰기, 읽기 모두에 잠금을 건다)
A세션의 조회 쿼리의 Exclusive Lock 획득 (for update
)
mysql> select * from menus where menuId=1 for update;
+--------+-------+
| menuId | stock |
+--------+-------+
| 1 | 10 |
+--------+-------+
1 row in set (0.00 sec)
commit (x)
B세션에서 동일하게 Exclusive Lock을 통한 조회
mysql> select * from menus where menuId=1 for update;
결과: commit
되지 않은, 배타 락을 먼저 획득한 A 세션으로 인해 무기한 대기에 빠진다. 만약 A세션의 커밋 혹은 롤백시 B세션의 대기는 풀리며 반영된다.
일반 모놀리식 환경(단일 DB 가정)에서 적절한 재고 차감 시나리오는 무엇일까?
정답은 없다.
물론, 분산 환경이 아닌 단일 DB 환경이므로 해결책에 대한 선택지는 줄어들 수 있겠지만 "어떤 재고 차감 서비스인가?" 에 따라 상이 할 것이다.
최대한 serializable하게 잡아 확실한 동시성 제어를 보장할 수도 있지만 이는 동시에 성능에 부하를 주는 결론으로 이어진다.
공연 티켓팅, 극장 예매, 그리고 현재 나의 프로젝트에서 서술하고 있는 메뉴 재고 차감 등 여러 서비스에서 어떤 포인트에 중점을 둘 것인가를 결정하는 것이 가장 선수되어야 할 핵심이지 않을까 싶다.
Use Exclusive Lock
+ Prevent Circular Wait
프로젝트의 초기 단계엔 많은 유저가 모이지 않을 것이며 설령 많은 유저가 생긴다 하더라도 티켓팅과 같은 동시 다발적인 접근 요청이 이루어지는 것이 아닌, 각 매장의 메뉴에 대한 동시적 접근 정도가 이루어진다.
더불어 재고의 잔여 수량에 대해 정확하게 체킹하는 것을 중점으로 둔다. 서로 다른 트랜잭션에서 수량에 대한 동시 접근 시 다른 조회 값을 불러오는 경우가 발생하면 안된다.
이러한 가정과 함께 Exclusive Lock을 사용하여 동시성을 제어해 보고자 한다.
✔ Scenario
동시성을 제어할 수 있으며 "데드락(교착 상태)"을 피하는 방법 역시 고려해 보아야 한다. 물론 이는 최대한 트랜잭션을 작게 잡는 것을 전제로 한다.
재고를 차감하기 전, 메뉴의 재고 수량이 존재하는가(0보다 큰가)에 대한 체킹을 먼저 수행 해준다. 이때 조회문에서 "SELECT ... FOR UPDATE"를 통한 Exclusive Lock을 취해준다면 애초에 조회부터 못하게 되니 원치 않은 잔여수량이 계산되는 등의 동시성 문제를 어느정도 해결할 수 있다. (물론 성능감소는 따라올 것이다)
하지만 그렇다고 해서 "Dead Lock"을 피할 수 있는 것은 아니다. 만약 여전히 맞물린 메뉴에 대해 각자의 락 선점이 이루어졌을 경우 교착은 일어나게 된다.
이 문제는 "Circular Wait(순환 대기, 환형 대기)" 라 부르며, 아래의 방법을 해결책으로 제시할 수 있다.
락 "순서화" 를 통해 모든 트랜잭션에서 동일한 순서로 락을 획득하게 하여 사이클을 형성하는 대기 상태를 방지한다.
즉, 자원 A, B, C가 존재한다면 락의 순서 또한 항상 A, B, C가 되게끔 하는 것이다.
만약, 메뉴 1, 2에 대한 재고를 업데이트 할 경우 각 트랜잭션 마다 전부 메뉴 1에 대한 락 획득 -> 메뉴 2에 대한 락 획득 으로 진행한다.
# Transaction 1
START TRANSACTION;
-- 메뉴 1의 재고를 업데이트하기 위해 해당 레코드를 선택하고 FOR UPDATE를 수행합니다.
SELECT * FROM menus WHERE menuId = 1 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 1;
-- 메뉴 2의 재고를 업데이트하기 위해 해당 레코드를 선택하고 FOR UPDATE를 수행합니다.
SELECT * FROM menus WHERE menuId = 2 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 2;
COMMIT;
# Transaction 2
START TRANSACTION;
SELECT * FROM menus WHERE menuId = 1 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 1;
SELECT * FROM menus WHERE menuId = 2 FOR UPDATE;
UPDATE menus SET stock = stock - 1 WHERE menuId = 2;
COMMIT;
메뉴 재고 차감 시나리오를 간단한 쿼리문을 통해 전개해 나가는 시간을 가져보았다.
가장 먼저 Lock Timeout과 Dead-Lock의 개념과 발생 이유 및 상황에 대해 인지할 필요가 있었으며, 동시성(Concurrency)문제와 데드락 문제를 해결할 수 있는 방법을 고민하였다.
이번 포스팅에서 설명한 비관적 락(Pessimistic Lock)외에 실제 DB 수준의 락을 사용하는 것이 아닌 버전 정보를 이용하여 동시성을 제어하는 "낙관적 락(Optimistic Lock)" 또한 존재한다.
비관적 락, 낙관적 락 모두 각자의 장단점이 존재하며 구현하고자 하는 프로세스및 추가 환경을 고려하여 선택하는 것이 중요하다.
다음 포스팅에선 위의 종합된 내용들을 토대로, NestJS와 Typeorm을 사용하여 재고 차감 로직을 작성하는 시간을 가져보고자 한다.
개발자 추천 드리고갑니다