Spring

Redis Lua Script 실제로 Atomic할까?

그해 2024. 4. 18. 18:52

배경

이전 글에서 Redis 의 Lua Script를 활용해 좌석 선택 기능을 Atomic하게 만들었다. 만들고 나기 의문이 들었다. 내가 만든 기능이 실제로 Atomic한가? 따라서 Atomic한지 테스트해보려고한다.

 

내가 테스트 하려고 하는 방법은 다음과 같다.

 

 

‘읽기 - 쓰기’의 작업 전/후에 로그를 넣어서 같은 좌석을 동시에 다수의 유저가 요청할때 한명만 좌석을 선점하며 동시에 로그가 차례대로 찍히는지 확인할 것이다.

테스트 및 결과

그 전에 Atomic 하게 구현되어 있지 않았을 때 코드를 먼저 테스트해보자.

    public void selectSeats(SeatDto seatDto, int 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, 8, TimeUnit.MINUTES);
        }
    }

 

다음과 같이 ‘서로 다른 유저가 같은 좌석을 구매’한다고 가정하고 테스트 코드를 만들었다.

@Getter
@RequiredArgsConstructor
public class SeatDto {

    private final Integer scheduleId;
    private final Integer seatGradeId;
    private final Integer seatNumber;
}

public void test() {
    int numRequests = 10;

    // 쓰레드 풀 생성
    ExecutorService executorService = Executors.newFixedThreadPool(numRequests);

    // 여러 사용자가 같은 좌석을 선택하도록 요청을 보냄
    for (int i = 0; i < numRequests; i++) {
        final int userId = i + 1;
        final SeatDto seatDto = new SeatDto(1, 1, 1);

        executorService.submit(() -> {
            try {
                // 좌석 선택
                selectSeats(seatDto, userId);
            } catch (CustomException e) {
                System.out.println(
                    "User " + userId + " encountered an error: " + e.getMessage());
            }
        });
    }
    // 스레드 풀 종료
    executorService.shutdown();
}

 

Redis의 Monitor로 실행명령어를 확인해보자.

1713753984.532783 [0 127.0.0.1:50569] "EXISTS" "1_1_1"
1713753984.532827 [0 127.0.0.1:50569] "EXISTS" "1_1_1"
1713753984.532831 [0 127.0.0.1:50569] "EXISTS" "1_1_1"

.
.
.
1713753984.536772 [0 127.0.0.1:50569] "SET" "1_1_1" "6" //유저 6
1713753984.536955 [0 127.0.0.1:50569] "SET" "1_1_1" "10" //유저 10 
1713753984.537118 [0 127.0.0.1:50569] "SET" "1_1_1" "9"

Redis의 monitor를 통해 Redis의 요청되는 모든 명령을 확인한 결과, 단순히 ‘읽기 - 쓰기’ 작업이 Atomic하지 않아서 마지막으로 요청한 유저가 좌석을 선점했다는 사실을 알 수 있다.

 

 

그렇다면 Redis의 Lua Script를 통해 Atomic하게 만들었을때 결과는 어떨까?

    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]" +
            "redis.call('SET', 'log:before', 'Before setting value in key ' .. redisKey)" +
            "if redis.call('EXISTS', redisKey) == 1 then" +
            "    return 'SEAT_IN_PROGRESS'" +
            "else" +
            "    redis.call('SET', redisKey, userId)" +
            "    redis.call('EXPIRE', redisKey, 420)" +
            "    redis.call('SET', 'log:after', 'After setting value in key ' .. redisKey)" +
            "    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 Monitor로 확인하면

1713754691.481674 [0 127.0.0.1:51034] "EVALSHA" "a5d7c5cd2d6c9f6e1620df89bf25df76f9b57089" "1" "1_1_1" "2" //유저 2가 lua script 실행
1713754691.481786 [0 lua] "SET" "log:before" "Before setting value in key 1_1_1" //Before 로그
1713754691.481794 [0 lua] "EXISTS" "1_1_1" //좌석 선점했는지 확인
1713754691.481799 [0 lua] "SET" "1_1_1" "2" //좌석이 선점되어있지 않기때문에 좌석 선점
1713754691.481809 [0 lua] "EXPIRE" "1_1_1" "420" //TTL
1713754691.481814 [0 lua] "SET" "log:after" "After setting value in key 1_1_1" //After 로그

1713754691.481822 [0 127.0.0.1:51034] "EVALSHA" "a5d7c5cd2d6c9f6e1620df89bf25df76f9b57089" "1" "1_1_1" "1" //유저 1이 lua script 실행
1713754691.481830 [0 lua] "SET" "log:before" "Before setting value in key 1_1_1"//Before 로그
1713754691.481834 [0 lua] "EXISTS" "1_1_1" //좌석 선점되었는지 확인 선점되어있기때문에 끝.

1713754691.481840 [0 127.0.0.1:51034] "EVALSHA" "a5d7c5cd2d6c9f6e1620df89bf25df76f9b57089" "1" "1_1_1" "3"
1713754691.481846 [0 lua] "SET" "log:before" "Before setting value in key 1_1_1"
1713754691.481850 [0 lua] "EXISTS" "1_1_1"

1713754691.481854 [0 127.0.0.1:51034] "EVALSHA" "a5d7c5cd2d6c9f6e1620df89bf25df76f9b57089" "1" "1_1_1" "4"
1713754691.481860 [0 lua] "SET" "log:before" "Before setting value in key 1_1_1"
1713754691.481866 [0 lua] "EXISTS" "1_1_1"

1713754691.481870 [0 127.0.0.1:51034] "EVALSHA" "a5d7c5cd2d6c9f6e1620df89bf25df76f9b57089" "1" "1_1_1" "5"
1713754691.481876 [0 lua] "SET" "log:before" "Before setting value in key 1_1_1"
1713754691.481880 [0 lua] "EXISTS" "1_1_1"

다음과 같이 처음에 좌석을 선점한 유저의 로그가 Before - After 형태로 잘 찍히고, 다음 좌석 선점을 시도한 유저들은 실패했으므로 Before 로그만 찍혔음을 확인할 수 있다.