TL;DR
- MVVC는 트랜잭션 시작점을 기준으로 데이터 버저닝을 한다
- 락 종류에 따라 충돌 상황을 테스트
- 공유락은 말그대로 트랜잭션끼리 공유할 수 있다
- 베타락은 말그대로 하나의 트랜잭션만 가진다
- 공유락을 가진 상태로 업데이트, 삭제 등을 하면 베타락을 획득하려는 시도를 한다
- 락 획득 대기 사이클이 생기면 데드락이 발생한다
- 데드락을 최소화하기 위한 락 설계 방법:
- 락을 잡는 순서를 트랜잭션 마다 동일하게 유지
- 락 범위를 축소
- 첫 쿼리 부터 for update로 가져오는 방법
Introduction
이번 주차는 낙관적 락과 비관적 락을 학습하게 되었다. 비관적 락은 흔히 느리지만 정합성이 중요할때 사용하며, 낙관적 락은 충돌 가능성이 낮을 때 상대적으로 빠른 상황을 요구할때 사용한다고 한다.
다른 접근 개념으로는 비관적 락은 모두 성공해야할때, 낙관적락은 하나만 성공하고 나머지 실패해도 괜찮을때 사용하는 관점으로 접근해도 괜찮다는 의견이 있었다.
나에게는 두 락 개념은 사실 어플리케이션 계층에서 언제 잡을까 나중에 감지할까? 등의 전략 개념으로 보였다. 각각의 락은 DB 레벨에서 아래 처럼 구현될 수 있는데,
먼저 비관적 락은
-- ◆ 재고 차감 예시 (MySQL InnoDB)
START TRANSACTION;
-- ① 읽으면서 잠금: PK = 42 레코드에 X(Row)-Lock
SELECT qty
FROM stock
WHERE id = 42
FOR UPDATE; -- 여기서 이미 경쟁 트랜잭션들 BLOCK
-- ② 비즈니스 로직
-- (애플리케이션에서 qty 체크 → 차감)
UPDATE stock
SET qty = qty - 1
WHERE id = 42;
COMMIT; -- 커밋 시 Row-Lock 해제
처럼 select 문에 for update를 사용해서 베타락을 잠고 트랜잭션이 커밋될때까지 물고 있는것이다. 반면에 낙관적 락은
UPDATE coupon
SET used = true,
version = version + 1
WHERE id = 17
AND version = 5; -- ★ “내가 처음 읽었을 때” 버전
락 없이 데이터를 읽어와서 업데이트 순간에 읽어온 데이터와 비교해서 최신 데이터와 동일한지 체크하고 업데이트를 하는 방식이다. 여기서 버전이나, 날짜 등을 사용하는데 업데이트 결과 얼마나 반영되었는지를 반환값으로 받아서 에러를 발생시켜서 리트라이 하는 식으로 구현할 수 있다.
근본적으로 이러한 낙관적 락, 베타락을 더 이해하기 위해서 물리적이면서 로우 레벨의 락 개념인 베타락과 공유락에 대해서 학습할 필요성을 느껴서 몇가지 테스트를 진행했고 공유하고자 한다.
Methodology
1. DB 테스트 환경
docker run -d \
--name mysql8 \
-e MYSQL_ROOT_PASSWORD=secret123 \
-e MYSQL_DATABASE=playground \
-p 3307:3306 \
-v mysql8_data:/var/lib/mysql \
mysql:8.0
MySQL 8.0을 도커 컨테이너 환경에서 띄워 테스트를 진행하였다. 도커 데스크탑이 설치된 호스트에서 위 명령어를 터미널에 입력하면 컨테이너를 띄울 수 있기 때문에 쉽게 따라할 수 있다. 이후 설정된 값을 이용해서 DB 클라이언트로 접속하여 테스트를 진행해볼 수 있다.
여기서는 IntelliJ Ultimate에 내장된 DB클라이언트를 사용하였다.
2. 테이블 / 데이터 설정
# 포인트 테이블
CREATE TABLE points (
id BIGINT PRIMARY KEY,
balance DECIMAL(12,2),
note VARCHAR(100)
);
# 테스트 데이터
INSERT INTO points VALUES (1, 5000, 'init');
테스트에 진행된 포인트 테이블과 테스트 데이터 설정을 위한 쿼리다. 단순한 테이블 설계와 하나의 레코드만으로 모든 테스트 케이스를 커버할 수 있었다.
3. 디버깅 명령어
# 지금 어떤 커넥션이 무슨 쿼리 돌리는 중인지 (빠른 스냅샷)
SHOW PROCESSLIST;
# InnoDB 트랜잭션 내부 상태/대기 시간 (심화 디버깅용)
SELECT trx_id, trx_state, trx_started,
trx_wait_started, trx_query
FROM information_schema.INNODB_TRX;
# 데드락/버퍼/락 대기 전체 로그 덤프 (종합 리포트)
SHOW ENGINE INNODB STATUS;
또한 테스트 결과를 디버깅 하기위해 위와 같은 명령어들을 사용했다. 각 명령어의 설명은 주석에 나타내었다.
Results
1. 읽기-쓰기 충돌 : FOR SHARE가 UPDATE를 막는지?
Isolation Level은 두 트랜잭션 모두 REPEATABLE READ로 유지하였다.
| Step | Tx1 | Tx2 | Note |
| 1 | start transaction; | Tx1 start | |
| 2 | select * from points where id = 1 for share; | s-lock 획득 | |
| 3 | start transaction; | Tx2 start | |
| 4 | update points set balance = 3000 where id = 1; | lock wait | |
| 5 | commit; | update points |
디버깅을 위해 lock wait 시점에서 show process list; 명령어를 사용했지만 아래 처럼 명확한 상태를 확인하기 어려웠다:

단순히 updating이라는 상태로 표기되어 다음과 같은 명령어로 lock wait 트랜잭션 상태를 확인할 수 있었다.
SELECT trx_id, trx_state, trx_started,
trx_wait_started, trx_query
FROM information_schema.INNODB_TRX

현재 환경에서 lock wait이 50s 유지되고 time out이 발생했다. 찾아보지 않았지만 락 대기 상태에서 전략이나 타임아웃 시간을 설정할 수 있는 방법이 분명있을것 같다.
위 테이블에 명시된 스텝들을 해석하면 다음과 같다:
- Tx1이 공유락을 획득
- Tx2는 UPDATE 구문을 날리기 위해 베타락을 획득하기를 기다린다
- 순환 대기 상태가 아니기 때문에 데드락이 발생하지 않고 락 대기
- 50s이 지나 Tx2는 타임 아웃이 발생
- Tx1는 여전히 트랜잭션 진행 중
결과적으로 공유락 상태에서 다른 트랜잭션의 업데이트는 막게된다.
for share를 실무에서 쓴적은 없지만, s락과 x락의 상호작용을 이해할 수 있는 테스트였다. 일반 읽기에서의 락 획득은 SERIALIZABLE 격리수준에서는 다른 결과가 나오는데 아래에서 테스트를 진행하기 때문에 생략한다.
2. 쓰기-쓰기 충돌 : FOR UPDATE 끼리 경쟁
Isolation Level은 두 트랜잭션 모두 REPEATABLE READ로 유지하였다.
| Step | Tx1 | Tx2 | Memo |
| 1 | start transaction; | Tx1 start | |
| 2 | select * from points where id = 1 for update; | x-lock 획득 | |
| 3 | start transaction; | Tx2 start | |
| 4 | select * from points where id = 1 for update; | lock wait | |
| 5 | Tx2 time out at 50s |

for update는 베타락을 획득하기 때문에 Tx2에서 베타락 획득 시도는 timeout을 초래했다. 1번 테스트와 유사한 결과를 나타내는데 update 구문 자체가 for update와 동일한 베타락을 요구하기 때문이다.
결국 두 트랜잭션은 충돌을 하게되고 나중에 베타락을 요청한 Tx2는 락 대기 상태를 유지한다.
3. 공유락-공유락 충돌
Isolation Level은 두 트랜잭션 모두 REPEATABLE READ로 유지하였다.
| Step | Tx1 | Tx2 | Memo |
| 1 | start transaction; | Tx1 start | |
| 2 | select * from points where id = 1 for share; | s-lock 획득 | |
| 3 | start transaction; | Tx2 start | |
| 4 | select * from points where id = 1 for share; | s-lock 획득 | |
| 5 | update points set balance = 3000 where id = 1; | lock wait | |
| 6 | commit; | release lock wait |
공유락은 말그대로 공유락이기 때문에 레코드를 읽어오면서 트랜잭션 각자 공유락을 획득할 수 있다. 충돌이 발생하는 순간은 한쪽이 update 구문을 사용하면서 베타락 획득을 요청하는데, 해당 레코드에 공유락이 걸려있으면 해지될때까지 락 대기 상태를 유지한다.
위 스텝 테이블에서 Tx2가 커밋하면서, 락 대기 상태가 릴리즈 되는 상황을 확인할 수 있었다. 만약 커밋을 하지 않고 Tx2에서 Tx1처럼 동일한 레코드에 업데이트 쿼리를 날리면 어떻게 될까?
| Step | Tx1 | Tx2 | Memo |
| 1 | start transaction; | Tx1 start | |
| 2 | select * from points where id = 1 for share; | s-lock 획득 | |
| 3 | start transaction; | Tx2 start | |
| 4 | select * from points where id = 1 for share; | s-lock 획득 | |
| 5 | update points set balance = 3000 where id = 1; | lock wait | |
| 6 | update points set balance = 3000 where id = 1; | deadlock |

결과적으로 데드락 로그를 확인할 수 있었다. 더 자세한 디버깅을 위해 SHOW ENGINE INNODB STATUS; 명령어를 실행하여 최근 데드락 발생한 로그를 덤프하였다.
내용이 길어서 대부분은 생략을 하였고, 데드락 관련 사항은 LATEST DETECTED DEADLOCK을 확인하면 된다. (1) TRANSACTION 처럼 앞의 숫자 1, 2는 이 문맥상에서 트랜잭션을 비교하기 위함이다.
두 트랜잭션은 동일하게
- 트랜잭션 시작
- S락을 갭락 없이 홀드
- 업데이트 구문으로 인한 X락 획득 대기 <- 여기서 2번째 트랜잭션이 롤백
=====================================
2025-08-08 00:22:14 281471946743552 INNODB MONITOR OUTPUT
=====================================
(생략)
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-08-08 00:22:09 281472884461312
*** (1) TRANSACTION:
TRANSACTION 3032, ACTIVE 12 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 148, OS thread handle 281471955689216, query id 3948 192.168.65.1 root updating
/* ApplicationName=IntelliJ IDEA 2023.1.7 */ update points set balance = 3000 where id = 1
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3032 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3032 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** (2) TRANSACTION:
TRANSACTION 3033, ACTIVE 7 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 146, OS thread handle 281471952334592, query id 3957 192.168.65.1 root updating
/* ApplicationName=IntelliJ IDEA 2023.1.7 */ update points set balance = 3000 where id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3033 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3033 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** WE ROLL BACK TRANSACTION (2)
(생략)
----------------------------
END OF INNODB MONITOR OUTPUT
============================
2번째 트랜잭션이 롤백되면 1번째 트랜잭션이 요청한 업데이트 구문은 수행되고 트랜잭션 상태가 유지된다. 롤백이나 커밋또한 발생하지 않는다.
데드락이 발생한 원인은 서로의 S락이 풀릴때까지 자신의 S락을 놓치 않아서 발생하는 순환 대기다. 결과적으로 둘중하나를 롤백하는데 가장 비용이 적은 쪽을 롤백한다. 관련해서 조사해보니 다음과 같은 메커니즘을 기반으로 한다:
- INSERT/UPDATE/DELETE 로 실제로 바꾼 (undo 로그에 기록된) 행의 개수를 기준으로 비교 -> 읽기 전용 트랜잭션은 비용이 0이라서 항상 희생 대상
- 비용이 동일한 경우 - wait 시간이 짧은 트랜잭션을 롤백하거나 임의로 선택 (공식적으로 밝혀진 규칙은 없음)
베타락-공유락
Isolation Level은 두 트랜잭션 모두 REPEATABLE READ로 유지하였다.
| Step | Tx1 | Tx2 | Memo |
| 1 | start transaction; | Tx1 start | |
| 2 | select * from points where id = 1 for update; | x-lock 획득 | |
| 3 | start transaction; | Tx2 start | |
| 4 | select * from points where id = 1 for share; | lock wait |
락을 잡으려는 대상 레코드에 공유락과 베타락이 동시에 잡힐 수 없다. 위에서도 비슷한 테스트 결과가 나왔는데, 베타락 자체가 하나만 걸리기 때문인것으로 보인다.
위 스텝 테이블의 순서를 바꿔도 동일한 경우가 발생한다. 예를 들어, x -> s나 s -> x나 동일하게 마지막에 락을 잡는 경우는 락대기에 걸린다.
베타락과 일반 읽기
Isolation Level은 두 트랜잭션 모두 REPEATABLE READ로 유지하였다.
| Step | Tx1 | Tx2 | Memo |
| 1 | start transaction; | Tx1 start | |
| 2 | select * from points where id = 1 for update; | x-lock 획득, balance = 5000 | |
| 3 | update points set balance = 3000 where id = 1; | balance = 3000 | |
| 4 | start transaction; | Tx2 start | |
| 5 | select * from points where id = 1 | balance = 5000 | |
| 6 | commit; | ||
| 7 | select * from points where id = 1 | balance = 5000 | |
| select * from points where id = 1 for update | balance = 3000 |
이 테스트가 이 테스트와 글을 작성하게한 시작점인데 처음에는 결과가 신기했지만 이해하고 나니 당연하게 느껴졌다.
먼저, Tx1이 포인트를 읽어서 5000 -> 3000으로 업데이트하였다. 커밋되지 않은 상태에서 Tx2가 일반 읽기로 포인트를 가져오면 5000으로 읽힌다.
Tx1이 커밋된 이후 Tx2에서 동일하게 일반 읽기를 해오면 여전히 5000으로 읽힌다. 하지만 for update로 읽으면 3000으로 읽힌다. 이 결과의 근본적인 원인은 REPEATABLE READ 격리 수준에서 MVCC의 동작원리와 관련되어있다.
for update는 최신 읽기를 한다. Tx2에서 처럼 트랜잭션 시작점을 기준 이후에 발생한 커밋은 일반 읽기에서 무시되는데, select으로 가져와서 메모리에 캐시되는 방식이 아니라는 점을 이해해야한다.
즉, 트랜잭션이 시작되면 그 시점이 어딘지를 알아온다. 그리고 이후에 발생하는 변경 사항은 log 처럼 쌓이는데 그 로그들을 무시하고 그 시점에서의 값만 읽어온다. 하지만 업데이트를 하기 위해 for update로 select를 해온다면 쌓인 로그들을 모두 반영해서 최신 읽기를 실행한다.
MVCC 의 명칭에서 알 수 있듯이 멀티 버저닝을 트랜잭션 단위로 한다고 이해할 수 있었다. 처음에는 읽은 시점의 데이터를 메모리에 저장해서 반영하는 방식으로 이해했었는데, 트랜잭션 시작 시점의 버전과 로그 데이터를 이용한 버저닝 관리는 대단한 설계인것 같다.
이러한 메커니즘을 이용해서 실무에서도 적용된것을 얼핏본것 같다. 조직도를 개편하는 기능인데, 조직 변경의 히스토리를 삭제, 이동, 생성으로 순차적으로 기록해서 각각의 날짜 시점에 버저닝을 하는 방식이다. 이거 실제로 구현하려면 상당히 어려울것 같다.
Serializable에서 일반 읽기
지금까지는 Repeatable Read 상황에서 비교를 하였는데, Serializable에서는 신기한 결과가 나와서 테스트 결과를 같이 작성하였다. 먼저 쿼리 실행전에 아래 명령어를 입력한다.
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
| Step | Tx1 | Tx2 | Memo |
| 1 | start transaction; | Tx1 start | |
| 2 | select * from points where id = 1; | s-lock 획득, balance = 5000 | |
| 3 | start transaction; | Tx2 start | |
| 4 | select * from points where id = 1 | s-lock 획득, balance = 5000 | |
| 5 | update points set balance = 3000 where id = 1; | lock wait | |
| 6 | update points set balance = 3000 where id = 1; | dead lock → Tx2 rollback |

Serializable에서는 양 트랜잭션 내에서 일반 읽기 쿼리만 발생시켜도 공유락을 획득하는것을 확인할 수 있었다. 그리고 각각 업데이트 문을 순차적으로 발생시키면 서로 s락을 붙들고 x락 획득을 승인받기를 기다리는데 결국 데드락이 발생한다.
이는 위에서 데드락 분석한 방법과 동일하게 SHOW ENGINE INNODB STATUS; 명령어를 통해 확인할 수 있었다.
=====================================
2025-08-08 00:42:05 281471946743552 INNODB MONITOR OUTPUT
=====================================
(생략)
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-08-08 00:41:59 281472884461312
*** (1) TRANSACTION:
TRANSACTION 3036, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 148, OS thread handle 281471955689216, query id 4305 192.168.65.1 root updating
/* ApplicationName=IntelliJ IDEA 2023.1.7 */ update points set balance = 3000 where id = 1
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3036 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3036 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** (2) TRANSACTION:
TRANSACTION 3037, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 146, OS thread handle 281471952334592, query id 4314 192.168.65.1 root updating
/* ApplicationName=IntelliJ IDEA 2023.1.7 */ update points set balance = 3000 where id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3037 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 18 page no 4 n bits 72 index PRIMARY of table `loopers`.`points` trx id 3037 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000bce; asc ;;
2: len 7; hex 02000001330151; asc 3 Q;;
3: len 17; hex 8000000000000000000000000000138800; asc ;;
4: len 8; hex 99b74efc38000000; asc N 8 ;;
5: SQL NULL;
6: len 8; hex 99b74efc38000000; asc N 8 ;;
7: len 8; hex 8000000000000001; asc ;;
8: SQL NULL;
*** WE ROLL BACK TRANSACTION (2)
(생략)
----------------------------
END OF INNODB MONITOR OUTPUT
============================
정리를 하자면 READ COMMITTED / REPEATABLE READ에서는 일반 SELECT 문을 날리는 경우 락없이 MVCC 기반의 스냅샷을 활용한다. 아마도 동시성을 높여서 성능을 확보하기 위함으로 보인다.
반면에, SERIALIZABLE에서는 공유락을 획득하는데, 완전한 직렬화를 보장해서 팬텀리드나 순서 문제를 차단하는 목적으로 생각된다.
실무에서 데드락 해소를 위한 락 설계 조정
테스트를 진행하면서 Low Level의 Lock에 대해 이해할 수 있었다. 그렇다면 실무에서 발생하는 데드락은 어떻게 해소를 해야하고 락을 설계해야할까? 이에 대해 학습한 내용을 아래 정리하려고 한다.
공유에서 베타락을 획득하는건 시간 소모가 들었음을 확인할 수 있었다. 그렇다면 해당 레코드를 결국 업데이트해야한다면 차라리 처음 부터 select for Update로 비관적 락 방식으로 물고 오는게 낫다고 생각한다. 처음 부터 베타락을 획득한다면 단일 레코드에서 데드락 발생 가능성은 낮아지지 않을까 추측된다.
데드락을 줄이는 방법중 가장 빈번하게 언급되는 방법은 레코드를 정렬해서 가져오는것이다. 아래와 같은 규칙을 정해서 트랜잭션 마다 동일하게 유지하는게 좋다고 한다.
SELECT * FROM table
WHERE id IN (:a, :b, :c)
ORDER BY id -- ▲ ascending!
FOR UPDATE;
실무에서는 하나의 테이블이 아닌 여러 도메인들이 동시에 메시지를 보내고 협업을 하게 되는데, 이 어플리케이션 레벨에서 어떻게 할지를 자원 순위표를 만들어 팀 공통으로 사용하는 방법도 있다고 한다.
예를 들어, 주문 서비스에서는 아래와 같은 순서가 있다:
1) point → 2) stock → 3) coupon → 4) order
그렇다면 실제 퍼사드 서비스에서도 이 순서를 보장하면서 로직을 짜기만 하면 된다.
@Transactional
public void placeOrder(...) {
pointRepo.lockAndDeduct(...); // 1
stockRepo.lockAndReserve(...); // 2
couponRepo.lockAndUse(...); // 3
orderRepo.save(...); // 4
}
여기서 알아두면 도움되는 관점은
- Repository는 "잠금 구현"을 책임 지고,
- Service는 "잠금 순서"를 책임진다.
일종의 관심사를 분리해서 각자의 역할에만 신경을 쓰는 방식으로 생각해보니 퍼사드 서비스에서는 어떤 고민을 해야할지 알게 되었다. 현재 주문 유스케이스를 퍼사드 레벨에서 어떻게 작성할지에 대한 고민들이 많았었다. 각각의 도메인과 비즈니스 정책을 이해하고 잠금 정책을 레포지토리 레벨에서 구현하고, 순서를 서비스에서 고민을 해보려고 한다.
마지막으로 SERIALIZABLE은 굉장히 강력하게 동시성 이슈를 해결해버리는데, 트레이드 오프로 해당 로직 자체가 싱글 스레드로 움직이는것 처럼 만들어버린다. 만약 꼭 필요한 경우라면 트랜잭션을 짧게 유지하는게 좋을것 같다.