낙관적 락을 통한 티케팅 시스템의 동시성 문제 해결 2 (Feat. DB Connection Pool)
서론
성공적으로 낙관적락을 통해 문제를 해결한 줄 알았으나
직접 MySQL을 통해 연동했을 때 결과적으로는 정상적으로 동시성 제어가 작동하지 않았습니다.
아래는 K6를 통해서 10명의 유저가 3개의 티켓에 대해서 동시다발적으로 구매요청을 진행했을 때의 결과 요약입니다.
한명의 유저를 제외하면 모두 실패하였습니다.
의문을 갖고 이번에는 50명의 유저가 3개의 티켓에 대해서 경쟁하는 테스트를 시도해보았습니다.
경합의 강도가 올라갔음에도 불구하고, 50명의 유저에 대해서 테스트를 했을 때는 성공하는것을 볼 수 있었습니다.
이와 같은 결과를 보고 몇가지 의문이 들었습니다.
- 테스트 코드에서는 정상적으로 작동하던 낙관적 락을 통한 동시성 제어가 하나를 제외하면 모두 실패하는 이유는 왜일까요?
- 어째서 더 많은 인원이 동시적으로 요청했는데 오히려 성공하는것 일까요?
본론
Issue #1 - Marked as Rollback-Only
서버 로그를 살펴보면서 몇가지 단서를 얻을 수 있었습니다.
아래 로그 일부분은 10명의 유저와 3개의 티켓 경합 결과 로그의 일부분입니다.
“JDBC transaction marked for rollback-only (exception provided for stack trace)”
트랜잭션이 롤백되고 있다는 로그가 찍혀있었습니다.
티켓 구매 트랜잭션 코드를 살펴보면 다음과 같습니다.
트랜잭션 자체가 롤백되는 상황은 모든 Retry를 소모하고, OptimisticLockingFailureException이 Callee 메소드인
createPurchase 로 전파되는 상황만 가능하다고 예상했지만, 로그에 나타나는 결과는 그렇지 않았습니다.
해당 로그에 따르면
첫번째 retry 시도에 즉시 트랜잭션은 Rollback-only로 지정되고 뒤에 몇 번의 retry 가 수행되더라도
(심지어 성공하더라도) 결과는 Rollback 됩니다.
Reason : Try-Catch inside Transaction
이유는 응? 이게 왜 롤백되는거지? 에서 찾을 수 있었습니다.
TL;DR
try-catch에서 예외처리를 하더라도 RuntimeException이 트랜잭션 안에서 발생한다면 해당 트랜잭션은 Rollback-only로 지정됩니다.
@Retry 애노테이션을 사용하더라도 근본적으로는 Try-Catch 문을 활용하기 때문에, RuntimeException을
상속받은 OptimisticLockingException과 ConcurrencyFailureException은 결국 트랜잭션을 오염시킵니다.
Issue #2 - Retry Counts
두번째로 식별한 문제는 Retry 횟수의 소모입니다.
재시도 횟수를 관찰한 결과, 모든 쓰레드가 배정된 재시도 횟수를 전부 소모하는 결과를 확인하였습니다.
어떠한 값을 재시도 횟수로 변경하더라도, 배정된 기회를 모두 소모하고 테스트 소모 시간은 재시도 횟수와
선형적으로 비례하였습니다.
Reason : Trasaction Isolation Level
테스트 코드에서 성공했지만, 실제 환경에서 실패하는 이유는 바로 트랜잭션 격리수준에 있습니다.
MySQL의 경우, 기본 트랜잭션 격리 수준은 REPEATABLE_READ 입니다.
H2의 경우, 기본 트랜잭션 격리 수준은 READ_COMMITED 입니다.
REPEATABLE_READ에서는 자신보다 먼저 실행된 트랜잭션의 데이터만을 언두 로그를 통해서 조회하기 때문에,
재시도가 이루어지더라도 다른 트랜잭션이 커밋한 데이터를 읽어올 수 없습니다.
아무리 재시도 기회를 많이 배정하더라도, 한번 경합에서 실패한 트랜잭션은 계속해서 이전 버전의 데이터를
조회하게 되고, 계속해서 실패하게 됩니다.
만약 maxAttempts가 없다면 극단적으로는 무한루프에 빠지는 결과가 나올 수 있습니다.
Solution to Issue #1 #2
결국 문제 #1과 #2를 공통적으로 해결하기 위해서는
재시도마다 새로운 트랜잭션으로 시작하거나,
구매 요청에 대해서 새로운 트랜잭션을 생성하는 방법이 있습니다.
가장 심플한 방법으로는 다음과 같이 @Retry 애노테이션의 위치를 상위 Callee 로 이동시키면 해결이 가능합니다.
저희가 최종적으로 이 문제를 해결하면서 다른 락들간의 공통된 관심사는 유지하고
변경점은 구분한 방식은 다음 포스팅을 참조하시면 되겠습니다.
Issue #3 - 경합도마다 결과가 다르다?
사실 #1 과 #2를 해결한 시점에서 더이상 문제는 없지만, 아직 해결하지 못한 미스테리가 있습니다.
바로 어떠한 해결책을 적용하지 않더라도, 요청하는 유저의 수만 늘린다면 정확하게 구매가 처리되는 현상입니다.
(가장 난해했고, 골머리를 앓게 만든 문제입니다)
이 문제의 원인을 파악하기 위해서 유저의 수가 몇인 지점부터 성공하는지 확인하기 위해서 수치를 조절하고,
여러번 테스트를 진행해보았습니다.
이상하게도 10부터 시작하여 11, 12로 1씩 증가함에 따라서 성공하는 유저의 인원수는 함께 증가하였습니다.
납득하기 어려운 결과에, 로그를 다시 한번 파보았습니다.
(편의상 재구성한 로그입니다)
직관적으로는 한명을 제외하면 모두 실패해야 하지만, 그렇지 않은것을 볼 수 있습니다.
하지만 로그를 관찰해보면서 특이한점을 발견할 수 있었습니다.
바로 11번 트랜잭션(exec-11)은 1~10번 트랜잭션이 종료될때까지 시작하지 않았다는 점입니다.
(이미지에서는 편의를 위해 순차적으로 구성했지만,
(실제 로그는 숫자가 뒤죽박죽이어서 식별하기 정말 어려웠습니다 😅..)
트랜잭션이 한번에 10개 단위로 실행될만한 이유에 대해 생각해보았고,
데이터베이스의 커넥션 풀이 떠올랐습니다.
조사 결과, 역시 사용하고 있던 JDBC 커넥션 풀인 HikariCP의 기본 풀 사이즈는 10이었습니다.
스프링 설정파일인 application.yml에서 hikariPool의 커넥션 풀 설정을 변경하고 다시 시도해보았습니다.
예상대로 한명을 제외하고 모두 실패하는 결과를 확인할 수 있었습니다 !
커넥션 풀이 10으로 설정되었을 때 성공하는 시나리오는 아래와 같습니다.
결론
이번 포스팅에서는 세가지 문제에 대한 원인과 해결을 다루어보았습니다.
핵심 주제를 뽑아보면 다음과 같습니다 :
- Try-Catch와 Rollback-Only
- 트랜잭션 격리수준 차이
- 데이터베이스 커넥션 풀
이유를 알고보면 쉽지만, 하나하나 모두 마주했을 당시에는 원인을 파악하기 까다로운 문제들이었습니다.
개인적으로 이번 트러블슈팅을 통해 얻은 교훈은 다음과 같습니다
- 데이터베이스 및 JPA 동작원리에 대한 정확한 개념 숙지의 필요성
- 적극적인 로그 디버깅
- (를 하기 위해선 자세한 로그 표시가 필요하겠네요)
- 테스트코드 맹신 금지
댓글남기기