상황
후원자분들은 캐릭터 스킨을 적용할 수 있게하기 위한 수단으로 쿠폰을 선택하였다.
이렇게 발급된 쿠폰을 앱내에서 등록하면 해당 쿠폰을 만료된 쿠폰으로 변경하는데, 동시에 여러명이 쿠폰 등록시 모두 쿠폰이 등록될 수 있는 동시성 이슈를 발견했다.
사용기술: Mysql8.0, JPA
문제 분석
@Transactional
public void checkSponsor(String socialId, String code) {
Sponsor sponsor = sponsorRepository.findByCode(code)
.orElseThrow(() -> CodeNotExistException.EXCEPTION);
// 쿠폰 유효성 확인
checkCodeAlreadyUsed();
// 쿠폰 사용 처리
confirmSponsor();
}
쿠폰 등록 요청이 서버로 왔을때, 서버에서는 아래의 과정을 거친다
1. 쿠폰이 유효한지 확인
2. 쿠폰 사용 처리
또한 현재 JPA를 사용하여 쿠폰 사용 처리에 해당 하는 부분은 flush시 dirty checking을 통해서 db에 반영되게 되어있다.
이에 따라 첫번째 요청의 변경사항이 db에 반영되기전에 두번째 요청이 들어와 쿠폰 유효성을 검증하면, 첫번째 변경사항이 반영되지 않은 데이터를 읽어와 중복 등록이 이루어지게 되는 것이다.
해결 방법
먼저 해당 문제의 해결법들 중 일부를 나열했다.
- syncronized 설정
- select ~ for update 를 통한 x-lock 설정
- Redis를 사용한 분산락 처리
본인은 이중에서 DB 리소스 점유와 쿠폰 등록이라는 서비스의 특성을 고려하여 해결법을 선택하고자 했다.
syncronized 설정 + 격리 수준 설정
checkSponsor 메서드에 syncronized 설정과 격리 수준을 Read Uncommitted로 설정 해준다면 문제를 해결할 수 있다.
하지만 트랜잭션 안에서 syncronized를 사용함으로써 발생하는 병목 및 DB 리소스 낭비, 그리고 격리 수준 설정시 발생하는 side effect 등으로 인해서 이 방법은 사용하지 않기로 했다.
트랜잭션 격리 수준 변경해야하는 이유
Mysql 8.0 기본 격리 수준인 repeatable read는, 트랜잭션 시작 이후 첫번째 읽기시 스냅샷을 생성한다.
만약 실행중이던 스레드가 Instrinsic Lock을 반환하고 트랜잭션을 커밋하려는 사이에, checkCodeAlreadyUsed 메서드에서 값을 읽는다면 동시성 문제가 발생한다.
따라서 이를 위해 격리 수준을 Read Uncommitted로 설정하고, checkSponsor 안에서 flush 한다면 문제를 해결할 수 있다.
MySQL :: MySQL 8.0 Reference Manual :: 15.7.2.3 Consistent Nonlocking Reads
15.7.2.3 Consistent Nonlocking Reads A consistent read means that InnoDB uses multi-versioning to present to a query a snapshot of the database at a point in time. The query sees the changes made by transactions that committed before that point in time, a
dev.mysql.com
select ~ for update 를 통한 x-lock 설정
쿠폰 유효성 검증을 위한 조회문에 for update 를 추가하여 x-lock을 해당 레코드에 설정한다.
이에 따라 트랜잭션 격리 수준을 조정하지 않고도 쿠폰 등록 동시성 처리를 할 수 있다.
하지만 이 방법도 db 리소스 점유 측면에서 보았을때 만족스럽지 못하였다. 결국 lock을 얻기 위해서는 db 커넥션을 점유한채로 기다려야 하기 때문이다.
Redis를 사용한 분산락 처리
Redis의 lock을 획득해야만 트랜잭션에 진입할 수 있게 한다.
트랜잭션이 완전히 끝나 db에 내용이 반영된 뒤 이후, 다음 요청이 db 데이터를 조회하기 때문에 동시성 문제도 해결되고,
하나의 쿠폰에 대해서 하나의 요청만이 트랜잭션을 얻어 작업을 진행할 수 있게 함으로, db 리소스도 낭비되지 않는다.
단점이 있다면 Redis를 관리해야 한다는 것 이었는데, 나날에서는 refresh 토큰 관리를 위해 이미 Redis를 사용하고 있던터라 해당 방법을 바로 적용할 수 있었다.
Redisson vs Lettuce
Redis 분산락 구현을 위한 라이브러리로 Redisson과 Lettuce 이 두 가지 방안이 있었다.
일반적으로 Redisson은 pub/sub 방식을 사용하고 락 획득 재시도 로직을 라이브러리에서 지원해주고 있다.
따라서 락 획득 재시도가 필요할 경우 Redisson을 사용한다.
Lettuce는 setnx로 spin lock 을 구현하고, 라이브러리 자체에서 락 획득 재시도를 지원해주지 않아 직접 코드를 작성해줘야한다. 따라서 락 획득 재시도가 필요없는 경우에는 비교적 가벼운 Lettuce를 사용한다.
쿠폰 등록이라는 서비스의 특성상 한 번 등록된 이후에는 더 이상의 변경이 이루어지지 않는다.
이러한 특성을 고려했을때 Lettuce를 이용하여 재시도없는 분산락을 구현하는 것이 적절하다고 판단했고,
Spring AOP를 통해 어노테이션 설정만으로 간단하게 분산락을 적용할 수 있도록 설정했다.
결과적으로 처음의 목표였던 'DB 리소스 점유' 와 '쿠폰 등록 서비스의 특성' 을 고려한 동시성 처리를 달성할 수 있었다.
'Project' 카테고리의 다른 글
[마이카마스터] 인덱스와 캐시를 이용한 측정에 의한 성능 개선 (0) | 2023.08.31 |
---|---|
[마이카마스터] 비동기 처리를 통한 구매 상담 신청 API 개선 (0) | 2023.08.31 |
[나날] 동시 요청시 Transaction Propagation 설정으로 인한 CP Deadlock 문제 (0) | 2023.06.26 |
[나날] 프록시 내부 호출을 고려한 트랜잭션 범위 최소화 (0) | 2023.06.19 |