(+2024.06.14 성능 비교 테스트 코드가 잘못된 부분이 있어 수정했습니다. )
배경 및 문제점
현재 진행하고있는 KBO-ticketing은 야구 예매 서비스이다. 따라서 다음과 같은 기능이 존재한다.
- 좌석을 선택한다. 선택한 좌석은 7분 동안 ‘내 좌석’으로 타인이 선택할 수 없다.
- 좌석을 예매한다.
위의 기능을 2개의 API로 나누어서 구현했고, ‘좌석 선택 API’의 flow는 다음과 같다.
Redis에 저장된 key를 확인해 좌석이 ‘선택’된 좌석인지 확인하고, redis Key가 존재하면 좌석 선택 불가, redis Key가 존재하지않으면 Redis에 key-value를 저장한다. 이때 value에는 user의 id를 저장한다.
@Service
@RequiredArgsConstructor
public class SeatService {
private final SeatMapper seatMapper;
private final RedisTemplate<String, String> redisTemplate;
public void selectSeats(SeatDto seatDto, Integer userId) {
String redisKey = String.format("%d_%d_%d", seatDto.getScheduleId(),
seatDto.getSeatGradeId(), seatDto.getSeatNumber());
Boolean selected = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(selected)) {
throw new CustomException(ErrorCode.SEAT_IN_PROGRESS);
} else {
redisTemplate.opsForValue()
.set(redisKey, String.valueOf(userId));
redisTemplate.expire(redisKey, 7, TimeUnit.MINUTES);
}
}
}
하지만 이때 1명 이상의 유저가 한 좌석을 동시에 선택하려고 하면 문제가 발생한다는 것을 알았다. 2명 이상의 유저가 같은 경기, 같은 좌석 등급, 같은 좌석번호를 좌석선택 API를 요청한다고 가정했을 때 문제는 다음과 같다.
만약 user 1이 Redis key를 확인하고, false를 리턴받아서 key를 저장 하기전에 , user 2가 Redis key를 확인하면 아직 redis key가 만들어지지 않았기 때문에 user 2의 요청은 false를 return 받아 redis key를 저장한다. 따라서 user 1의 요청으로 Redis value에 user 1의 user id가 저장되고, user 2의 요청도 유효하다고 판단되었기 때문에 redis value가 user 2의 user id로 업데이트된다.
따라서 결국 user 1의 요청은 성공했지만, 동시성 문제가 발생한다. 이는 ‘Redis의 key를 가져오는 것’과 ‘Key를 저장하는 것’이 Atomic 하지 않아서 발생한다. 이 두 연산을 Atomic 하게 만들어야한다.
해결 방법
그림을 보면 ‘Redis의 key를 가져오는 것’과 ‘Key를 저장하는 것’ 연산이 하나로 묶여있다. 다음과 같이 구현하면 먼저 요청한 user 1의 요청이 작업이 끝날때까지 user 2의 요청은 기다리게 되고, 요청이 수행되었을 때는 ‘이미 선택한 좌석입니다.’라는 에러가 뜬다.
그렇다면 어떻게 Atomic 하게 처리할 수 있을까? 그전에 먼저 Atomic의 개념을 알고 가자.
Atomic이란?
Atomic은 '원자성'을 의미하며, 작업이 더 이상 쪼개어질 수 없는 가장 작은 단위로, 다른 작업에 의해 간섭되지 않는 것을 의미한다.
Integer a = 10;
a++;
예를 들어 a가 라는 값이 있을 때, a 플러스 1을 하려고 하면 CPU 입장에서 총 몇번의 작업을 실행해야 할까?
CPU 입장에서 총 3번 작업이 실행해야 한다.
- 값을 읽고 (read)
- 값을 증가시키고
- 값을 저장한다. (write)
만약 값을 저장하려고 하는 시점에 다른 쓰레드가 a 값에 12라고 저장했다면 어떻게 해야 할까? 내가 값을 저장하려고 했던 시점에서는 a의 값이 10이었는데 메모리에 있는 값은 12가 되었다. 그렇다면 값은 11이 되어야 할까 13이 되어야 할까?
이러한 문제점을 막기 위해 서로 다른 연산을 하나의 연산으로 만들어 다른 연산이 끼어들지 못하게 만드는 것이 Atomic이다.
Atomic하게 처리할수 있는 방법은 2가지이다.
- syncornized를 활용하기 (메소드 레벨, 블록 레벨)
- redis의 lua script를 이용하여 atomic하게 처리하기
방법 1. synchronized를 통한 동시성 문제 해결
synchrozied 키워드는 메소드나 블록에 사용해 한번에 한 쓰레드만 수행하도록 보장해준다.
메소드 레벨의 synchronized
@Service
@RequiredArgsConstructor
public class SeatService {
private final SeatMapper seatMapper;
private final RedisTemplate<String, String> redisTemplate;
public synchronized void selectSeats(SeatDto seatDto, Integer userId) {
String redisKey = String.format("%d_%d_%d", seatDto.getScheduleId(),
seatDto.getSeatGradeId(), seatDto.getSeatNumber());
Boolean selected = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(selected)) {
throw new CustomException(ErrorCode.SEAT_IN_PROGRESS);
} else {
redisTemplate.opsForValue()
.set(redisKey, String.valueOf(userId));
redisTemplate.expire(redisKey, 7, TimeUnit.MINUTES);
}
}
}
synchronized 키워드를 메소드에 사용해 하나의 쓰레드만 해당 메소드에 접근할 수 있다. 따라서 다수의 쓰레드가 해당 메소드를 동시에 호출하는 경우 첫번째 요청을 처리하는 동안 나머지 요청은 대기 상태에 있게 된다. 그리고 대기 중인 쓰레드는 잠금이 해제될 때까지 대기한다.
블록 레벨의 synchronized
public void selectSeats(SeatDto seatDto, Integer userId) {
String redisKey = String.format("%d_%d_%d", seatDto.getScheduleId(),
seatDto.getSeatGradeId(), seatDto.getSeatNumber());
synchronized (this) {
Boolean selected = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(selected)) {
throw new CustomException(ErrorCode.SEAT_IN_PROGRESS);
} else {
redisTemplate.opsForValue()
.set(redisKey, String.valueOf(userId));
redisTemplate.expire(redisKey, 7, TimeUnit.MINUTES);
}
}
}
블록 레벨 synchronized를 활용해 원자성이 필요한 부분만 임계영역으로 설정할 수 있도록 했다.
방법2. Redis의 Lua Script를 통한 동시성 문제 해결
두 번째 방법은 Redis의 Lua script를 통해서 연산을 Atomic 하게 처리하는 것이다.
Lua script란 Redis 서버에서 실행되는 스크립트로, 여러 Redis 명령을 조합하여 복잡한 작업을 원자적으로 만든다. Redis 서버에 내장된 Lua 인터프리터가 Lua 스크립트를 실행하고 결과를 반환하며 데이터의 일관성과 안전성을 보장하며 Redis 2.6.0부터 사용할 수 있다.
공식 홈페이지에서 Atomic 한 수행을 보장한다고 확실하게 쓰여있다. 즉, 레디스의 lua script는 레디스 내부에서 ‘EVAL’ 명령어로 실행되고,레디스는 싱글쓰레드로 다른 요청(명령)을 동시에 처리할 수 없기 때문에 Atomic이 보장된다.
Redis guarantees the script's atomic execution. While executing the script, all server activities are blocked during its entire runtime. These semantics mean that all of the script's effects either have yet to happen or had already happened.
Redis Lua Script
먼저 Lua Script가 익숙하지 않으니 어떻게 작동되는 것인지 Redis-Cli 에서 어떻게 사용하는지 테스트해봤다.
1. script를 사용하려면 script를 Redis 서버에 저장해야한다. 다음은 Redis 서버에 Key-Value를 저장하는 script이다.
local key = KEYS[1]
local value = ARGV[1]
return redis.call('SET', KEYS[1], ARGV[1]);#Redis의 set을 활용해 Redis에 key-value를 저장한다.
2. SCRIPT LOAD 명령어를 통해 script 를 저장하면 스크립트의 해시값을 반환한다. 그리고 이 해시값은 나중에 호출할때 사용한다.
SCRIPT LOAD "local key = KEYS[1]; local value = ARGV[1]; return redis.call('SET', key, value)"
3. EVALSHA라는 명령어를 이용해 해시값에 실제로 Redis에 저장할 key, value를 전달한다.
EVALSHA 488c32a23b73ac44b15dca4d134dd45c65d8c7f9 1 key1 value1
이제 본격적으로 프로젝트에 적용을 해보자.
@Service
@RequiredArgsConstructor
public class SeatService {
private final SeatMapper seatMapper;
private final RedisTemplate<String, String> redisTemplate;
public void selectSeats(SeatDto seatDto, Integer userId) {
String redisKey = String.format("%d_%d_%d", seatDto.getScheduleId(),
seatDto.getSeatGradeId(), seatDto.getSeatNumber());
Boolean selected = redisTemplate.hasKey(redisKey);
if (Boolean.TRUE.equals(selected)) {
throw new CustomException(ErrorCode.SEAT_IN_PROGRESS);
} else {
redisTemplate.opsForValue()
.set(redisKey, String.valueOf(userId));
redisTemplate.expire(redisKey, 7, TimeUnit.MINUTES);
}
}
}
위의 코드를 Lua Script 로 만들면 다음과 같다.
EVAL "
local redisKey = KEYS[1];
local userId = ARGV[1];
if redis.call('EXISTS', redisKey) == 1 then
return 'SEAT_IN_PROGRESS'
else
redis.call('SET', redisKey, userId)
redis.call('EXPIRE', redisKey, 420)
return 'SUCCESS'
end
" 1 scheduleId_seatGradeId_seatNumber userId
redisKey가 존재하면 SEAT_IN_PROGRESS, redisKey가 존재하지 않으면 7분동안 key-value를 저장하는 내용이다.
실제로 실행해봤을때 잘 수행되는것을 확인할 수 있었다.
이제 스프링 어플리케이션에 Redis Lua Script를 적용해보자 Spring Data Redis에서 RedisTemplate을 이용해서 스크립트를 실행할 수 있도록 기능을 지원해준다.
@Service
@RequiredArgsConstructor
public class SeatService {
private final SeatMapper seatMapper;
private final RedisTemplate<String, String> redisTemplate;
public void selectSeats(SeatDto seatDto, Integer userId) {
String redisKey = String.format("%d_%d_%d", seatDto.getScheduleId(),
seatDto.getSeatGradeId(), seatDto.getSeatNumber());
String script = "local redisKey = KEYS[1]" +
"local userId = ARGV[1]" +
"if redis.call('EXISTS', redisKey) == 1 then" +
" return 'SEAT_IN_PROGRESS'" +
"else" +
" redis.call('SET', redisKey, userId)" +
" redis.call('EXPIRE', redisKey, 420)" +
" return 'SUCCESS'" +
"end";
List<String> keys = Collections.singletonList(redisKey);
String result = redisTemplate.execute(
new DefaultRedisScript<>(script, String.class), keys, String.valueOf(userId));
if ("SEAT_IN_PROGRESS".equals(result)) {
throw new CustomException(ErrorCode.SEAT_IN_PROGRESS);
}
}
}
Redis Lua Script vs Java synchronized 간의 성능 테스트
이제 두 방법이 정상적으로 실행되는 것을 확인했으니 어떤 방법을 채택할지 고민해보았다. 1000번을 요청하는 테스트 코드를 만들어 둘의 시간을 비교하는 코드를 만들어서 성능을 비교해보자.
테스트 코드
1. 루프 전/후로 시작시간, 종료시간을 기록
2. 두 테스트 모두 동일한 조건으로 루프횟수만큼 같은 좌석을 선택하도록 구현
public void test() {
// ExecutorService 사용해 스레드 풀 생성
ExecutorService executor = Executors.newFixedThreadPool(9); //고정된 쓰레드 풀
//시작 시간
long startTime = System.nanoTime();
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
SeatDto seatDto = new SeatDto(i, i, i);
Integer userId = i;
//스레드 풀에 작업 제출
//모든 작업이 쓰레드를 통해 작업 수행
futures.add(executor.submit(() -> {
selectSeats(seatDto, userId);
}));
}
// 모든 작업의 완료를 기다림
for (Future<?> future : futures) {
try {
future.get(); // 연산의 결과를 반환, 모든 작업이 완료될 때까지 기다림
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
// 전체 소요 시간 출력
long lastEndTime = System.nanoTime();
// 전체 소요 시간 계산 (초 단위)
long durationInNano = lastEndTime - startTime;
double durationInSeconds = durationInNano / 1_000_000_000.0;
System.out.println("전체 소요 시간: " + durationInSeconds + "초");
System.out.println("모든 작업이 완료되었습니다.");
}
결과는 다음과 같다.
블럭 레벨의 synchronized 실행 시간
메소드 레벨의 synchronized 실행 시간
Redis의 Lua Script 사용했을때 실행 시간
결론
성능 테스트 결과 synchronized보다 Redis의 Lua script를 사용했을 때 약 23배 정도 더 빠르다는 것을 알 수 있었다. 23배나 차이가 나는 이유를 좀 더 생각해보았다.
synchronized를 사용하면 redis key값을 확인하고, 값을 저장하고, 키의 만료시간을 설정하는 네트워크 콜이 총 3번 일어난다. 호출은 총 1000회에 redis operation 3회이므로 3 x 1000 = 3000회의 순차적인 Redis operation이 발생한다. 따라서 23초로 나눠보면 23초 /3000회= 0.007초 즉 회당 7ms이다. 반면에 redis의 lua script는 네트워크 콜이 1개이므로 1초 /1000회 = 0.001 초이다. 즉 회당 1ms이다.
다음과 같이 성능이 차이가 나는 이유를 추측해보자면 synchronized를 사용하면 락을 획득/반납하는 과정에서 자바 모니터와의 커뮤니케이션 비용이 드는게 큰 이유일 것 같다. 추가로 Redis가 싱글 쓰레드이고 순차적으로 실행되다 보니 락 관리에 드는 비용이 없어서 성능이 더 좋은 것으로 예상된다.
따라서 더 나은 성능을 위해 나는 synchronized가 아닌 Redis의 Lua Script를 사용하여 Atomic한 연산을 처리하기로 했다.
다음 글에서는 내가 만든 로직이 실제로 Atomic한가? 에 대해서 포스팅해보겠다 ~ 😃
-> https://azelhhh.tistory.com/116
또한 자바 모니터가 어떻게 동작하는지도 공부해봐야겠다.
'Spring' 카테고리의 다른 글
nGrinder와 Pinpoint로 성능테스트 및 개선하기 (3) - Connection Pool 크기 조절 (0) | 2024.05.19 |
---|---|
nGrinder와 Pinpoint로 성능테스트 및 개선하기 (2) - 병목지점 파악 (0) | 2024.05.14 |
nGrinder와 Pinpoint로 성능테스트 및 개선하기 (1) - 환경 구축 (0) | 2024.05.10 |
Redis Lua Script 실제로 Atomic할까? (0) | 2024.04.18 |
mockito 테스트 시 파라미터에 무엇을 넣어야할까? (0) | 2024.04.04 |