🔎 본 글을 읽기 앞서 해당 게시글을 먼저 참고 해주시길 바랍니다.
해당 게시글은 추후 내용이 변경될 수 있습니다.
해당 게시글에서 설명하지 않는 사전 지식들은 이미 알고있다고 가정하고 작성된 글입니다.
[DB] 트랜잭션과 동시성 제어
Introduce결제 시스템을 구축하면서 동시성 문제를 경험한 적이 있다. 여러 사용자가 동시에 결제를 시도할 때, 예상하지 못한 데이터 불일치와 정합성 문제가 발생했으며, 이를 해결하기 위해 다
woojjam.tistory.com
Introduce
회사에서 진행한 프로젝트에서 결제 시스템을 구축하면서 동시성 문제를 마주한 적이 있다. 여러 레퍼런스를 참고하며 해결책을 찾으려 했지만, 대부분의 자료는 동시성이란 무엇인지, 어떤 해결 방법이 존재하는지에 대해서 설명하는 데 집중하고 있었다.
단순히 동시성 문제를 해결하는 것이 아니라, 문제를 점진적으로 해결해 나가는 과정을 다룬 레퍼런스는 많지 않았다. 그점이 상당히 아쉽다고 생각했고, 나와 같은 고민을 했던 사람들에게 도움이 되길 바라며 본 글을 작성하고자 한다.
해당 시리즈에서는 격리 수준부터 시작해 Synchronized, 낙관적 락, 비관적 락, 그리고 분산 락까지 동시성 문제를 점진적으로 해결해 나가는 과정을 다룰 것이다.
1. 격리 수준으로 동시성 제어하기
2. Synchronized로 동시성 제어하기
3. 낙관적 락으로 동시성 제어하기
4. 비관적 락으로 동시성 제어하기
5. 분산락으로 동시성 제어하기
예제 코드
해당 코드는 Coupon과 User 객체를 조회한 뒤, 해당 쿠폰의 재고가 남아있다면 쿠폰을 사용하고, 재고가 없다면 예외를 발생시킨다. 그리고 쿠폰을 사용 했다면 User가 Coupon을 사용한 이력을 History에 저장하는 비즈니스 로직이다.
동시성 테스트를 위해 100명의 유저가 A 쿠폰 20장을 동시에 사용하도록 테스트 코드를 작성보자.
테스트 코드는 해당 저장소를 참고하였다. 100명의 유저가 A 쿠폰 20장을 두고 동시에 경쟁을 벌이는 상황을 테스트 해보자.
쿠폰이 정확히 20장이 사용되면 좋겠지만..
다음과 같이 쿠폰이 100장 사용되었다.
만약 이게 콘서트 티켓팅이라고 생각했을 때 수용좌석은 3,000석이나 실제 예약한 사람은 30,000명이 되는 상황이 벌어졌고, 담당자가 나라고 생각해보자. 생각만 해도 끔찍하지 않은가? 물론 해결 책은 있다.
코드 때문에 자살할 수는 없으니깐.. 동시성 문제를 사전에 방지하고, 문제를 해결해보자.
격리 수준(Serializable)으로 동시성 제어하기
트랜잭션의 ACID 특성 중 Isolation은 동시에 여러 트랜잭션이 실행될 때 서로 간섭하지 않도록 보장하는 특성이다.
특히, 가장 높은 격리 수준인 Serializable 은 트랜잭션을 직렬화하여 실행함으로써 데이터 정합성을 유지한다
따라서 격리 수준을 Serializable 로 설정하면 정확히 20장의 쿠폰만 사용이 될 것 이다.
스프링에서는 @Transactional을 제공하고, 옵션으로 SERIALIZABLE을 설정할 수 있다.
격리 수준을 변경 한 다음 테스트를 실행해보자.
예상과는 다르게 테스트를 돌려보면 20장의 쿠폰이 아닌 13장의 쿠폰이 사용된다. 격리 수준을 SERIALIZABLE 로 설정했음에도 불구하고, 예상했던 결과와는 달랐다.
그렇다면 왜 이런 결과가 나왔을까? MySQL 문서를 참고하면 힌트를 얻을 수 있다.
문서에 따르면 트랜잭션의 격리 수준이 SERIALIZABLE 일때, autocommit이 disable일 경우 기본적인 select 문 실행시에도 S-Lock을 설정한다고 되어있다. 따라서 락의 사용이 많고, 이는 데드락의 발생 가능성이 높다.
InnoDB의 상태를 모니터링하기 위해 SHOW ENGINE INNODB STATUS 를 실행시켜 보았다.
이러한 로그를 확인할 수 있는데 29843381 트랜잭션이 S-Lock을 획득하고, coupon을 update할때 X-Lock을 요청하였으나 대기하고 있다. 그리고 100844 트랜잭션이 S-Lock을 획득하고, 똑같이 coupon을 update할때 X-Lock을 요청하였으나 데드락이 발생하여 Roll Back 된것을 확인할 수 있다.
📌 그렇다면 트랜잭션의 실행 순서가 어떻게 될까?
1번 트랜잭션이 Coupon, User를 조회한다.
-> S-Lock을 획득한다.
2번 트랜잭션이 Coupon, User를 조회한다.
-> S-Lock은 공유할 수 있기에 S-Lock을 획득한다.
1번 트랜잭션이 Coupon을 사용한다. (Update)
-> update 하기 위해 x-lock을 획득해야 한다.
-> 2번 트랜잭션이 S-Lock을 가지고 있어서 2번 트랜잭션이 S-Lock을 반납할때 까지 대기한다.
2번 트랜잭션이 Coupon을 사용한다.
-> update 하기 위해 x-lock을 획득해야 한다.
-> 1번 트랜잭션이 S-Lock을 가지고 있어서 1번 트랜잭션이 S-Lock을 반납할때 까지 대기한다.
이렇게 1번, 2번 트랜잭션이 서로를 기다리며 영원히 대기하는 데드락이 발생하게 된다. MySQL의 데드락 감지 알고리즘에 의해 특정 트랜잭션이 강제로 롤백되게 되고, 이로 인해 일부 유저의 쿠폰 사용 요청이 실패하면서 정확히 20개의 쿠폰이 정상적으로 사용되지 않았다.
그렇다면 격리 수준만으로는 동시성 제어를 할 수 없다는 것을 알아보았다. 다른 방식을 사용해야한다.