Coupon을 발행하는 파일럿 프로젝트를 진행하며 겪은 동시성 문제를 해결하는 글입니다.
우선 Coupon을 발행하는 코드입니다.
플로우를 간단하게 살펴보면 사용자가 Coupon을 받을 수 있는지 없는지 체크 한 후 Coupon을 받을 수 있다면 Coupon 발부에 성공하게 됩니다.
동시에 여러명이 Coupon을 발급하였을 때, 정확한 Coupon 개수만큼 발급하는지에 대한 테스트 코드를 작성해보겠습니다.
동시에 Coupon을 발행하게 되면 테스트는 실패한다.
왜 실패할까? 로그를 살펴보자
위 로그를 보면 Lock을 얻는 과정에서 Deadlock이 생긴다고 한다.
Lock은 언제 얻을까?
답은 Mysql 연관관계에 있다.
외래 키 계약조건이 있는 테이블에 삽입, 삭제, 갱신을 할 때 제약조건을 위반하는지 확인하기 위해 관련된 레코드에 S Lock(공유 잠금)을 설정한다.
공유 잠금이란 다른 트랜잭션의 데이터 변경을 막고 데이터 일관성을 유지하는 잠금 유형이다. 여러 트랜잭션이 동시에 공유 잠금을 얻을 수 있다. 그러나 공유 잠금을 설정한 트랜잭션이 있을 때, 다른 트랜잭션은 해당 데이터에 대해 배타적 잠금(X lock)을 얻지 못한다.
배타적 잠금이란 하나의 트랜잭션만 쓰기 작업을 할 수 있게 해주는 잠금 유형이다. 배타적 잠금을 얻지 못한 트랜잭션은 잠금을 얻은 트랜잭
션이 종료하기 전까지 대기해야 한다.
하나의 트랜잭션에서 S Lock을 가지고 있어도 다른 트랜잭션에서 S Lock을 얻을 수 있을까?
Mysql 공식 문서에서 S Lock을 갖고 있는 트랜잭션이 있더라도 다른 트랜잭션이 S Lock을 얻을 수 있다.
하지만 X Lock은 즉시 얻지 못한다. S Lock이 끝날 때까지 대기해야 한다.
여기서 데드락이 발생하는 것이다. A 트랜잭션이 S Lock을 얻고, B 트랜잭션이 S Lock을 얻은 후, A 트랜잭션이 X Lock을 얻기 위해 B 트랜잭션 S Lock이 끝날 때까지 대기, B 트랜잭션 또한 X Lock을 얻기 위해 A 트랜잭션이 끝날때까지 대기하여 데드락이 발생한다.
그렇다면 데드락이 발생하는 연관관계를 끊으면 테스트는 성공할까?
10개의쿠폰만 발행해야 하는데 모든 멤버가 쿠폰을 발행받는다.
만약 순차적으로 발행하면 테스트는 통과한다.
왜 그럴까?
순차적으로 실행할 땐 Read -Write가 순차적으로 진행되지만, 동시에 실행할 땐 스레드 A가 Read, 스레드 B가 Read를 하고 A가 Write, B가 Write 하여 정합성이 깨져 위와 같은 결과가 나오게 됩니다.
그럼 이를 해결하기 위해 Synchronized, 낙관적 락과 비관적락을 통해 해결해 보겠습니다.
Synchronized
멀티스레드 환경에서 단일 스레드만 함수를 실행할 수 있게 해주는 synchronized를 사용해서 테스트를 돌려보자.
10개의 Coupon만 발행해야 하는데 20개의 Coupon을 발행하고 있다.
이는 @Transactional 때문에 발생하는 일이다.
트랜잭션이 실행되기 전 후 코드가 보이지 않지만 AOP를 통해 begin, commit이 실행되고 있다.
Transaction Begin -> Target(Coupon 발행) -> Transaction Commit
따라서 스레드 A가 Coupon을 발행하고 DB에 반영되기 전에 스레드 B가 Target에 접근할 수 있다는 뜻이다.
Thread A : Transaction Begin -> Target -> Transaction Commit
Thread B : Transaction Begin -> Target -> Transaction Commit
그렇다면 @Transactional을 실행하는 함수를 Synchronized안에 감싸주면 어떻게 될까?
이렇게 synchronized로 함수를 감싸주게 되면 테스트를 통과하게 된다.
하지만 synchronized는 몇 가지 단점이 존재한다.
한 스레드가 메서드 작업이 완료할 때까지 다른 스레드들은 대기해야 한다. 이로 인해 프로그램의 성능이 저하될 수 있다.
또한 서버가 여러 대일 경우 데이터의 정합성을 보장할 수 없다.
Lock
Lock은 낙관적 Lock , 비관적 Lock이 존재한다.
낙관적 Lock
낙관적 Lock은 여러 트랜잭션 간 충돌이 일어나지 않을 것이라고 가정
비관적 Lock은 여러 트랜잭션간 충돌이 일어날 것이라고 가정한다.
조금 더 자세히 설명하면 낙관적 락의 경우 내가 먼저 해당 값을 수정했다고 명시하여 다른 사람이 동일 조건으로 값을 수정할 수 없게 하는 방법으로 DB에서 제공하는 락이 아닌 애플리케이션 단에서 제공하는 방법이다.
JPA를 사용하는 경우, @Version 어노테이션을 붙인 필드를 하나 추가하여 트랜잭션 시작 시에 조회한 버전과 커밋 시에 버전이 동일한지를 체크하여 중간에 누군가 먼저 수정하지 않았는지를 확인하는 방법이다.
따라서 낙관적 락을 사용하는 경우 충돌이 발생했을 떄에 대한 처리를 개발자가 직접 해주어야 한다.
스프링에서는 AOP를 통해서 제공하는 @Retryable 어노테이션을 이용하여 예외가 발생했을 때 해당 메서드를 재시도할 수 있는 기능을 제공해준다. 하지만 낙관적 락을 사용하지 않은 이유는 우선 가장 큰 단점인 롤백에 대한 처리다. 아무리 스프링에서 @Retryable 기능을 제공한다고는 하지만 몇 번이나 재시도를 할지에 대한 기준을 세우기 어렵다는 문제가 있다.
비관적 Lock
비관적 Lock은 X-Lock을 걸어야 하기 때문에 PESSIMISTIC_WRITE를 사용해야 한다.
위와 같이 비관적 Lock을 걸어주면 테스트를 통과한다.
sql문을 확인해 보면
select
c1_0.id,
c1_0.name,
c1_0.reserved_amount,
c1_0.total_amount
from
coupon c1_0
where
coupon_group.id=? for update
for update 문을 사용하여 Lock을 획득한다.
비관적 Lock이 항상 좋진 않습니다. 데이터 무결성을 제공하지만 데이터 자체에 락을 걸게 되므로 성능상 손해를 많이 보게 됩니다.
또한 데드락이 발생할 수 있습니다. 이는 Jpa에서 제공하는 QueryHints를 사용하면 Lock을 소유하는 시간을 설정하여 데드락을 방지할 수 있다.
성능테스트
동시성에 관련해서 성능테스트도 진행해보았습니다.
성능테스트는 K6로 진행하였습니다.
K6를 사용한 이유는 js를 사용하여 다른 테스트도구들 보다 편하게 스크립트를 작성할 수 있었고, 성능 테스트시 적은 메모리를 사용하기 때문에 K6를 사용하게 되었습니다.
처음에 동시성을 해결하기 위해 PromotionOption을 가져올 때도 비관적 Lock을 걸었습니다.
따라서 비관적락을 두 번(PromotionOption, Coupon) 걸어 동시성을 해결할 수 있었습니다.
성능 개선을 하기 위해 쿠폰 100개를 발급받기 위해 성능을 측정해보았습니다.
이 때, 1분 동안 VUser를 1000명까지 서서히 올려서 진행하였습니다.
성능 측정 결과 초당 http 요청을 118건 처리할 수 있었습니다.
전에 동시성을 해결하기 위해 시도했던 Synchronized도 성능측정을 해보았습니다.
성능 측정시 http 요청을 97건 처리할 수 있었습니다.
맨처음 비관적 Lock을 PromotionOption에서 걸지 않고 Coupon을 가져올 때만 비관적 Lock을 걸고, 성능테스트를 진행해보았습니다.
성능 측정 시 http 요청을 193건 처리할 수 있었습니다.
동시성을 처리하기 위해 비관적 Lock이 필요하지 않은곳에 Lock을 걸거나, synchronized 를 사용하였을 때보다 Lock이 필요한 부분만 걸었을 시, 성능이 개선된것을 확인할 수 있었습니다.
'스프링' 카테고리의 다른 글
분산 시스템에서 메시지 안전하게 다루기 (0) | 2023.12.22 |
---|---|
스케줄러와 Transactional 테스트 코드에서 생긴 문제 해결하기 (0) | 2023.10.09 |
Spring WebSocket Ping / Pong (2) | 2023.03.27 |
WAS, Servlet 용어 정리 (0) | 2023.01.04 |
Controller , Service , Repository 이해하기 (0) | 2022.05.19 |