포스팅 목표
트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized
들어가기 전.. Spring @Transactional with synchronized keyword doesn't workLet's say I have a java class with a method like this (just an example) @Transactional public synchronized void onRequest(Request request) { if (request.shouldAddBook()) { if (
seondays.tistory.com
(이전 포스팅에서 이어집니다)
이번 포스팅에서는 MySQL InnoDB 트랜잭션의 격리 수준을 `REPEATABLE READ`에서 `SERIALIZABLE`로 변경한다면 동시성 문제 발생 상황(Lost Update)에서 어떠한 변화가 있을지를 테스트해 봅니다.
SERIALIZABLE로 변경 후 테스트
@Transactional(isolation = Isolation.SERIALIZABLE)
public void test() {
TestEntity entity = testRepository.findById(1L).orElseThrow();
int count = entity.getCount();
if (count > 0) {
entity.setCount(count - 1);
} else {
throw new IllegalStateException("숫자가 모두 소진되었습니다.");
}
testRepository.save(entity);
}
기존 코드 로직을 트랜잭션 생성 시 격리 수준을 `SERIALIZABLE`로 설정하도록 변경하고 테스트를 진행해 보겠습니다.
결과는 예외가 발생하며 테스트가 실패합니다. 예외 메시지를 살펴보면 데드락이 발생한다는 것을 확인할 수 있는데요.
저는 격리 수준을 `SERIALIZABLE`로 변경할 경우, 생성된 트랜잭션 150개 중에 앞 순서에서부터 100개가 순차대로 실행되어 테스트가 성공할 것이라고 생각했는데 전혀 예상하지 못한 예외가 발생했습니다.
왜 예상과 다르게 데드락으로 인한 롤백 예외가 발생하는 걸까요?
Serializable의 개념과 SERIALIZABLE 정의 알아보기
Serializable
본격적인 데드락 이야기 전 잠깐 생각해 보겠습니다. Serializable가 어떤 뜻을 가지는 단어이기에 가장 강력한 고립 수준을 나타내는 명칭이 되었을까요?
검색해 보면 Serializable는 직렬화라는 뜻으로 번역되며, DB 트랜잭션 관련뿐만 아니라 객체 & 바이트 스트림 관련해서 사용되는 단어이기도 합니다. 저는 처음에 단어를 접했을 때 `serial하게 한다`는 말뜻 그대로 무언가(객체, 트랜잭션 등등)를 연속적 혹은 순차적으로 만든다는 의미로 이해를 했었는데요.
정확하게는 DB 트랜잭션에서의 Serializable이란 트랜잭션이 병렬로 실행되는 방식(Non-Serial Schedule, 비직렬 스케줄)에서의 실행 결과가 논리적으로는 마치 순차적 실행(직렬)이 된 것처럼 작동하는 속성을 의미한다고 합니다.
여기서 포인트는 트랜잭션이 실제로 순차적으로 실행되지 않는다는 점입니다.
따라서 `SERIALIZABLE` 역시 비직렬 스케줄 방식으로 동작한 결과를 직렬 스케줄을 실행한 결과와 동일하도록 논리적으로 보장하는 것이지, 물리적으로 트랜잭션들이 직렬로 차례차례 실행되는 것은 아닙니다.
그러니까 100개의 트랜잭션이 순차대로 실행되기 때문에 테스트가 성공할 것이라는 제 예상이 틀린 예상이었던 것이에요 🥲
SERIALIZABLE
그렇다면 MySQL의 `SERIALIZABLE` 격리 수준은 어떻게 정의되어있는지도 한번 확인해 보겠습니다. 아래는 MySQL 9.0 InnoDB 스토리지 엔진에서의 `SERIALIZABLE` 격리 수준의 정의입니다.
This level is like REPEATABLE READ, but InnoDB implicitly converts all plain SELECT statements to SELECT ... FOR SHARE if autocommit is disabled. If autocommit is enabled, the SELECT is its own transaction. It therefore is known to be read only and can be serialized if performed as a consistent (nonlocking) read and need not block for other transactions. (To force a plain SELECT to block if other transactions have modified the selected rows, disable autocommit.) DML operations that read data from MySQL grant tables (through a join list or subquery) but do not modify them do not acquire read locks on the MySQL grant tables, regardless of the isolation level. For more information, see Grant Table Concurrency.
여기서 가장 핵심을 다음과 같이 정리해볼 수 있겠는데요.
REPEATABLE READ와 유사하지만, autocommit이 꺼져 있는 경우 모든 일반 SELECT 문을 SELECT … FOR SHARE로 변경한다 → `SERIALIZABLE`에서는 읽기 작업 시에도 공유락을 획득
이것은 작업에 있어 더 많은 락을 사용한다는 말과도 같아요. (원래라면 SELECT문에서 사용하지 않았을 공유락 사용) 즉, 다르게 말하면 데드락이 발생하기 쉬운 환경이 된다는 의미이기도 합니다.
데드락 발생 상황 분석
본격적으로 데드락이 왜 발생했는지 확인해봅시다!
이번 섹션에서는 코드의 어떤 부분에서 데드락이 발생하기 위한 4가지 조건들이 충족되었는지 체크해 보겠습니다.
- 상호 배제 : 자원은 한 번에 하나의 트랜잭션만 접근해서 사용 가능
- 점유와 대기 : 자원을 가지고 있는 상태에서 필요한 다른 자원을 요청하고 대기
- 비선점 : 외부에서 해당 자원 점유를 강제로 뺏을 수 없음
- 환형 대기 : 트랜잭션들이 서로의 자원을 기다리면서 원형으로 대기
TestEntity entity = testRepository.findById(1L).orElseThrow();
맨 첫 번째로 특정 레코드를 조회해서 가져오는 코드가 있습니다. 다음과 같은 예시를 생각해 볼게요.
- 1번 트랜잭션이 SELECT..FOR SHARE 해서 해당 레코드에 접근하고, 공유락을 획득합니다.
- 바로 뒤이어 2번 트랜잭션이 SELECT..FOR SHARE 해서 해당 레코드에 접근합니다. 2번 역시 공유락을 획득합니다.
여기까지는 아무런 문제가 없고, 이제 그다음으로 레코드 값을 새롭게 갱신하는 코드가 있습니다.
entity.setCount(count - 1);
(..생략..)
testRepository.save(entity);
1번 트랜잭션이 count를 감소시킨 후 저장하려고 먼저 시도합니다.
- 1번 트랜잭션이 UPDATE를 통해 쓰기 작업을 시도하며, 쓰기 작업은 배타락이 필요합니다.
- 하지만 2번 트랜잭션이 공유락을 가지고 있는 상태로, 2번이 가진 공유락이 반납되어야만 1번이 배타락을 획득할 수 있습니다. (2번이 가진 공유락을 강제로 뺏을 수 없음 -> 비선점)
- 따라서 1번 트랜잭션은 이를 기다리며 대기 (점유와 대기)
- 2번 트랜잭션도 마찬가지로 UPDATE를 통해 쓰기 작업을 시도할 것입니다.
- 하지만 1번 트랜잭션이 공유락을 들고 있기 때문에 배타락을 획득하지 못하고 역시 1번 트랜잭션이 공유락을 반납하기를 기다리며 대기
- 그리고 이 모든 과정은 공유락과 다르게 배타락은 공유가 불가능하기 때문에 일어나는 일입니다. (상호 배제)
결론적으로 1번과 2번 트랜잭션이 서로를 기다리며 영원히 대기하게 됩니다. (환형 대기)
엔진은 이런 데드락 상황을 감지하여 둘 중 하나의 트랜잭션을 롤백시켜 버리고, 위와 같이 데드락으로 인한 롤백 예외가 발생하게 되는 것입니다.
데드락 정보 모니터링 해보기
이번에는 실제로 데드락이 어떻게 발생했는지 직접 DB에서 확인해 보겠습니다. `SHOW ENGINE INNODB STATUS\G` 명령어를 통해 InnoDB의 상태를 모니터링할 수 있는데, 여기에서 데드락 관련 정보도 확인 가능합니다.
가장 최근 발생한 데드락 정보를 볼까요?
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-12-30 07:06:08 281472683532032
*** (1) TRANSACTION:
TRANSACTION 7392, ACTIVE 0 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 8145, OS thread handle 281472432258816, query id 75904 192.168.65.1 root updating
update TestEntity set count=71 where id=1
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 37 page no 4 n bits 72 index PRIMARY of table `buddyguard`.`TestEntity` trx id 7392 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000001cdd; asc ;;
2: len 7; hex 01000001200df6; asc ;;
3: len 4; hex 80000048; asc H;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 37 page no 4 n bits 72 index PRIMARY of table `buddyguard`.`TestEntity` trx id 7392 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000001cdd; asc ;;
2: len 7; hex 01000001200df6; asc ;;
3: len 4; hex 80000048; asc H;;
*** (2) TRANSACTION:
TRANSACTION 7391, ACTIVE 0 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 8148, OS thread handle 281471928090368, query id 75903 192.168.65.1 root updating
update TestEntity set count=71 where id=1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 37 page no 4 n bits 72 index PRIMARY of table `buddyguard`.`TestEntity` trx id 7391 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000001cdd; asc ;;
2: len 7; hex 01000001200df6; asc ;;
3: len 4; hex 80000048; asc H;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 37 page no 4 n bits 72 index PRIMARY of table `buddyguard`.`TestEntity` trx id 7391 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000001cdd; asc ;;
2: len 7; hex 01000001200df6; asc ;;
3: len 4; hex 80000048; asc H;;
*** WE ROLL BACK TRANSACTION (2)
해당 모니터링 로그로 다음과 같은 내용을 확인 가능합니다. 앞서 코드로 알아본 데드락 시나리오와 동일한 상황인 것을 볼 수 있어요.
- 7392번 트랜잭션과 7391번 트랜잭션에서 데드락이 발생했다는 것을 알 수 있습니다.
- 두 트랜잭션 모두 `update TestEntity set count=71 where id=1` 쿼리 실행에서 문제가 생겼습니다.
- 두 트랜잭션 모두 공유락(S LOCK)을 획득한 상태입니다. → `trx id 7391/7392 lock mode S locks rec but not gap`
- 두 트랜잭션 모두 배타락(X LOCK)이 부여되기를 기다리고 있습니다 → `trx id 7391/7392 lock_mode X locks rec but not gap waiting`
- 마지막 줄을 통해 엔진이 7391번 트랜잭션을 롤백시켰음을 알 수 있습니다.
재시도 로직 추가로 해결할 수 있을까?
결과를 보고 나니, 그러면 롤백되어 실패한 트랜잭션들을 다시 재시도하는 방법도 있지 않을까? 하는 생각도 들었습니다. 재시도 로직을 위한 Spring Retry같은 라이브러리도 있다는 걸 알게 되었고요.
그런데 생각해 보니 이렇게 선착순 접근이 중요한 상황에서는 실패한 트랜잭션을 잡아서 재시도하는 게 불공정하다는 느낌을 받게 되었습니다.
선착순 티켓팅 시스템을 개발하는데, 비즈니스 규칙이 '먼저 들어온 사용자부터 티켓을 예매할 수 있어야 한다' 라는 상황을 예로 들어보겠습니다. A B C 순서로 트랜잭션이 시작되었고, 한정 티켓은 2장이라고 가정합니다.
- A B 트랜잭션이 거의 동시에 들어오는 바람에 둘 사이에 데드락이 발생합니다.
- DB는 이를 감지하고 A 트랜잭션을 롤백해 버립니다.
- B는 한정 티켓을 획득합니다. 바로 뒤이어 들어온 C도 한정 티켓을 획득합니다.
- A는 롤백 예외를 체크 후 재시도하지만 이미 한정 티켓은 0장입니다.
사실상 제일 첫 번째로 접근한 트랜잭션은 A지만, 위 예시에서는 티켓을 얻지 못하는 상황이 되었습니다. 만일 synchronized 상황에서 순차 접근해서 티켓을 가져갔다면 먼저 들어온 순서대로 A, B가 티켓을 가져갔을 것입니다.
물론 실제로 티켓팅을 하는 상황이라고 했을 때 이용자들이 이러한 사실을 알 도리는 없겠지만.. 결국 어쨌든 주어진 조건을 충족하지 못하는 시스템이 되는 셈입니다.
또 한 가지 문제는 Retry를 사용할 때 무한정으로 재시도시킬 수 없기 때문에 재시도 횟수를 정해야 하는데, 횟수를 1번으로 하더라도 많은 수의 트랜잭션이 계속 재시도 로직을 반복하게 되면 성능에 부담이 될 가능성도 높습니다.
물론 모든 것은 상황과 조건에 따라 달라질 수 있겠습니다. 지금 저는 티켓팅 상황을 가정해서 재시도 로직이 적합하지 않다고 판단했지만, `SERIALIZABLE`와 재시도 로직을 함께 사용하는 방법이 적절한 케이스도 있을 것이라고 생각해요.
마무리
이번 포스팅에서는 Lost Update 문제를 해결하기 위한 방법 중 하나로 `만일 격리 수준을 가장 엄격한 SERIALIZABLE로 변경한다면 코드에서 별다른 컨트롤 없이도 문제가 발생하지 않을 것`이라는 가설을 테스트하고 학습하는 시간을 가졌는데요.
결론적으로 제 케이스에서는 격리 수준을 `SERIALIZABLE` 로 변경하는 것만으로는 데드락으로 인한 롤백 예외가 발생하여 문제를 해결할 수 없었습니다.
그 이유를 크게 아래 세 가지 부분으로 정리할 수 있을 것 같아요.
- `SERIALIZABLE` 격리 수준은 논리적인 직렬화를 보장하며, SELECT 작업 시 `SELECT … FOR SHARE` 쿼리를 사용한다.
- 그렇기 때문에 SELECT 작업 시에도 공유락을 획득하게 되고, 이 때문에 데드락이 발생할 가능성이 높아진다.
- 데드락이 발생하면 DB는 이를 감지하고 롤백시켜 예외를 발생시키기 때문에 우리가 원하는 대로 동작하지 않게 된다.
그렇다면 이제는 격리 수준 조정이 아닌 다른 방법들을 시도해 볼 타이밍인 것 같습니다. 이어지는 세 번째 포스팅에서 관련 내용을 다뤄보도록 할게요!
'etc' 카테고리의 다른 글
트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized (1) | 2025.01.03 |
---|---|
왜 내가 만든 서버는 서버가 먼저 요청을 끊는 걸까? 궁금증 해결기 (0) | 2024.08.23 |
리눅스 scp 사용 시 Permission denied (publickey).lost connection 오류 해결기 (0) | 2024.04.13 |