🔎 본 글을 읽기 앞서 해당 게시글을 먼저 참고 해주시길 바랍니다.
해당 게시글은 추후 내용이 변경될 수 있습니다.
해당 게시글에서 설명하지 않는 사전 지식들은 이미 알고있다고 가정하고 작성된 글입니다.
[DB] 트랜잭션과 동시성 제어
Introduce결제 시스템을 구축하면서 동시성 문제를 경험한 적이 있다. 여러 사용자가 동시에 결제를 시도할 때, 예상하지 못한 데이터 불일치와 정합성 문제가 발생했으며, 이를 해결하기 위해 다
woojjam.tistory.com
| 일시 | 설명 |
| 25.03.08 | - 게시글 작성 |
| 25.03.09 | - Synchronized 분산 테스트 추가 |
| 25.03.16 | - Pesimistic Lock 내용 추가 |
| 25.03.28 | - Optimistic Lock 내용 추가 및 데드락 추가 |
Introduce
회사에서 진행한 프로젝트에서 결제 시스템을 구축하면서 동시성 문제를 마주한 적이 있다. 여러 레퍼런스를 참고하며 해결책을 찾으려 했지만, 대부분의 자료는 동시성이란 무엇인지, 어떤 해결 방법이 존재하는지에 대해서 설명하는 데 집중하고 있었다.
단순히 동시성 문제를 해결하는 것이 아니라, 문제를 점진적으로 해결해 나가는 과정을 다룬 레퍼런스는 많지 않았다. 그점이 상당히 아쉽다고 생각했고, 나와 같은 고민을 했던 사람들에게 도움이 되길 바라며 본 글을 작성하고자 한다.
해당 시리즈에서는 격리 수준부터 시작해 Synchronized, 낙관적 락, 비관적 락, 그리고 분산 락까지 동시성 문제를 점진적으로 해결해 나가는 과정을 다룰 것이다.
예제 코드

해당 코드는 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개의 쿠폰이 정상적으로 사용되지 않았다.
그렇다면 격리 수준만으로는 동시성 제어를 할 수 없다는 것을 알 아보았다. 다른 방식을 사용해야한다.
Synchronized으로 동시성 제어하기
Synchronized는 JVM 내부의 모니터 락을 기반으로 동작하는 동기화 메커니즘이다. Java에서 스레드간 동시 실행을 방지하는 키워드로 특정 블록이나 메서드에 synchronized를 걸면, 1개의 스레드만 접근할 수 있도록 Lock이 걸리게 된다.
즉, 쿠폰을 사용하는 메서드인 useCoupon() 에 synchronized를 걸면 특정 스레드가 점유하고 있을시 다른 스레드들은 접근할 수 없다는 말이다. 그렇다면 스레드끼리 동시에 실행되지 않는것이 보장됨으로 동시성을 제어할 수 있지 않을까 라는 생각이 든다.
하지만 결론부터 말하자면 synchronized로는 완벽하게 동시성을 제어하기 어렵다.
백문이 불여일견 바로 테스트를 돌려보자.

useCoupon()을 호출하는 상위 레이어인 Facade 클래스에서 synchronized를 적용하였다. 메소드에 synchronized를 적용해도 되지만 나는 Lombok에서 제공하는 @Synchnized 를 사용하였다. 둘 중에 아무거나 사용해도 큰 차이는 없다.
여기에는 사실 이유가 있긴한데..
synchronized는 트랜잭션이 적용된 서비스 코드가 아니라, 해당 서비스 코드를 호출하는 상위 레이어에서 적용해야 동기화가 정상적으로 동작하기 때문이다.
그 이유는 @Transactional이 Spring AOP 기반의 프록시 객체에서 실행되며, 클래스에 적용될 경우 CGLIB을 통해 런타임에 프록시 객체가 생성된다. 하지만 synchronized는 JVM이 제공하는 동기화 기능으로, 원본 객체에서 동작하기 때문에 같이 사용할 경우 트랜잭션이 적용된 프록시 객체와 동기화 대상이 달라져 예상대로 동작하지 않을 수 있다.
해당 내용을 더 제대로 팔려면 Spring 과 JVM에 대한 내용을 알아야 하기 때문에 본 글에서는 여기까지만 설명하겠다.
자 그럼 테스트를 실행시켜보자.

동일한 테스트 케이스인 100명의 유저가 동시에 20개의 쿠폰 발급이 성공했다.

History 테이블의 데이터를 확인해도 정확히 20개의 데이터가 저장되었다. 즉, 동시성으로 인해 충돌이 발생하지 않고, 100명의 유저가 동시에 요청해도 정확히 20개의 쿠폰이 사용되었다는 말이다.
분명 처음에는 synchronzied로는 동시성을 제어하기 어렵다고 했는데, 너무나도 쉽게 테스트가 성공했다.
그 이유는 현재 테스트 환경은 단일 인스턴스이기 때문이다.

위에서도 말했듯이 synchronized는 JVM 내에서 모니터 락을 통해 동기화한다.
하나의 JVM에서 실행되는 여러 스레드 간에는 동시성을 제어할지라도 서버가 여러대인 분산 환경이라면 각 서버의 JVM이 서로 다른 메모리를 사용하기 때문에 각 서버는 개별적으로 동기화를 진행한다.
즉, 서버 1의 JVM은 서버 2에 대한 스레드를 제어할 수가 없다. 그러므로 애플리케이션 레벨에서의 동시성 제어만으로는 서버가 여러대인 경우 동시성을 제어할 수 없는 것이다.
그럼 정말로 서버가 여러대일때 동시성을 제어할 수 없는지 테스트 해보자.
📌 테스트 환경
테스트하고자 하는 시나리오는 분산 서버로 100명의 유저가 동시에 100개의 요청을 보내는거다.

간단하게 분산 서버에서의 동시성 테스트를 위해 Docker와 Nginx를 이용하여 로드 밸런싱과 다중 서버 환경을 구축해보았다.
8080으로 요청을 보내게 되면 Nginx를 통해 8081, 8082로 부하 분산된다.
그리고 부하 테스트를 위해 Apache Benchmark를 사용할 예정이다.
ab -n 100 -c 100 -p request.json -T application/json http://localhost:8080/api/use-coupon
-n 100 → 총 100개 요청
-c 100 → 100개의 요청을 동시에 보냄
-p request.json → POST 요청의 바디
-T application/json → Content-Type 설정
📌 테스트 결과

테스트 결과 20개의 쿠폰이 모두 발급이 된것을 확인할 수 있다. 그렇다면 실제로 쿠폰을 사용한 유저도 20명일까?

실제로 쿠폰을 사용한 유저의 수는 32명이다. 즉, 기존의 쿠폰의 수량인 20개보다 더 많은 수량이 발급이 되었다는 의미이다.
즉, synchronized는 분산 환경에서 동시성을 제어할 수 없음을 의미한다.
실무에서는 거의 대부분 2대 이상의 서버를 사용할 것이다. 그러므로 synchronized는 동시성 제어를 위해서는 사용하지 않는것이 적절할 것이다.
낙관적 락으로 동시성 제어하기
지금까지 격리 수준과 synchronized 로는 멀티 인스턴스 환경에서 동시성을 제어할 수 없다는 것을 알아보았다.
그렇다면 다른 방법을 모색해야 한다. 따라서 어플리케이션 또는 DB 차원에서 명시적인 동시성 제어 기법이 필요하다. 이 중 하나가 바로 낙관적 락(Optimistic Lock)이다.
낙관적 락이란 대부분의 경우 충돌이 발생하지 않는다는 전제하에 락을 걸지 않고, 작업을 일단 진행한 후, 최종적으로 충돌이 있었는지를 검사하여 처리하는 동시성 제어 방식이다. 즉, DB의 락을 사용하지 않고, 자유롭게 읽고 처리한 뒤, 커밋 시점에만 Version 관리를 통해 문제가 없다면 commit, 있다면 재시도하거나 Rollback 처리하는 방식이다.
📌 동작 방식

1. 데이터를 읽을 때 Version 필드를 같이 조회한다.
2. 데이터를 수정하고 업데이트한다.
3. 이때 만약 다른 트랜잭션 먼저 수정하여 Version이 바뀌었다면 해당 트랜잭션은 실패로 간주하고, 롤백된다.
4. Version이 현재 데이터베이스의 Version과 일치한다면 Version을 증가시키고, 트랜잭션은 commit 된다.
JPA에서는 @OptimisticLocking 또는 필드에 @Version 어노테이션을 추가하여 낙관적락을 손 쉽게 적용할 수 있다.

@Version을 사용하면 수정이 될 때 자동으로 버전을 증가시키며, 조회 시점과 버전이 다른 경우 OptimisticLockException 예외를 발생시킨다. 그러므로 버전이 일치하지 않아 실패할 경우를 대비하여 재시도 로직을 작성해야 한다.
JPA가 제공하는 Lock 옵션에는 여러가지가 있다.
1. NONE
기본적으로 락을 걸지 않는 방식이지만 Entity에 @Version이 적용된 필드가 있다면 낙관적 락이 암시적으로 적용된다. 만약 엔티티를 수정할 경우 버전을 체크하면서 버전을 증가한다. 만약 버전이 현재 버전이 아니라면 예외가 발생한다.
두 번의 갱신 손실 문제를 방지할 수 있다.
2. OPTIMISTIC
엔티티를 조회만 해도 버전을 체크한다. 즉, 트랜잭션을 종료할 때까지 데이터 정합성을 보장한다는 의미이다.
명시적으로 낙관적 락을 적용하는 방식으로 조회시에도 낙관적 락을 적용하여 Non-Repeatable을 방지한다.
3. OPTIMISTIC_FORCE_INCREMENT
낙관적 잠금을 사용하면서 버전 정보를 강제로 증가시키는 옵션이다. 해당 옵션은 엔티티는 수정하지 않지만 버전은 증가시켜야 하는 경우에 사용하는 옵션으로 일반적인 상황에서는 잘 쓰이지 않는다.
📌 테스트
그렇다면 100명의 유저가 총 20장인 1번 쿠폰을 동시에 사용하고자 할때 과연 동시성이 보장이 될까?

테스트 코드는 이전 방식과 똑같이 진행할 것이고, 해당 테스트를 진행해보자.

다음과 같이 Thread-6 에서 ObjectOptimisticLockingFailureException 이 발생하였다. 이는 이전에 언급하였듯이 Version이 일치하지 않아 발생한 예외이다.
따라서 낙관적 락을 적용할경우 재시도 로직이나 Version 충돌에 대한 대응 방법이 있어야한다.

나는 단순히 쿠폰의 재고가 정확히 소진되었는지만 검증하고 싶기 때문에 단순하게 반복문으로 뚜깍뚜깍 작성해보았다.
(실제 프로젝트에서 적용할 경우에는 프로젝트만의 정책에 따르길..)

단일 인스턴스 환경에서는 성공적으로 쿠폰 발급이 완료되었다. 그렇다면 분산 서버 환경에서도 과연 동시성을 제어할 수 있을지 테스트를 또 진행해보자.
부하 발생 명령어는 이전과 똑같이 진행하였다.

발급된 쿠폰의 수가 정확히 20장 발급된 것을 확인할 수 있다. 이말은 즉, 낙관적 락은 분산 서버 환경에서도 동시성을 보장할 수 있음을 의미한다.
📌 데드락 발생
그렇다면 이제 낙관적 락으로는 동시성 문제를 해결할 수 있음을 알아보았다. 그렇다면 실제 프로젝트에 적용하기 이전에 도메인 로직을 살짝 바꿔보겠다.

이전에는 사실 TestCoupon과 TestHistory가 연관 관계를 가지고 있지 않았다. 단순 pk값을 필드로 가지고 있었다.
하지만 실제 결제 시스템에서 데이터 정합성을 보장하기 위해서 연관 관계를 매핑하였다.

그리고 코드를 다음과 같이 수정하고, 테스트를 실행시켜보자.

예상하지 못한 데드락이 발생했다.
어라? 데드락은 이전에 격리수준을 Serializable로 설정했을때 발생했었는데 왜 또 발생하는 걸까?

데드락 발생 로그를 보면 특정 트랜잭션이 S-Lock을 획득 하고 있는데, 다른 트랜잭션은 X-Lock을 요청하기 때문에 서로를 무한히 대기하면서 발생하였다.
하지만 낙관적 락은 DB의 락을 사용하지 않는것 아닌가?
그렇다. 나는 낙관적 락을 사용했을 뿐 DB Lock은 사용하지 않았다. 그렇다면 왜 사용이 됐을까?
MySQL 문서를 찾아보면 관련 내용을 찾을 수 있다.

요약하면 외래키가 있는 테이블에서, 외래키를 포함한 데이터를 insert, update , delete 하는 쿼리는 제약조건을 확인하기 위해 S-Lock을 설정한다고 한다.
즉, TestHistory 테이블에 새로운 데이터를 insert 하는데 이때 TestCoupon의 id를 외래키로 가지고 있기 때문에 TestCoupon에 S-Lock이 걸리게 된것이다.
그리고 X-Lock이 사용된 이유도 관련 내용을 요약해보자면

Update 쿼리에 사용되는 모든 레코드에는 Exclusive lock이 설정된다고 한다. 그러므로 coupon.use() 를 실행하면서 update 쿼리가 발생하고 x-lock이 걸리게 된 것이다.
그래서 최종적으로 데드락 발생 원인을 정리해보면
1. 트랜잭션 A가 TestHistory 데이터를 insert
- 외래키가 걸려있는 TestCoupon에 S-Lock이 전파
2. 트랜잭션 B가 TestHistory 데이터를 insert
- 외래키가 걸려있는 TestCoupon에 S-Lock이 전파
- S-Lock은 호환이 가능하므로 서로 Lock을 걸 수 있음
3. 트랜잭션 A가 Coupon을 사용
- update 쿼리가 발생하고, TestCoupon 레코드에 X-Lock 을 걸려고 시도
- 트랜잭션 B가 TestCoupon에 S-Lock을 걸어놨기에, 대기
4. 트랜잭션 B가 Coupon을 사용
- update 쿼리가 발생하고, TestCoupon 레코드에 X-Lock 을 걸려고 시도
- 트랜잭션 A가 TestCoupon에 S-Lock을 걸어놨기에, 대기
5. 데드락 발생
- 서로 다른 트랜잭션이 같은 자원에 대해 Lock을 가지고 있어, Lock을 해제할때까지 서로 무한으로 대기하면서 데드락 발생
📌 S Lock -> X Lock ?
그런데 코드를 보았을 때 의문점이 들 수 있다.

코드를 다시 살펴보면 coupon.use() 를 먼저 실행하고, TestHistory 를 저장한다. 이는 즉, X-Lock을 먼저 획득하고, S-Lock을 획득한다는 의미이다.
그렇다면 의문이 든다.
1. 왜 로그에는 S-Lock -> X-Lock 순서로 락을 획득할까
2. X-Lock을 먼저 획득한다면 다른 트랜잭션은 어차피 락을 획득하지 못하고, 대기하므로 S-Lock이 전파되더라도 데드락이 발생하지 않는것이 아닌가?
둘다 맞는말이다.
해당 의문점에 대한 대답은 MySQL의 Lock 방식에 있는것이 아닌 JPA의 쓰기 지연 전략 때문에 실제 락 획득 시점이 내가 작성한 코드 순서와 다르기 때문이다.
JPA 쓰기 지연이란 영속성 컨텍스트에 변경이 발생했을 때, 바로 데이터베이스로 쿼리를 보내지 않고 SQL 쿼리를 버퍼에 모아놨다가, 영속성 컨텍스트가 flush 하는 시점에 모아둔 SQL 쿼리를 데이터베이스로 보내는 기능
비관적 락으로 동시성 제어하기
비관적 락은 데이터를 조회하거나 수정할 때 다른 사용자가 해당 데이터에 접근하지 못하도록 락을 설정한다. 이로 인해 데이터를 수정하는 동안에는 다른 트랜잭션이 해당 데이터에 접근할 수 없어 동시성을 제어할 수 있다.
비관적 락을 사용하면 SQL 쿼리에 select ... for update 구문이 사용되는데, 이 때 기본적으로 next-key lock을 사용한다. 이와 관련된 자세한 내용은 MySQL 문서를 참고하자.

사실 동시성이 발생하는 이유가 TestCoupon을 조회한 뒤 재고를 감소시키는 동안, 다른 트랜잭션이 해당 데이터를 동시에 조회 및 수정하게 되면서 동시성 충돌이 발생한것이다.
그렇다면 만약 이를 방지하기 위해 읽는 시점에 락을 걸어, 다른 트랜잭션이 해당 데이터를 조회 및 수정하지 못하도록 한다면..?
동시성이 해결되지 않을까?
📌 테스트
그렇다면 JPA의 비관적 락을 사용하여 동시성을 제어할 수 있는지 테스트 해보자. JPA에서 락을 사용하기 위해서는 Lock 어노테이션을 사용한다.

비관적 락(Pessimistic Lock)은 일반적으로 PESSIMISTIC_WRITE 옵션을 사용하며, 이는 해당 레코드에 대해 쓰기 가능성을 고려해 읽기 시점에 데이터베이스 락을 선점하는 방식이다.
이전과 똑같이 100명의 유저가 A 쿠폰 20장을 동시에 사용하는 상황일때 테스트를 진행해보면

성공적으로 쿠폰이 발급되었고, select ... for update 쿼리문이 실행된 것도 확인할 수 있다

발급된 쿠폰의 수도 정확히 20장이다!
일단 단일 인스턴스 환경에서는 동시성이 성공적으로 제어된다는 것을 알 수 있다. 그렇다면 과연 분산 서버에서도 동시성을 제어할 수 있을지 확인해보자.
📌 분산 환경 테스트
테스트할 인프라 환경은 이전과 똑같다.
ab -n 100 -c 100 -p request.json -T application/json http://localhost:8080/api/coupon/use-multi-server
다음 명령어를 통해 100명의 유저가 동시에 100개의 요청을 보내도록 해보자.

A 쿠폰은 일단 정상적으로 모두 발급이 되었다. 그렇다면 실제로 발급된 쿠폰의 수도 20개일까?

비관적 락을 적용한 결과 분산 환경에서도 실제로 발급된 쿠폰의 수가 20개였다. 즉, 비관적 락은 분산 환경에서도 동시성을 제어할 수 있다는 의미이다.
📌 데드락이 발생하지 않는가?
무엇보다 낙관적 락을 적용하였을때는 외래키가 전파되면서 데드락이 발생하였으나, 비관적 락은 조회시점에 락을 먼저 점유하면서 데드락이 발생하지 않은것을 확인할 수 있다.
그렇다면 비관적 락은 데드락이 발생하지 않을까??
처음에는 단순하게 조회 시점에 락을 거니깐 데드락이 발생하지 않겠다 라고 생각을 했다. 하지만 상황에 따라 데드락 문제가 발생할 수 있다는 것을 인지해야한다. 데드락이 발생할 수 있는 시나리오를 알아보자.
1. 트랜잭션 A가 X 테이블의 1번 데이터에 Lock을 건다.
- X에 대해서 어떠한 Lock도 없으므로 X-Lock을 획득
2. 트랜잭션 B가 Y 테이블의 1번 데이터에 Lock을 건다.
- Y에 대해서 어떠한 Lock도 없으므로 X-Lock을 획득
3. 트랜잭션 A가 Y 테이블의 1번 데이터에 접근한다.
- 하지만 트랜잭션 B가 이미 Y에 대해 Lock을 걸어놨기에 대기
4. 트랜잭션 B가 X 테이블의 1번 데이터에 접근한다.
- 하지만 트랜잭션 A가 이미 X에 대해 Lock을 걸어놨기에 대기
해당 시나리오에서는 트랜잭션 A와 B가 각자의 자원을 점유하고, 상대방이 가진 자원을 얻기 위해 무한히 대기하게 되는 데드락이 발생한다.
단순하게 시나리오를 설명만 해서는 되겠는가? 바로 검증해보자.

txA와 txB 메소드를 작성하였다
txA 는 Coupon을 먼저 조회하여 Lock을 적용한 뒤, User에 Lock을 적용하는 메소드이다. 그리고 txB는 User를 먼저 조회하여 Lock을 적용한 뒤, Coupon에 Lock을 적용하는 메소드이다.
앞서 언급한 시나리오처럼 각각의 트랜잭션이 락을 거는 순서가 다르게 하여 테스트를 진행해보자.

2개의 요청이 동시에 실행될때 다음과 같이 데드락이 발생한 것을 볼 수 있다.
그러므로 비관적 락을 사용할 경우에는 반드시 모든 트랜잭션에서 동일한 순서로 락을 걸도록 강제하던가, 타임아웃을 설정하여 특정 트랜잭션이 락을 오랫동안 점유하지 못하도록 해야한다.
그래서 어떤 방식을 선택했나요?
지금까지 동시성 문제를 점진적으로 해결해 나가는 과정을 다루었다. 그래서 어떠한 방법을 선택해야할까?
먼저 선택에 대한 정답은 없고, 타당한 이유가 있어야 한다고 생각한다. 그러니 내가 선택한게 절대 정답은 아니다.
일단 격리 수준과 Synchronzied로는 동시성을 제어할 수 없으므로 생략하였고, 낙관적 락과 비관적 락 중 어떤걸 적용해야할까 고민이 많았다.
외래키 제약 조건으로 인한 락 전파와 JPA 쓰기 지연에 의해서 낙관적 락은 데드락이 발생하였다. 강제로 flush 를 호출하거나 외래키를 걸지 않으면 어찌저찌 해결이 되지 않을까 싶긴하지만 이 부분은 또 다른 트러블 슈팅의 내용이라 너무 딥해지기도 했고, (추후 고민해볼만한 주제가 생기긴 했다.) 데이터의 정합성을 확실하게 보장할 수 있다를 장담할 수 없었다.
또한 비관적 락은 조회시에 락을 반드시 점유해야 하므로 성능적인 이슈가 많다고 한다.
그래서 직접 시간을 측정 해보았다.


300명의 유저가 20개의 쿠폰을 두고 경쟁을 벌일 때 시간 차이다.
비관적락이 모든 트랜잭션에 대해 락을 사용하므로 성능이 저하된다는 문제가 있지만 실제로 성능 차이는 큰 의의를 둘 정도는 아니라고 생각될 정도였다. 그 이유는 낙관적 락은 충돌이 발생하지 않을것이라고 가정하고, 락을 점유하지 않는 방식이므로 충돌이 많이 발생하는 환경에서는 어찌됐든 그 만큼의 재시도 로직이 동작해야하고, 따라서 그 만큼의 시간이 걸리게 된다.
그리고 내가 동시성을 적용할려는 곳도 쿠폰 발급처럼 동시 경쟁이 잦은 환경이므로 비관적 락을 적용하였다.
하지만 비관적 락에도 한계는 분명히 있다.
1. 앞서 언급한 성능 문제
2. 서비스의 규모가 커질 수록 여러 테이블에 락이 걸려 결국 데드락 발생
3. 분산 DB
위 3가지 한계점이 존재한다.
따라서 다음에는 해당 한계점들을 극복하면서 동시성 문제를 해결할 방법을 연구해 볼 것이다.
'Backend > Spring Boot' 카테고리의 다른 글
| 자바와 스프링의 비동기 처리 - 2편: CompletableFuture의 예외 처리와 타임 아웃 (4) | 2025.08.01 |
|---|---|
| 자바와 스프링의 비동기 처리 - 1편: CompletableFuture 톺아보기 (3) | 2025.07.11 |
| 스프링 이벤트를 발행하여 트랜잭션과 관심사 분리하기 (2) | 2025.04.29 |