지난 글에선, 낙관적 락을 사용하기, 테이블 구조 변경을 통해 선착순 제출 로직의 동시성 문제를 해결했다. 이 글에선 테이블 구조를 별도로 변경하지 않고 Redis의 분산락을 통해 문제를 해결해본 과정을 적을 예정이다.
Redis를 통한 동시성 해결
동시성 문제가 발생하는, 락이 필요한 부분은 ‘정답 제출’하는 득점자를 생성하는 과정이다.
제출한 요청 중 ‘정답’으로 판별된 내용을 기준으로만 득점자를 파악하면 된다.
따라서, 아래 득점자 확인 및 등록
로직에 대해서만 lock을 적용하면 된다.
- 득점자 존재 여부를 확인한다.
- 존재한다면, 본인이 더 빠른 요청일 경우,
- 존재하지 않으면, 본인을 득점자로 생성한다.
Lettuce 분산락
redis의 setnx
명령어를 통해 분산락을 구현할 수 있다.. 특정 키에 대한 value가 존재하면 false, 존재하지 않으면 값을 설정하고 true를 반환할 수 있다.
이를 통해 득점자를 생성하기 전 lock을 획득하고, 로직을 마치면 락을 반환하는 방식을 통해 하나의 트랜잭션만 득점자 업데이트를 하는 방식으로 문제를 해결할 수 있다.

별도의 RedisLockRepository에 아래 2개, lock
, unlock
메서드를 구현한다.
public Boolean lock(Long key){
return redisTemplate.opsForValue()
.setIfAbsent(generate(key), "lock", Duration.ofMillis(5_000));
}
public Boolean unlock(Long key){
return redisTemplate.delete(generate(key));
}
이후 facade class를 구현해 아래와 같이 락 획득을 시도하는 스핀락을 구현하자.
- 락 획득을 시도한다.
- 획득에 실패하면 100밀리초를 대기 하고 다시 획득을 시도한다 → 스핀락
- 락 획득에 성공하면 비즈니스 로직을 진행
- 락을 반환해준다.
// LettuceLockScorerFacade.java
public void checkAndThenUpdate(Quiz findQuiz, Member findMember, Long ticketNumber) throws InterruptedException {
while (!redisLockRepository.lock(findQuiz.getId())) {
Thread.sleep(100);
}
try {
scorerService.checkAndThenUpdateScorer(findQuiz, findMember, ticketNumber);
} finally {
redisLockRepository.unlock(findQuiz.getId());
}
}
이렇게 동시에 100명의 사용자의 정답 요청을 보내보면 아래와 같이 가장 먼저 요청한 사용자가 득점자로 인정된다.

단점

- 스핀락으로 구현되기 때문에 Redis 서버에 과부화를 줄 수 있다
실제로 100명의 동시 요청을 보낸 위 테스트의 실행 시간은 아래와 같다. (약4초) - 별도로 lock 관리를 구현해줘야한다.
RedisLockRepository
를 통해 별도의 메서드를 구현해야한다. (사실 이해만 하면 하기는 쉽다.)
https://github.com/Youthhing/Quiz-Example/pull/6
Redisson
redis는 pub/sub 구현이 가능한데 이를 기반으로 분산락을 구현한 클라이언트가 redisson이다.
특정 key에 대한 채널을 구독하고 큐의 형태로 대기한다.
락을 점유한 트랜잭션이 락 반환을 채널에 알리면 다음 트랜잭션이 락을 점유하는 방식이다.

별도로 lock을 구현하지 않고 제공되는 RedissonClient
의존성 추가를 통해 구현할 수 있다.
getLock()
: 특정 키에 대한 락 인스턴스를 조회tryLock(waitTime, leaseTime, 시간 단위)
: 락을 waitTime만큼 가지려고 시도 후, leaseTime만큼 점유한다.
따라서, 정답이라면 Facade를 통해 락 획득을 시도 후 득점자 업데이트 로직을 수행한다.
@Transactional
public ReplyResponse submitAnswer(SubmitAnswerRequest request) {
Long ticketNumber = ticketNumberRepository.increment(request.quizId());
Quiz findQuiz = quizRepository.findById(request.quizId()).orElseThrow();
Member findMember = memberRepository.findById(request.memberId()).orElseThrow();
boolean isAnswer = isAnswer(findQuiz, request.answer());
if (isAnswer) {
scorerUpdateFacade.checkAndUpdateScorer(findQuiz, findMember, ticketNumber);
}
replyRepository.save(Reply.builder()
.quiz(findQuiz)
.memberId(findMember.getId())
.ticketNumber(ticketNumber)
.build());
return ReplyResponse.from(isAnswer);
}
// ScoereUpdateFacade.java
public void checkAndUpdateScorer(Quiz quiz, Member member, Long ticketNumber) {
RLock lock = redissonClient.getLock(generate(quiz.getId()));
try {
boolean available = lock.tryLock(30, 1, TimeUnit.SECONDS);
if (!available) {
log.warn("[락 획득 실패] : {}", ticketNumber);
return;
}
log.info("[락 획득 : {}], 시간: {}", ticketNumber, LocalDateTime.now());
scorerService.checkAndThenUpdate(quiz, member, ticketNumber);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
log.info("[락 반납 : {}]", ticketNumber);
lock.unlock();
}
}
}
이후 k6를 통해 100여명의 동시 요청을 시도해도 득점자의 순서는 완전하게 보장된다.

생성된 득점자의 순서는 1번으로 보장된다.

https://github.com/Youthhing/Quiz-Example/pull/5
우려 사항
Redis를 사용해 구현하는 락은 실제 DB가 아닌 서버의 인메모리에서 통제되는 락이다. 따라서, 다른 로직에서 scorer를 변경하는 경우가 있다면 문제가 발생할 수 있다.
우리는 ‘정답 추가 및 수정’ 로직에서 정답이 바뀜에 따라 득점자를 다시 계산해야하는 경우가 있다.
따라서, 이러한 로직에도 동시성 문제가 발생하지 않도록 관리할 필요가 있다.
느낀점
낙관적 락
, Lettuce를 통한 락
, RedissonClient
등 다양한 방법으로 동시성을 제어할 수 있었다.
- 득점자를 ‘생성’하는 시점에서의 동시성 제어가 필요하다는 점.
- 이미 운영되고 있는 득점자 테이블 구조를 변경하는 비용
- 크지 않은 서버를 운영하는만큼 redis의 부하를 줄여야한다는점
이 글에선 예제를 통해 설명했지만 실제 프로젝트에선 위 3가지의 이유로 인해 RedissonClient
를 적용할 예정이다.
예제를 통해 여러 동시성 제어 방법의 장, 단점을 이해할 수 있었고 그 과정에서 트랜잭션의 전파 범위, MySQL의 격리 수준등 이론으로 배운 것들을 체득할 수 있었기 때문에 다른 로직에서 동시성 처리가 필요할 ㅈ
다만, DB와의 가능한 커넥션 풀 이상의 범위에 동시 요청은 테스트할 수 없었는데 커넥션 풀에 대한 이론을 공부한 후에 더 많은 상황에서도 문제가 발생하지 않는지 겪어보고 싶다.
위 두 과정에서 공통적으로 트랜잭션 전파 수준과 관련된 이슈를 겪었다. 이는 우리가 선택한 MySQL의 격리 수준에 따른 이슈였는데 해당 글은 다음 글에서 다루도록 하겠다.
'프로젝트 > COTATO.KR' 카테고리의 다른 글
Refresh Token Rotation (0) | 2024.07.21 |
---|---|
선착순 퀴즈 프로젝트 V1 회고 (2) | 2024.07.08 |
선착순 로직 개선기 (1) - 낙관적 락 활용 (1) | 2024.07.05 |
Enum 그룹화를 통한 MemberRole 관리 (0) | 2024.06.12 |
정답 제출 API 멱등성 처리하기 (0) | 2024.06.01 |
지난 글에선, 낙관적 락을 사용하기, 테이블 구조 변경을 통해 선착순 제출 로직의 동시성 문제를 해결했다. 이 글에선 테이블 구조를 별도로 변경하지 않고 Redis의 분산락을 통해 문제를 해결해본 과정을 적을 예정이다.
Redis를 통한 동시성 해결
동시성 문제가 발생하는, 락이 필요한 부분은 ‘정답 제출’하는 득점자를 생성하는 과정이다.
제출한 요청 중 ‘정답’으로 판별된 내용을 기준으로만 득점자를 파악하면 된다.
따라서, 아래 득점자 확인 및 등록
로직에 대해서만 lock을 적용하면 된다.
- 득점자 존재 여부를 확인한다.
- 존재한다면, 본인이 더 빠른 요청일 경우,
- 존재하지 않으면, 본인을 득점자로 생성한다.
Lettuce 분산락
redis의 setnx
명령어를 통해 분산락을 구현할 수 있다.. 특정 키에 대한 value가 존재하면 false, 존재하지 않으면 값을 설정하고 true를 반환할 수 있다.
이를 통해 득점자를 생성하기 전 lock을 획득하고, 로직을 마치면 락을 반환하는 방식을 통해 하나의 트랜잭션만 득점자 업데이트를 하는 방식으로 문제를 해결할 수 있다.

별도의 RedisLockRepository에 아래 2개, lock
, unlock
메서드를 구현한다.
public Boolean lock(Long key){
return redisTemplate.opsForValue()
.setIfAbsent(generate(key), "lock", Duration.ofMillis(5_000));
}
public Boolean unlock(Long key){
return redisTemplate.delete(generate(key));
}
이후 facade class를 구현해 아래와 같이 락 획득을 시도하는 스핀락을 구현하자.
- 락 획득을 시도한다.
- 획득에 실패하면 100밀리초를 대기 하고 다시 획득을 시도한다 → 스핀락
- 락 획득에 성공하면 비즈니스 로직을 진행
- 락을 반환해준다.
// LettuceLockScorerFacade.java
public void checkAndThenUpdate(Quiz findQuiz, Member findMember, Long ticketNumber) throws InterruptedException {
while (!redisLockRepository.lock(findQuiz.getId())) {
Thread.sleep(100);
}
try {
scorerService.checkAndThenUpdateScorer(findQuiz, findMember, ticketNumber);
} finally {
redisLockRepository.unlock(findQuiz.getId());
}
}
이렇게 동시에 100명의 사용자의 정답 요청을 보내보면 아래와 같이 가장 먼저 요청한 사용자가 득점자로 인정된다.

단점

- 스핀락으로 구현되기 때문에 Redis 서버에 과부화를 줄 수 있다
실제로 100명의 동시 요청을 보낸 위 테스트의 실행 시간은 아래와 같다. (약4초) - 별도로 lock 관리를 구현해줘야한다.
RedisLockRepository
를 통해 별도의 메서드를 구현해야한다. (사실 이해만 하면 하기는 쉽다.)
https://github.com/Youthhing/Quiz-Example/pull/6
Redisson
redis는 pub/sub 구현이 가능한데 이를 기반으로 분산락을 구현한 클라이언트가 redisson이다.
특정 key에 대한 채널을 구독하고 큐의 형태로 대기한다.
락을 점유한 트랜잭션이 락 반환을 채널에 알리면 다음 트랜잭션이 락을 점유하는 방식이다.

별도로 lock을 구현하지 않고 제공되는 RedissonClient
의존성 추가를 통해 구현할 수 있다.
getLock()
: 특정 키에 대한 락 인스턴스를 조회tryLock(waitTime, leaseTime, 시간 단위)
: 락을 waitTime만큼 가지려고 시도 후, leaseTime만큼 점유한다.
따라서, 정답이라면 Facade를 통해 락 획득을 시도 후 득점자 업데이트 로직을 수행한다.
@Transactional
public ReplyResponse submitAnswer(SubmitAnswerRequest request) {
Long ticketNumber = ticketNumberRepository.increment(request.quizId());
Quiz findQuiz = quizRepository.findById(request.quizId()).orElseThrow();
Member findMember = memberRepository.findById(request.memberId()).orElseThrow();
boolean isAnswer = isAnswer(findQuiz, request.answer());
if (isAnswer) {
scorerUpdateFacade.checkAndUpdateScorer(findQuiz, findMember, ticketNumber);
}
replyRepository.save(Reply.builder()
.quiz(findQuiz)
.memberId(findMember.getId())
.ticketNumber(ticketNumber)
.build());
return ReplyResponse.from(isAnswer);
}
// ScoereUpdateFacade.java
public void checkAndUpdateScorer(Quiz quiz, Member member, Long ticketNumber) {
RLock lock = redissonClient.getLock(generate(quiz.getId()));
try {
boolean available = lock.tryLock(30, 1, TimeUnit.SECONDS);
if (!available) {
log.warn("[락 획득 실패] : {}", ticketNumber);
return;
}
log.info("[락 획득 : {}], 시간: {}", ticketNumber, LocalDateTime.now());
scorerService.checkAndThenUpdate(quiz, member, ticketNumber);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
log.info("[락 반납 : {}]", ticketNumber);
lock.unlock();
}
}
}
이후 k6를 통해 100여명의 동시 요청을 시도해도 득점자의 순서는 완전하게 보장된다.

생성된 득점자의 순서는 1번으로 보장된다.

https://github.com/Youthhing/Quiz-Example/pull/5
우려 사항
Redis를 사용해 구현하는 락은 실제 DB가 아닌 서버의 인메모리에서 통제되는 락이다. 따라서, 다른 로직에서 scorer를 변경하는 경우가 있다면 문제가 발생할 수 있다.
우리는 ‘정답 추가 및 수정’ 로직에서 정답이 바뀜에 따라 득점자를 다시 계산해야하는 경우가 있다.
따라서, 이러한 로직에도 동시성 문제가 발생하지 않도록 관리할 필요가 있다.
느낀점
낙관적 락
, Lettuce를 통한 락
, RedissonClient
등 다양한 방법으로 동시성을 제어할 수 있었다.
- 득점자를 ‘생성’하는 시점에서의 동시성 제어가 필요하다는 점.
- 이미 운영되고 있는 득점자 테이블 구조를 변경하는 비용
- 크지 않은 서버를 운영하는만큼 redis의 부하를 줄여야한다는점
이 글에선 예제를 통해 설명했지만 실제 프로젝트에선 위 3가지의 이유로 인해 RedissonClient
를 적용할 예정이다.
예제를 통해 여러 동시성 제어 방법의 장, 단점을 이해할 수 있었고 그 과정에서 트랜잭션의 전파 범위, MySQL의 격리 수준등 이론으로 배운 것들을 체득할 수 있었기 때문에 다른 로직에서 동시성 처리가 필요할 ㅈ
다만, DB와의 가능한 커넥션 풀 이상의 범위에 동시 요청은 테스트할 수 없었는데 커넥션 풀에 대한 이론을 공부한 후에 더 많은 상황에서도 문제가 발생하지 않는지 겪어보고 싶다.
위 두 과정에서 공통적으로 트랜잭션 전파 수준과 관련된 이슈를 겪었다. 이는 우리가 선택한 MySQL의 격리 수준에 따른 이슈였는데 해당 글은 다음 글에서 다루도록 하겠다.
'프로젝트 > COTATO.KR' 카테고리의 다른 글
Refresh Token Rotation (0) | 2024.07.21 |
---|---|
선착순 퀴즈 프로젝트 V1 회고 (2) | 2024.07.08 |
선착순 로직 개선기 (1) - 낙관적 락 활용 (1) | 2024.07.05 |
Enum 그룹화를 통한 MemberRole 관리 (0) | 2024.06.12 |
정답 제출 API 멱등성 처리하기 (0) | 2024.06.01 |