개요
CS퀴즈 프로젝트의 핵심 로직은 ‘선착순’으로 가장 빠른 정답을 제출한 득점자를 확인하는 것이다.
따라서, 여러 정답자 중에 가장 빠른 정답자의 득점 기록을 바탕으로 득점자를 생성해야한다.
문제풀이의 기본적인 로직은 아래와 같다.
- 관리자가 문제 풀이를 허용한다.
- 부원들이 문제 풀이 신호를 확인한다.
- 정답을 제출한다.
- 정답 요청이라면, 득점자가 존재하는지 확인한다.
- 득점자가 존재하지 않는다면, 해당 요청을 기반으로 득점자를 생성한다.
하지만, 일반적인 로직을 통한 구현은 동시성 문제를 겪기 쉬운데 이 글에선 여기서 발생하는 동시성 문제와 해결책을 담은 예제를 통해 찾아보도록 하겠다.
우선, 위에서 말한 로직을 간단하게 구현하면 아래와 같다.
@Transactional
public ReplyResponse submitAnswer(SubmitAnswerRequest request){
// 퀴즈를 찾는다.
Quiz findQuiz = quizRepository.findById(request.quizId()).orElseThrow();
boolean isAnswer = isAnswer(findQuiz, request.answer());
if (isAnswer && !scorerRepository.existsByQuizId(findQuiz.getId())){
scorerRepository.save(Scorer.builder()
.memberId(request.memberId())
.build());
}
Reply createdReply = Reply.builder()
.member(findMember)
.quiz(findQuiz)
.build();
replyRepository.save(createdReply);
return ReplyResponse.from(createdReply);
}
이 때, 문제 풀이 신호를 확인한 동시에 여러 부원들이 정답을 제출하기에 가장 빠른 정답자, 득점자를 생성하는 과정에서 동시성 문제가 발생한다.
문제 상황
이런 정답 요청 A,B,C 가 순서대로 거의 동시에 들어왔다고 가정하자.
(단, 오답 요청은 별도의 순서처리가 필요 없기에 고려하지 않겠다.)
A가 가장 먼저 온 정답 요청이기에 득점자 테이블을 확인한다.
해당 퀴즈에 대한 득점자가 존재하지 않음을 확인하고 본인을 Scorer Table의 컬럼으로 생성한다.
기본적으로 아래 2단계의 작업이 필요하다.
- 득점자 존재여부 확인 후 없음을 인지
- 본인을 득점자로 생성
A가 1번 요청을하고 2번을 마무리하기 전에 B가 1번 작업으로 득점자 테이블을 확인한다.
이 때, B도 득점자가 없음을 확인하고 본인을 득점자로 만들 준비를 한다.
DB에 한 문제에 대한 득점자는 1명만 존재해야하는데 2개 이상의 득점자가 생성될 수 있다.
@Transactional
public ReplyResponse submitAnswer(SubmitAnswerRequest request) {
// 문제 확인
Quiz findQuiz = quizRepository.findById(request.quizId()).orElseThrow();
//정답 여부 확인
boolean isAnswer = isAnswer(findQuiz, request.answer());
// 정답이고 득점자가 존재하지 않는다면
if (isAnswer && !scorerRepository.existsByQuizId(findQuiz.getId())) {
// 본인을 득점자로 생성
scorerRepository.save(Scorer.builder()
.quizId(findQuiz.getId())
.memberId(request.memberId())
.build());
}
Member findMember = memberRepository.findById(request.memberId()).orElseThrow();
replyRepository.save(Reply.builder()
.quiz(findQuiz)
.memberId(findMember.getId())
.build());
return ReplyResponse.from(isAnswer);
}
실제로 k6 를 통해 40명의 멤버가 동시에 요청을 보내면 어떻게 될지에 대한 테스트를 진행해봤다.
하나의 문제에 9명의 득점자가 생긴 것을 확인할 수 있다.
진행한 k6 테스트 스크립트
import http from 'k6/http';
import { check } from 'k6';
import { randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.1.0/index.js';
export default function () {
const url = 'http://localhost:8080/api/reply';
const payload = JSON.stringify({
quizId: 1, // 실제 테스트에서 필요한 값으로 변경하세요
memberId: randomIntBetween(1, 40),
answer: 1 // 정답 요청
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const res = http.post(url, payload, params);
check(res, {
'is status 200': (r) => r.status === 200,
});
}
해당 작업 내용에 대한 코드는 아래 깃허브에서 확인할 수 있다.
https://github.com/Youthhing/Quiz-Example/tree/typical-way-0628
비관적 락 (Pessimistic Lock)
가장 먼저 떠올렸던 방법이다.
실제 DB에 Exclusive-Lock를 건다. 우리는 이때 기존 존재하는 득점자를 건드리는 것이 아닌 득점자 객체를 새로 생성해야하기 때문에 ‘득점자 테이블’ 전체에 락을 걸어야한다.
이럴 경우, A요청이 테이블 전체에 락을 획득하고 1,2번 작업을 진행한다.
이후에 들어온 요청 B는 A가 작업을 완료할때까지 ‘득점자 존재 여부 확인’의 1번 작업을 할 수 없다.
A가 2번 요청을 끝내고 B가 1번 요청을 하게 되면, 득점자가 이미 존재하므로 이후의 작업은 본인을 득점자로 생성하는 작업을 할 수 없다.
결국, 가장 빠른 요청인 A가 득점자로 생성되어 정합한 데이터를 얻을 수 있다.
대충 코드는 이러하다
@Transactional
public ReplyResponse submitAnswer(SubmitAnswerRequest request) {
Quiz findQuiz = quizRepository.findById(request.quizId()).orElseThrow();
Member findMember = memberRepository.findById(request.memberId()).orElseThrow();
boolean isAnswer = isAnswer(findQuiz, request.answer());
// 정답이고 득점자가 존재하지 않으면 Scorer Table 전체에 비관적 락을 획득한다.
if (isAnswer && !scorerRepository.existByQuizIdWithPessimisticLock(findQuiz.getId())) {
//락을 획득했다면 save
scorerRepository.save(Scorer.builder()
.quizId(findQuiz.getId())
.memberId(findMember.getId())
.build());
}
replyRepository.save(Reply.builder()
.quiz(findQuiz)
.memberId(findMember.getId())
.build());
return ReplyResponse.from(isAnswer);
}
단점
- 득점자 ‘테이블 전체’에 락을 거는 경우 다른 프로세스에서도 득점자 테이블에 접근할 수 없다.
- 관리자의 실시간 문제별 득점자 조회 요청
- 기수별 최상위 정답자 조회
- 특히, 1번 예시의 경우는 문제 풀이가 진행되는 동시(득점자가 생성되는 시점)에 득점자 조회하는 기능을 요청하면 다른 프로세스 또는 스레드에서 득점자 테이블에 접근할 수 없다.
- 실제로 아래 2가지 로직에서 득점자 조회를 사용한다.
득점자 ‘테이블’에 대한 비관적 락을 획득하면, 테이블 전체에 락이 걸리기 때문에 득점자를 ‘조회’하는 프로세스에선 이 테이블 락이 해제될 때까지 기다려야하는 것이다.
따라서, 다른 프로세스에선 Scorer 테이블에 접근할 수 없다는 문제가 발생하다.
- 퀴즈 조회, 정답 확인 과정에서 순서가 역전될 수 있다.
- 자바는 멀티스레드 환경에서 동작하기 때문에, A → B → C 순서로 요청이 들어왔다해도 락을 먼저 획득하는 것은 다른 스레드일 수 있다.
또한, Pessmistic 락은 DeadLock 발생 가능하다는 단점이 있다. (물론, 이 경우엔 없을 것 같긴한데 … )
따라서, 이러한 문제를 방지하기 위해 아래와 같은 로직을 생각했다.
Redis 싱글 스레드로 요청별로 순서를 정하면 어떨까?
우선, 멀티스레드 환경에서 순서 역전 문제를 방지하기 위해 Service 메서드에 진입하자마자 redis의 increment함수를 통해 요청 순서를 확인받았다.
Redis는 싱글스레드이기에 가장 여러 요청이 몰리더라도 정합하게 숫자를 하나씩 증가시킬 수 있다.
public Long increment(Long quizId){
String key = KEY_PREFIX + quizId;
return redisTemplate.opsForValue()
.increment(key);
}
이후, 필요한 로직을 처리하고 정답이고 득점자가 존재하지 않으면 Redis의 캐시서버에 ‘득점자의 티켓번호’를 저장한다.
- scorerExistsRepository.java
@Slf4j
@Repository
@RequiredArgsConstructor
public class ScorerExistRepository {
private static final String KEY_PREFIX = "$scorer_";
private static final Long NONE_VALUE = Long.MAX_VALUE;
private static final Integer SCORER_EXPIRATION = 60 * 24;
private final RedisTemplate<String, Long> redisTemplate;
public boolean saveScorerIfFastest(final Long quizId, Long ticketNumber) {
if (getScorerTicketNumber(quizId) > ticketNumber) {
log.info("[기존 득점자보다 빠른 요청 들어옴] 기존: {}, 현재: {}", getScorerTicketNumber(quizId), ticketNumber);
saveScorer(quizId, ticketNumber);
return true;
} else {
return false;
}
}
public void saveScorer(Long quizId, Long ticketNumber) {
String quizKey = KEY_PREFIX + quizId;
redisTemplate.opsForValue().set(
quizKey,
ticketNumber,
SCORER_EXPIRATION,
TimeUnit.MINUTES
);
}
public Long getScorerTicketNumber(final Long quizId){
String quizKey = KEY_PREFIX + quizId;
if (redisTemplate.opsForValue().get(quizKey) == null) {
redisTemplate.opsForValue().set(quizKey, NONE_VALUE, SCORER_EXPIRATION, TimeUnit.MINUTES);
return NONE_VALUE;
}
return redisTemplate.opsForValue().get(quizKey);
}
public void setCache(Long quizId) {
String quizKey = KEY_PREFIX + quizId;
redisTemplate.opsForValue().set(
quizKey,
NONE_VALUE,
SCORER_EXPIRATION,
TimeUnit.MINUTES
);
}
}
이렇게 되면 DB를 조회하지 않아도 현재 득점자와 자신을 비교할 수 있다.
@Transactional
public ReplyResponse submitAnswer(SubmitAnswerRequest request) {
Long ticketNumber = ticketNumberRepository.increment(request.quizId());
log.info("[티켓 번호]: {}, [요청 시간] : {}", ticketNumber, LocalDateTime.now());
Quiz findQuiz = quizRepository.findById(request.quizId()).orElseThrow();
Member findMember = memberRepository.findById(request.memberId())
.orElseThrow(() -> new RuntimeException("멤버 못찾음?"));
boolean isAnswer = isAnswer(findQuiz, request.answer());
// 정답이면 Redis에 Scorer의 ticketNumber 값을 저장하라 -> 이후에 느린 요청이 오더라도 컷 당함.
if (isAnswer && scorerExistRepository.saveScorerIfFastest(findQuiz, ticketNumber)) {
log.info("[정답인 요청] : {}", ticketNumber);
scorerRepository.findByQuizId(findQuiz.getId()).ifPresentOrElse(
scorer -> {
log.info("득점자 업데이트 : {}", ticketNumber);
scorer.updateMember(findMember.getId());
scorerRepository.save(scorer);
},
() -> createScorer(findQuiz, findMember, ticketNumber)
);
}
replyRepository.save(Reply.builder()
.quiz(findQuiz)
.memberId(findMember.getId())
.ticketNumber(ticketNumber)
.result(isAnswer)
.build());
return ReplyResponse.of(isAnswer, findMember.getId(), ticketNumber);
}
자신이 빠르다 → 득점자 정보 생성 또는 수정
자신이 느리다 → if문 진입하지 않음
하지만 … 이 경우에도 …
동시에 40개의 정답 요청을 보내도, 결국 redisTemplate
으로 레디스에 값을 조회하는 순간만 싱글스레드이고 자바 메서드는 멀티스레드로 동작했기 때문에 if문안에 2개이상의 요청이 들어가는 경우가 발생했다.
40개의 동시 정답 요청에 9명이 득점을 하는 엄청난 동시성 문제가 발생한다.
원인
이러한 문제가 발생한 이유는 결국 자바는 멀티스레드 환경에서 돌아가기 때문이다.
여러 작업 중 요청된 순서는 싱글스레드로 정합하게 처리할지라도, 이후 작업이 멀티스레드에서 진행되기 때문에 순서를 보장할 수 없다.
요청 번호를 발급 받는 시기와 득점처리 사이에 처리할 로직이 많은데 여기서만해도 퀴즈 조회, 멤버 조회, 정답확인 3개가 들어간다.
//최초 요청: 순서를 번호로 발급한다.
Long ticketNumber = ticketNumberRepository.increment(request.quizId());
1. 퀴즈 조회
2. 부원 조회
3. 정답 조회
// 정답이면 Redis에 Scorer의 ticketNumber 값을 저장하라 -> 이후에 느린 요청이 오더라도 컷 당함.
if (isAnswer && scorerExistRepository.saveScorerIfFastest(findQuiz.getId(), ticketNumber)) {
log.info("[정답인 요청] : {}", ticketNumber);
scorerRepository.findByQuizId(findQuiz.getId()).ifPresentOrElse(
scorer -> {
log.info("득점자 업데이트 : {}", ticketNumber);
scorer.updateMember(findMember.getId());
scorerRepository.save(scorer);
},
() -> createScorer(findQuiz, findMember, ticketNumber)
);
}
실제 비즈니스 로직에선 유효성 검사 등 추가 작업이 존재하고 각 스레드별로 작업 속도가 다르니 요청 순서를 보장하지 못하고 먼저 온 요청이 더 늦게 if문에 진입할 수 있다.
그 과정에서 득점자의 요청 순서가 24로 설정되어 있는 상태에서, A(10) → B(3) 이런 식으로 if문에 접근한다고 가정해보자.
A(10) 읽기 → B(3) 읽기 → B(3) 쓰기 → A(10) 쓰기
이런 순서로 작업이 진행되면, 가장 빠른 득점자는 3번이지만, 10번으로 기록되는 문제가 발생한다.
Redis의 싱글스레드에 대한 잘못된 공부
redis의 싱글스레드 특징은 redisTemplate 과 상호 작용하는 시점에만 적용된다.
따라서, RedisRepository의 기타 메서드는 Java의 멀티스레드에서 실행되고 상대적으로 늦게 온 요청이라도 할당된 작업을 먼저 처리했다면,
해결
우리가 하고 싶은 것은 ‘저장하는 시점’에 가장 빠른 값이어야한다.
이를 위해 아래 2가지 작업을 진행해야한다.
- 현재 가장 빠른 정답 요청의 번호를 확인한다.
- 득점자가 존재하지 않는다면, 본인을 득점자로 저장한다.
- 본인의 순서와 비교한다.
- 본인이 더 빠르다면, 본인을 득점자로 업데이트한다.
특히, 이 작업을 진행하는 동안 다른 스레드는 해당 작업을 실행하면 안된다.
기존에 고려했던 비관적 락
은 테이블 전체에 락을 걸어야한다는 단점이 있었다. 그러한 문제를 일으키지않고 해결할 방법을 차례대로 고려해보겠다.
낙관적 락 사용
리소스에 락을 거는 것이 아닌, 저장하려는 시점에 동시성 문제가 발생하면 처리하는 방법
- 별도의 락을 사용하지 않기 때문에 성능 좋음
- 득점자 조회 기능 아무 문제 없음
단점, 충돌이 많이 일어나는 경우엔 오히려 성능이 저하된다 (버전을 계속 업데이트하니까)
별도의 version
컬럼을 사용해 리소스의 수정 사항이 생기면 이를 업데이트한다. 따라서, 어플리케이션에서 리소스를 수정하기 전 version을 읽고 수정 후 업데이트한다. 이때, 본인이 읽었던 version과 값이 다르다면 OptimisticLockException
이 발생한다.
이를 이용해 적절히 facade를 구현해 락을 관리할 수 있다.
하지만, 역시 이 경우 Version에 대해 Row락
을 사용할 수 없다.
최초에 득점자가 존재하지 않는 경우에 조회 쿼리를 날리면 아무 값도 반환되지 않기 때문에 결국 락이 걸리지 않는다.
결국 Scorer 테이블 전체에 락을 걸어야하는데 이 경우, 비관적락과 동일하게 다른 로직에서의 접근이 불가능하단 문제가 발생한다.
테이블 설계를 변경하면 어떨까?
Q. Table을 잘못 설계한 것 아닐까?
득점자를 최초로 생성하는 시점에선 Row에 대한 락을 획득할 수 없기 때문에 비관적 락, 낙관적 락 모두 락을 사용해야하는 단위가 ‘테이블’이다.
득점자 테이블에 저장되어있어야하는 정보는 아래와 같다.
- 득점한 member_id
- 득점자의 ticketNumber
- quiz_id
이 정보를 Quiz테이블에 넣어보자.
이렇게 DB설계를 바꾸면, 퀴즈가 이미 생성되어있기에 테이블 단위로 락을 걸지 않고 득점자 존재를 확인하고 쓰는 작업에 테이블 단위의 락을 거는게 아닌 quiz_id
에 해당하는 row에만 락을 걸 수 있다.
따라서, 아래 3개의 컬럼을 Quiz.java에 추가하자.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Quiz {
// 생략
@Column(name = "scorer_id")
private Long memberId;
@Column(name = "scorer_ticket_number")
private Long ticketNumber;
@Version
private Long version;
@Builder
public Quiz(Long number, Long answer) {
this.number = number;
this.answer = answer;
this.ticketNumber = Long.MAX_VALUE; // 퀴즈가 생성될때 득점자의 요청 순서를 Long.MAX로 지정
}
public void updateScorer(Long memberId, Long ticketNumber){
this.memberId = memberId;
this.ticketNumber = ticketNumber;
}
}
이후, QuizService.java에, 기존 문제의 득점자를 확인하고, 본인이 더 빠르다면 해당 문제의 득점자를 변경하는 방식을 적용해보자.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateScorer(Quiz quiz, Member member, Long ticketNumber) {
Quiz findQuiz = quizRepository.findByIdWithOptimisticLock(quiz.getId()).orElseThrow();
if (findQuiz.getTicketNumber() > ticketNumber) {
findQuiz.updateScorer(member.getId(), ticketNumber);
quizRepository.save(findQuiz);
log.info("[득점자 업데이트] 기존: {}, 수정 후 {}", quiz.getTicketNumber(), ticketNumber);
}
}
단, 이 때 Transaction의 전파 수준을 REQUIRES_NEW
변경해야하는데 그렇지 않으면 DeadLock이 발생한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class OptimisticLockReplyFacade {
private final QuizService quizService;
public void checkAndUpdateScorer(Quiz quiz, Member member, Long ticketNumber) throws InterruptedException {
while (true) {
try {
quizService.updateScorer(quiz, member, ticketNumber);
break;
} catch (Exception e){
log.warn("[락 획득 대기]");
Thread.sleep(50);
}
}
}
}
@Transactional
public ReplyResponse submitAnswer(SubmitAnswerRequest request) throws InterruptedException {
// 순서, 문제, 부원, 정답 여부 등등 확인
// 정답이면, 득점자의 존재 여부를 확인 후 조건에 맞춰 업데이트한다.
if (isAnswer) {
optimisticLockReplyFacade.checkAndUpdateScorer(findQuiz, findMember, ticketNumber);
}
//저장 후 반환
return ReplyResponse.from(isAnswer);
}
100여명이 동시 요청을 보내도 문제없이 가장 빠른 사용자가 정답으로 기록된다.
자세한 코드는 해당 PR과 브랜치를 참고하자.
https://github.com/Youthhing/Quiz-Example/pull/3
단점
- quiz의 다른 컬럼을 수정하는 요청이 들어와도 버전이 변경된다.
- 문제 풀이를 진행하는 과정에서 ‘정답’을 수정해야하는 경우가 있는데 이 경우 성능 문제가 발생할 수 있다.
- 비즈니스 로직상 득점자에 관한 다른 기능이 추가되는 확장성이 어렵다는 문제가 있다.
- 필요한 트랜잭션이 많아진다.
- (요청 수 * 2)의 커넥션이 필요하다.
- 실제로 아래와 같이 데이터베이스와의 커넥션 숫자를 적절히 조정해야한다.
다음 글에선 이러한 문제를 개선하기 위해 테이블 구조를 변경하지 않고 Redis를 통해 분산락을 구현해볼 예정이다.
'프로젝트 > COTATO.KR' 카테고리의 다른 글
선착순 퀴즈 프로젝트 V1 회고 (2) | 2024.07.08 |
---|---|
선착순 로직 개선기 (2) - Redis 분산락 (0) | 2024.07.05 |
Enum 그룹화를 통한 MemberRole 관리 (0) | 2024.06.12 |
정답 제출 API 멱등성 처리하기 (0) | 2024.06.01 |
멱등성을 통한 정답 제출 API 중복 요청 방지 (이론) (0) | 2024.06.01 |