1. 쿠폰 발급 동시성 문제 발생
이전에 쿠폰 발급 동시성 테스트 중 데드락과 동시성 문제가 발생하였다!
그런데 내가 처음에 집중하려던 것은 동시성 문제이기에 일단 `flush()`로 임시조치를 하고 다시 테스트를 실행해보았다.
실행 결과, 동시성 문제가 발생하였다!
발급된 쿠폰(`IssuedCoupon`)은 100개가 생성됐지만 쿠폰(`Coupon`)의 수량은 정상적으로 감소하지 않았다.
이를 해결하기 위해 `비관적 락`을 도입하기로 했다.
1-1. 비관적 락을 선택한 이유
락에는 `비관적 락`과 `낙관적 락`이 있다.
- `낙관적 락` 동시성 문제가 발생하지 않을 것이라 가정하여 동시성 문제가 발생했을 때 이를 처리하는 방식
- `비관적 락` 트랜잭션 충돌이 잦을 것이라 가정하고 데이터를 사용하기 전에 미리 락을 걸어 동시성 문제를 방지하는 방식
쿠폰 발급 로직은 트랜잭션 충돌이 많을 것이라 판단하여 `비관적 락`을 선택했다.
2. 데드락과 동시성 문제 해결 과정
기존에 데드락이 발생한 이유는 서로 다른 트랜잭션이 동일한 데이터에 대해 S Lock을 설정하여, 수정에 필요한 X Lock을 제대로 걸지 못했기 때문이다.
`flush()`를 적용하여 데드락을 해결하였지만 MySQL의 InnoDB는 기본 SELECT 시 S LOCK이 설정되지 않는다. 따라서 다른 트랜잭션이 읽고 있는 데이터라도 X LOCK을 걸어 수정할 수 있었고 이로 인해 동시성 문제가 발생하였다.
따라서 쿠폰의 잔여 수량을 정상적으로 감소시키기 위해 `쿠폰 조회` 메서드에 비관적 쓰기 락을 적용하기로 결정했다.
2-1. JPA Pessimistic Lock
JPA에서 비관적 락은 `@Lock` 어노테이션을 사용하여 설정할 수 있다.
- @Lock(LockModeType.PESSIMISTIC_READ) : 데이터를 읽기 위해 비관적 읽기 락을 설정하여, 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 방지 (S Lock)
- @Lock(LockModeType.PESSIMISTIC_WRITE) : 데이터를 수정하기 위해 비관적 쓰기 락을 설정하여, 다른 트랜잭션이 해당 데이터를 읽거나 수정하지 못하도록 방지 (X Lock)
두 가지 옵션 중 비관적 쓰기 락을 적용하였다.
락을 적용하려고 했던 첫 시도 (실패)
처음에는 CouponRepository 인터페이스에 비관적 락을 설정해 보았으나, 적용되지 않았다.
인터페이스에서 락을 적용하려고 했던 이유는 findById와 같은 조회 메서드를 사용할 때 트랜잭션 간의 동시성을 관리하려는 목적이었지만, 실제로는 JPA 엔티티에 비관적 락을 걸어야 한다는 것을 깨달았다.
구현 방식 수정
그래서 CouponRepositoryImpl 구현체에 비관적 락을 적용해보았다.
findById와 같은 일반 조회 메서드에 락을 걸게 되면, 단순 조회 API 호출 시에도 락이 적용되기 때문에, 실제 업데이트용 조회 메서드를 새로 만들었다.
그런데 또 다시 락이 제대로 적용되지 않았다.
문제 해결
다시 확인해 보니, JPA에서 제공하는 쿼리 메서드나 @Query 어노테이션을 사용하여 엔티티 객체에 락을 걸어야 한다는 사실을 알게 되었다.
이렇게 엔티티 메서드에 비관적 쓰기 락을 적용하고 나니 flush()를 호출하지 않고도 데드락이 발생하지 않았고, 동시성 문제도 해결되었다.
3. 해결 과정 (트랜잭션 흐름)
3-1. 비관락 적용 전 데드락 및 동시성 문제 발생
`비관적 락`과 `flush()` 둘 다 적용하기 전의 트랜잭션 실행 흐름이다.
INSERT 쿼리가 먼저 발생하여 동일한 부모 쿠폰 레코드에 대해 S LOCK이 걸려 데드락이 발생한다.
물론 데드락이 없는 트랜잭션에서는 동시성 문제가 발생한다.
3-2. flush() 적용 후 동시성 문제 발생
`flush()`를 적용하여 트랜잭션 실행 순서는 보장했지만 동시성 문제가 발생한다.
MySQL의 InnoDB에서는 조회 시 S LOCK을 걸지 않기 때문에 동시에 두 트랜잭션이 같은 쿠폰 데이터를 읽었고 수정을 시도한다.
결과적으로 `Lost Update`가 발생한다.
3-3. 비관적 락 적용 후 문제 해결
`JPA Pessimistic Lock`을 적용하여 동시성 문제를 해결하였다.
JPA에서 `Pessimistic Lock`을 사용하면 `FOR UPDATE` 구문을 통해 해당 레코드에 X LOCK이 적용된다. 이로 인해 해당 레코드는 다른 트랜잭션에서 읽거나 수정할 수 없게 되어 동시성 문제가 해결된다.
S LOCK으로는 해결이 안되나? 라고 생각할 수 있지만 그럼 또 다시 데드락 문제가 발생한다!
동일한 쿠폰 레코드에 S LOCK을 걸게 되고 결과적으로 데이터 수정시 필요한 X LOCK을 획득할 수 없어 데드락이 발생할 것이다.
4. 성능 테스트
`flush()`를 통한 임시 조치로 데드락 문제는 완화할 수 있었지만, 동시성 문제를 완전히 해결할 수는 없었다.
이에 JPA의 Pessimistic Lock을 활용하여 `FOR UPDATE` 구문을 적용함으로써 데이터 수정 과정에서 트랜잭션 충돌과 Lost Update 문제를 근본적으로 방지할 수 있었다.
하지만 `비관적 락`은 해당 로직을 수행할때마다 해당 레코드에 락을 걸기 때문에 성능 상의 문제를 야기할 수 있다.
이를 검증하기 위해 K6 툴을 사용하여 로컬 환경에서 성능 테스트를 진행했다.
4-1. 100명의 사용자가 1초에 1회 → 10번 반복 (총 1000회 요청)
- 테스트 시나리오
100명의 사용자가 1초마다 한 번씩 요청을 보내고, 이를 10초 동안 반복하도록 스크립트를 작성했다. - 결과
처리량은 http_reqs나 iterations 메트릭을 기준으로 확인했으며, 초당 약 40건 정도의 처리량을 기록했다.
4-2. 500명의 사용자가 1초에 1회 → 총 500회 요청
- 테스트 시나리오
500명의 사용자가 동시에 요청을 보내도록 설정했다. - 결과
- 처리량은 이전 테스트와 유사하게 초당 약 40건 수준을 유지했다.
- 하지만 응답 시간(http_req_duration)은 크게 증가했다.
- 최소 응답 시간: 82ms로 비교적 빠르게 응답한 요청도 있었으나,
- 최대 응답 시간: 10초로 매우 길게 측정되었다.
요청이 많아질수록 비관적 락으로 인해 잠금을 대기하는 시간이 길어졌다.
이는 비관적 락의 특성상 대기가 증가하며 발생하는 병목 현상으로 보인다.
테스트 결과, 현재 서비스의 처리량은 40/s 수준으로, 안정적인 서비스에서 요구되는 700~800/s 에 비해 턱없이 부족한 수준이다.
이로 인해 성능 개선이 필수적인 상황이다
비관적 락 대신 동시성 문제를 해결하는 방법에는 무엇이 있는지 찾아봐야겠다.
'공부 > Project' 카테고리의 다른 글
Springboot 쿠폰 발급 동시성 테스트 데드락 문제 발생 (MySQL InnoDB, FK 참조 레코드..) (1) | 2024.12.26 |
---|---|
작업 환경 전환 시 Git 을 활용한 임시 커밋 처리 (0) | 2024.11.19 |
[Circular dependency between the following tasks] 순환 종속성 문제 해결과 Mapper 활용 (0) | 2024.11.07 |
웹 프로젝트 서버 배포 흐름도 (0) | 2024.09.25 |
Spring Cloud FeignClient N+1 호출 문제 해결하기 (0) | 2024.09.23 |