Home 대용량 트래픽 대응 (선착순 이벤트 응모 시스템 구현기)
Post
Cancel

대용량 트래픽 대응 (선착순 이벤트 응모 시스템 구현기)

이번 프로젝트 선택 요구사항에 대용량 트래픽을 다뤄보는 요구사항이 있었다.

선착순 응모 시스템을 구현해야 한다. 선택사항이었지만 이런 상황을 가정해서 구현해본 경험이 없었고, 시간도 충분했기 때문에 하기로 결정했다.

요구사항은 아래와 같다.

  • 자녀 성향을 응모하는 100명 한정 선착순 이벤트 페이지 시스템을 구현한다.
    • 응모 페이지는 회원의 이름과 전화번호를 받는다.
    • 중복으로 응모는 되지 않는다.
    • 선착순 응모 페이지는 매일 오후 1시 정각에 가장 높은 트래픽을 받는다.
      • 1분에 10만 요청을 10분간 받는 것을 가정.
    • 응모 결과는 다음날 오후 1시에 발표한다.

구현했던 과정을 정리해보고자 한다.

구현방법 1 (대기열 방식)

미리 정리하자면 이 요구사항을 해결하기 위해 두 가지 방법으로 구현을 하게 됐다. 결과적으로 두 번째 구현 방식이 더 나은 성능을 보여 최종적으로 채택하게 되었다.

우선 첫 번째 구현방식의 구현 과정을 보자.

RDB를 이용한 방식

우선 요구사항을 보면, 선착순으로 응모한 100명을 정확히 구하면 되고, 임의의 시간에 특정 최대 어느정도의 트래픽이 몰릴 수 있어 그 부하를 견뎌낼 수 있을만한 로직을 작성하면 된다.

우선 처음에는 단순하게 RDB를 통해 이벤트 데이터들을 처리하는 방식을 채택했다.

  • 사용자 요청 시 User와 Event 정보를 조회하여 중복 참여 등의 유효성 검사 진행
    • Repository를 활용해 RDB와 직접 통신
  • 유효성 검사에 문제가 없다면 이벤트에 참여했다는 Insert문 실행

당연하게도 기능 자체는 정상적으로 동작한다.

하지만 이렇게 RDB를 직접 활용했을 때 대용량 트래픽 환경, 즉 동시에 오는 요청이 많아진다면 여러 문제가 생길 수 있다.

  • 트랜잭션 잠금
    • 동시 요청이 많아졌을 때 데이터베이스에서 트랜잭션 잠금 문제 발생 가능
  • 응답 지연
    • 부하가 몰리는 상황에서 응답 자체가 매우 느려지는 현상 발생 가능
  • 데이터베이스 부하
    • 대량 트래픽을 견뎌내기에 RDB에 접근하는 횟수가 과다하다. 실제 운영 환경이라면 데이터베이스 서버가 다운될 수 있다.

Redis 활용

RDB 방식의 한계는 인지하고 있었고, 이를 해결하기 위해 여러 자료들을 찾아보았다.

보통 이러한 문제가 있을 때 Redis를 도입하거나, 더 나아가서는 카프카같은 메시지 브로커를 활용해 대용량 트래픽 환경에 대응한다.

그런데 멘토분들께서 현재 요구사항의 트래픽정도는 Redis 정도만 활용해도 충분히 감당 가능한 트래픽이기 때문에 굳이 메시지 브로커까지 도입할 필요는 없다고 하셨다.

따라서 우리는 Redis를 활용해 대응해보기로 했다. Redis를 활용했을 때 아래와 같은 장점들이 있다.

  • 빠른 읽기/쓰기 성능
    • 인메모리 기반 저장소이기 때문에 RDB와 비교해 훨씬 빠른 처리가 가능하다.
  • 중복 요청 처리가 간편하다.
    • RDB를 활용했을 때 직접 DB를 조회하여 중복을 처리했었다.
    • Redis를 활용한다면 ZSet, Set 등의 자료구조를 활용해 중복 확인 로직을 간소화할 수 있다.
  • RDB 대비 많은 요청 처리 가능
    • 트랜잭션 잠금이나 디스크 I/O 문제 없이 초당 수십만건의 요청을 처리할 수 있다.

이러한 장점을 바탕으로 아래와 같이 아키텍처를 설계했다.

  • 이벤트 응모 요청
    • 사용자가 API 서버로 JWT 토큰, 이름, 전화번호, 이벤트 ID와 같은 필요 데이터를 포함해 요청을 보낸다.
  • 중복 검사
    • Redis의 Set 자료구조를 이용해 중복 요청을 확인한다.
    • Key를 생성해 (전화번호 + 이름) 조합을 저장하고 이미 저장된 값이라면 중복 요청으로 간주하고 차단한다.
  • 대기열 관리
    • Redis의 Zset 자료구조를 활용해 대기열을 관리한다.
    • 요청 시각을 score로 활용해 userId를 저장한다. 어떤 유저가 어떤 시간에 대기열에 들어왔는지 알 수 있다.
    • 이 때 만약 대기열의 크기인 100을 초과한다면 그 요청은 즉시 거부한다.
  • 당첨자 저장
    • 대기열이 100명 가득찬 경우 대기열에 들어온 100명에 대한 정보를 Redis에 따로 저장해둔다.
    • 이후 Redis에 저장되어있는 당첨자 데이터를 스케줄러를 활용하여 트래픽이 몰리지 않는 시간대에 RDB에 저장한다.

구현1 (Redis 도입)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
@Slf4j
@Service
public class EventService {
    private final JoinEventRepository joinEventRepository;
    private final UserRepository userRepository;
    private final EventRepository eventRepository;
    private final RedisTemplate<String, String> template;
    private static final int QUEUE_LIMIT = 100; // 대기열 크기
    private static final int ISSUE_BATCH_SIZE = 10; // 한 번에 발급할 쿠폰 수

    public void insertJoinEvent(Long userId, EventRequestDto eventRequestDto) {
        // 1. phoneNumber + username 조합으로 redis 에서 중복 확인을 한다.
        // 1-1 고유키 생성
        Long eventId = eventRequestDto.getEventId();
        String eventKey = "event"+eventId; // 이벤트 참여 요청 key
        String uniqueKey = eventRequestDto.getPhoneNumber() + eventRequestDto.getUsername(); // 전화번호 + 이름 key
        // 1-2 대기열 크기 확인 (불필요한 로직 개선)
        Long issuedJoinEventCount = template.opsForValue().increment("issuedJoinEvent", 1);
        if(issuedJoinEventCount == null || issuedJoinEventCount > QUEUE_LIMIT) {
            log.info("이벤트 참여 대기열이 가득 찼습니다.");
            template.opsForValue().decrement("issuedJoinEvent");
            return;
        }

        // 1-2 중복 확인 (phoneNumber + username)
        if (Boolean.TRUE.equals(template.opsForSet().isMember(eventKey + ":duplicates", uniqueKey))) {
            log.info("이미 발급 요청이 있습니다 (phoneNumber & username): {}", uniqueKey);
            template.opsForValue().decrement("issuedJoinEvent");
            return;
        }

        // 1-3 중복 확인 userId
        if (template.opsForZSet().score(eventKey, String.valueOf(userId)) != null){
            log.info("이미 발급 요청이 있습니다 (userId): {}", userId);
            template.opsForValue().decrement("issuedJoinEvent");
            return;
        }

        // 2. 중복 확인 후 대기열에 등록한다.
        if (issuedJoinEventCount <= QUEUE_LIMIT) {
            long score = System.currentTimeMillis();
            template.opsForZSet().add(eventKey, String.valueOf(userId), score);
            template.opsForSet().add(eventKey + ":duplicates", uniqueKey);
            log.info("요청이 접수되었습니다. userId: {}", userId);
        }
    }

    @Scheduled(fixedDelay = 10000) // 10초
    public void processQueue() {
        Set<Long> eventIds = eventRepository.findAllEventIds();
        for (Long eventId : eventIds) {
            String eventKey = "event"+eventId;
            System.out.println(eventKey);
            Set<String> usersId = template.opsForZSet().range(eventKey, 0, ISSUE_BATCH_SIZE - 1); // 상위 10명
            if (usersId != null && !usersId.isEmpty()) {
                for (String userIdStr : usersId) {
                    Long userId = Long.valueOf(userIdStr);
                    Double score = template.opsForZSet().score(eventKey, userIdStr); // score 가져오기
                    // INCR 재고 관리
                    Long issuedWinEventCount = template.opsForValue().increment("issuedWinEvent", 1);
                    log.info("현재 발급된 쿠폰 수: {}", issuedWinEventCount);
                    // redis winners key 에 당첨자 저장
                    if (issuedWinEventCount <= 100) {
                        if (score != null) {
                            template.opsForZSet().add("winners", String.valueOf(userId), score); // Set 으로 해도됨
                            log.info("쿠폰이 성공적으로 발급되었습니다. userId: {}", userId);
                        }
                    } else {
                        log.info("최대 쿠폰 수에 도달했습니다. 발급을 중지합니다.");
                        return;
                    }
                }
                // 발급된 쿠폰을 Redis 에서 제거
                template.opsForZSet().remove(eventKey, usersId.toArray());
            }
        }
    }

    @Scheduled(cron = "0 23 10 * * ?")
    public void saveWinnersToDatabase() {
        Set<String> winners = template.opsForZSet().range("winners", 0, -1); // 모든 당첨자 가져오기
        if (winners != null) {
            for (String winnerIdStr : winners) {
                Long winnerId = Long.valueOf(winnerIdStr);
                saveToDatabase(winnerId);
            }
        }
    }

    private void saveToDatabase(Long userId) {
        User user = userRepository.findById(userId).orElse(null);
        Event event = eventRepository.findById(1L).orElse(null);
        if (user != null && event != null) {
            JoinEvent joinEvent = JoinEvent.builder()
                    .user(user)
                    .event(event)
                    .name(user.getUsername())
                    .phoneNumber(user.getPhoneNumber())
                    .createdAt(LocalDateTime.now())
                    .isWin(true)
                    .build();
            joinEventRepository.save(joinEvent);
        }
    }
}
  • increment()
    • 숫자 값을 증가시키는 간단하고 빠른 Redis 명령어.
    • 응모 요청을 받을 때 대기열의 크기를 증가시켜 현재 대기열에 몇 명이 존재하는지 효율적으로 추적할 수 있다.
    • increment는 연산이 매우 빠르다.
    • 또한 Redis는 단일 스레드로 동작하는데 increment 연산이 원자적이기 때문에 동시성 문제가 없다. 별도의 트랜잭션 관리 없이 안전하게 카운팅이 가능하다.
  • opsForSet()
    • Redis의 SET 자료구조를 통해 동일 사용자가 중복된 요청을 보내는 것을 방지한다.
    • RDB를 통해 중복 조회 쿼리를 실행하는 것에 비교해 훨씬 빠르다.
      • 내부적으로 해시 테이블을 사용해 Set을 구현하고 값의 존재 여부를 빠르게 파악한다. O(1)에 가까운 복잡도.
    • Redis의 특성을 활용해 요청당 단일 명령어로 처리할 수 있다.
      • Redis는 단일 스레드 이벤트 루프로 동작한다.
      • 단일 스레드에서 순차적으로 명령어가 실행되기 때문에 명령어의 충돌이나 동시성 문제가 발생하지 않는다.
  • opsForZSet()
    • ZSet 자료구조를 활용해 선착순을 관리한다.
    • ZSet은 자동으로 정렬된 상태로 유지된다. 따라서 대기열에서 상위 사용자들을 손쉽게 추출할 수 있고 복잡한 정렬 쿼리가 따로 필요하지 않다.
      • 내부적으로 Skip List라는 자료구조를 사용해 정렬과 검색이 빠르다. O(log n)

Error가 나지 않고 CPU 사용량을 최대 60%대로 유지하며 부하를 받아낼 수는 있었다.

문제점

물론 로컬에서 진행한 테스트라 운영 환경을 가정했을 때 비교적 의미가 없을 수 있지만 CPU 사용량이 높았다.

또한 평균 응답속도가 0.9초로 괜찮았지만 최대 응답속도가 6초에 달했고 이는 일부 유저는 좋지 않은 사용 경험을 갖게 된다는 뜻이 될 수 있었다.

그리고 가장 큰 문제는 User1부터 User100까지의 선착순 100명 안에 들도록 하는 테스트를 했는데, 실제로는 부하가 몰렸을 때 1 ~ 100 중 일부 값이 누락되고 선착순 100명에 User101, User111과 같은 유저가 들어와있다는 문제가 있었다.

구현 2 (루아 스크립트 사용)

성능과 로직에 일부 문제가 있다고 판단해 루아 스크립트를 사용하게 되었다. 더해 불필요한 로직들을 제거했다.

우선 루아 스크립트를 사용했을 때 아래와 같은 장점들을 가질 수 있다.

  • 원자성 보장
    • Redis 명령어는 기본적으로 하나씩 실행되어 원자성을 보장한다. 하지만 여러 명령어를 연속적으로 호출했을 때 중간에 다른 요청이 개입할 가능성이 존재한다.
    • 루아 스크립트를 사용하게 되면 여러 Redis 명령어를 단일 트랜잭션으로 실행한다. 따라서 모든 명령어가 하나의 작업으로 간주되기 때문에 명령어가 중간에 개입할 수 없다.
      • INCR, ZADD, SADD 등이 분리된 상태에서 동시성 문제로 대기열 순서가 꼬일 수 있는 문제가 방지될 수 있다.
  • 네트워크 왕복 감소 (성능 향상 기대)
    • Redis 명령어를 호출할 때 클라이언트와 Redis간 네트워크 통신이 발생한다.
    • 루아 스크립트를 사용하면 여러 명령여를 한 번의 요청으로 실행하기 때문에 네트워크 왕복 비용을 줄일 수 있다.

따라서 루아 스크립트를 사용해 기존의 일종의 동시성 문제와 성능 문제를 개선해보고자 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@Slf4j
@Service
@RequiredArgsConstructor
public class EventService {

    private final JoinEventRepository joinEventRepository;
    private final UserRepository userRepository;
    private final EventRepository eventRepository;
    private final RedisTemplate<String, String> template;

    private static final int QUEUE_LIMIT = 100; // 대기열 크기
    private static final String WINNERS_KEY = "winners";
    private static final String ISSUED_COUNT_KEY = "issuedJoinEvent";

    @Transactional
    public void insertJoinEvent(Long userId, EventRequestDto eventRequestDto) {
        // Lua Script
        String luaScript = """
                local eventId = ARGV[1]
                local userId = ARGV[2]
                local uniqueKey = ARGV[3]
                local queueLimit = tonumber(ARGV[4])
                local eventKey = 'event' .. eventId
                local duplicatesKey = eventKey .. ':duplicates'
                local issuedJoinEventCount = tonumber(redis.call('GET', 'issuedJoinEvent') or '0') -- nil이면 '0'으로
              
               if issuedJoinEventCount >= queueLimit then
                   return 'QUEUE_FULL'
               end
                   

                if redis.call('SISMEMBER', duplicatesKey, uniqueKey) == 1 then
                    return 'DUPLICATE_REQUEST'
                end

                if redis.call('ZSCORE', eventKey, userId) ~= false then
                    return 'DUPLICATE_USER_ID'
                end

                local score = redis.call('TIME')[1] * 1000 + redis.call('TIME')[2] / 1000
                redis.call('ZADD', eventKey, score, userId) -- ZSet에 데이터 추가
                redis.call('SADD', duplicatesKey, uniqueKey)  -- Set에 데이터 추가
                redis.call('INCR', 'issuedJoinEvent')

                if issuedJoinEventCount + 1 >= queueLimit then
                    return 'PROCESS_QUEUE'
                end
                return 'SUCCESS'
                """;

        Long eventId = eventRequestDto.getEventId();
        String uniqueKey = eventRequestDto.getPhoneNumber() + eventRequestDto.getUsername();
        String[] args = {String.valueOf(eventId), String.valueOf(userId), uniqueKey, String.valueOf(QUEUE_LIMIT)};

        // Lua 스크립트 실행
        String result = template.execute((RedisCallback<String>) redisConnection -> {
            byte[][] argsBytes = new byte[args.length][];
            for (int i = 0; i < args.length; i++) {
                argsBytes[i] = args[i].getBytes(StandardCharsets.UTF_8);
            }

            // Lua 스크립트 실행
            byte[] resultBytes = redisConnection.eval(
                    luaScript.getBytes(),
                    ReturnType.VALUE,
                    3,
                    argsBytes
            );

            return resultBytes != null ? new String(resultBytes, StandardCharsets.UTF_8) : null; // 결과를 문자열로 변환
        });

        switch (Objects.requireNonNull(result)) {
            case "QUEUE_FULL":
                log.info("이벤트 참여 대기열이 가득 찼습니다.");
                return;
            case "DUPLICATE_REQUEST":
                log.info("이미 발급 요청이 있습니다 (phoneNumber & username): {}", uniqueKey);
                return;
            case "DUPLICATE_USER_ID":
                log.info("이미 발급 요청이 있습니다 (userId): {}", userId);
                return;
            case "PROCESS_QUEUE":
                processQueue(eventId);
                break;
            case "SUCCESS":
                Long newCount = template.opsForValue().increment(ISSUED_COUNT_KEY, 1);
                log.info("요청이 접수되었습니다. userId: {}, 현재 대기열: {}", userId, newCount);
                break;
        }
    }

    public void processQueue(Long eventId) {
        String eventKey = "event" + eventId;
        Set<String> usersId = template.opsForZSet().range(eventKey, 0, QUEUE_LIMIT - 1);

        if (usersId != null && !usersId.isEmpty()) {
            for (String userIdStr : usersId) {
                Long userId = Long.valueOf(userIdStr);
                template.opsForSet().add(WINNERS_KEY, String.valueOf(userId)); // Set 으로 해도됨
                log.info("쿠폰이 성공적으로 발급되었습니다. userId: {}", userId);
            }
            // 발급된 쿠폰을 Redis 에서 제거
            template.opsForZSet().remove(eventKey, usersId.toArray());
        }
    }

    // 데이터베이스에 당첨자 저장
    @Scheduled(cron = "0 23 10 * * ?")
    public void saveWinnersToDatabase() {
        Set<String> winners = template.opsForSet().members(WINNERS_KEY);
        log.info("Winners: {}", winners);
        if (winners != null && !winners.isEmpty()) {
            for (String winnerIdStr : winners) {
                Long winnerId = Long.valueOf(winnerIdStr);
                saveToDatabase(winnerId);
            }
        }
    }

    private void saveToDatabase(Long userId) {
        User user = userRepository.findById(userId).orElse(null);
        Event event = eventRepository.findById(1L).orElse(null);

        if (user != null && event != null) {
            JoinEvent joinEvent = JoinEvent.builder()
                    .user(user)
                    .event(event)
                    .name(user.getUsername())
                    .phoneNumber(user.getPhoneNumber())
                    .createdAt(LocalDateTime.now())
                    .isWin(true)
                    .build();

            joinEventRepository.save(joinEvent);
        }
    }
}
  • 로직 변경
    • 기존 로직에서 increment로 대기열 크기를 증가시키고 isMember로 중복 여부를 확인 후 ZSet에 사용자를 추가했다.
      • 명령어를 별도로 실행했다.
    • 루아 스크립트를 사용해 대기열 크기 확인, 중복 요청 확인, 사용자 추가 작업을 하나의 요청으로 처리한다.
    • SISMEMBERZSCORE를 하나의 스크립트 내에서 확인하고, 중복일 경우 즉시 종료한다.
    • 대기열 크기 확인(INCR)과 사용자 추가(ZADD, SADD)를 하나의 트랜잭션으로 묶어 처리한다.

그런데 CPU 사용량에서 오히려 불안한 모습을 보였다. 최대 99.1%로 사실상 실패한 지점도 생긴다.

문제점

이 스크립트 구조는 여러 조건문과 반복문을 포함하고 있어 성능 문제가 발생한 것으로 추측된다.

ZRANGE로 당첨자를 가져오고 SADD로 추가하는 반복문 부분에서 큰 부하가 일어날 수 있는데, ZREM (ZSetRemove) 명령에서 일종의 역캡슐화인 unpack 과정이 일어나는데 그 때 부하가 심하다고 한다.

구현 3 (루아 스크립트 개선)

ZREM 명령어는 ZSet의 여러 요소를 한 번에 제거할 때 발생하는 unpack 과정에서 부하가 심한 것이라고 한다. 따라서 이 부분을 한 번에 삭제하는 방식 대신 개별 삭제로 바꾸어 개선했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@Slf4j
@Service
@RequiredArgsConstructor
public class EventService {

    private final JoinEventRepository joinEventRepository;
    private final UserRepository userRepository;
    private final EventRepository eventRepository;
    private final RedisTemplate<String, String> template;

    private static final int QUEUE_LIMIT = 100; // 대기열 크기

    public void insertJoinEvent(Long userId, EventRequestDto eventRequestDto) {
        String luaScript = """
            local eventId = ARGV[1]
            local userId = ARGV[2]
            local uniqueKey = ARGV[3]
            local queueLimit = tonumber(ARGV[4])
            local score = tonumber(ARGV[5])
            local eventKey = 'event' .. eventId
            local duplicatesKey = eventKey .. ':duplicates'
            local winnersKey = 'winners'
            local issuedJoinEventCount = tonumber(redis.call('GET', 'issuedJoinEvent') or '0')
              
            if issuedJoinEventCount >= queueLimit then
                return 'QUEUE_FULL'
            end

            if redis.call('SISMEMBER', duplicatesKey, uniqueKey) == 1 then
                return 'DUPLICATE_REQUEST'
            end

            if redis.call('ZSCORE', eventKey, userId) ~= false then
                return 'DUPLICATE_USER_ID'
            end

            redis.call('ZADD', eventKey, score, userId)
            redis.call('SADD', duplicatesKey, uniqueKey)
            redis.call('INCR', 'issuedJoinEvent')
            return 'SUCCESS'
            """;

        Long eventId = eventRequestDto.getEventId();
        String uniqueKey = eventRequestDto.getPhoneNumber() + eventRequestDto.getUsername();
        long score = System.currentTimeMillis();
        String[] args = {String.valueOf(eventId), String.valueOf(userId), uniqueKey, String.valueOf(QUEUE_LIMIT), String.valueOf(score)};

        // Lua 스크립트 실행
        String result = template.execute((RedisCallback<String>) redisConnection -> {
            byte[][] argsBytes = new byte[args.length][];
            for (int i = 0; i < args.length; i++) {
                argsBytes[i] = args[i].getBytes(StandardCharsets.UTF_8);
            }

            byte[] resultBytes = redisConnection.eval(
                    luaScript.getBytes(StandardCharsets.UTF_8),
                    ReturnType.VALUE,
                    0,
                    argsBytes
            );

            return resultBytes != null ? new String(resultBytes, StandardCharsets.UTF_8) : null;
        });

        // `QUEUE_FULL` 상태일 때 `processQueue`를 바로 호출
        if ("QUEUE_FULL".equals(result)) {
            processQueue(eventId);
        } else {
            switch (Objects.requireNonNull(result)) {
                case "DUPLICATE_REQUEST":
                    log.info("이미 발급 요청이 있습니다 (phoneNumber & username): {}", uniqueKey);
                    break;
                case "DUPLICATE_USER_ID":
                    log.info("이미 발급 요청이 있습니다 (userId): {}", userId);
                    break;
                case "SUCCESS":
                    log.info("요청이 접수되었습니다. userId: {}", userId);
                    break;
            }
        }
    }

    public void processQueue(Long eventId) {
        String eventKey = "event" + eventId;
        Set<String> usersId = template.opsForZSet().range(eventKey, 0, QUEUE_LIMIT - 1);

        if (usersId != null && !usersId.isEmpty()) {
            for (String userIdStr : usersId) {
                Long userId = Long.valueOf(userIdStr);
                template.opsForSet().add("winners", String.valueOf(userId));
                log.info("쿠폰이 성공적으로 발급되었습니다. userId: {}", userId);
                template.opsForZSet().remove(eventKey, userIdStr);
            }
        }
    }
    
	//...
}
  • ZREM 대기열 제거 작업을 processQueue에 집중해 역할을 분리한다. 이를 통해 불필요한 ZSet 연산을 최소화할 수 있다.
  • QUEUE_FULL 상태일 시 즉시 processQueue를 호출한다. 기존에 바로 다음 로직으로 넘어가지 않는 비효율적인 부분을 개선했다. 대기열이 가득 찬 상황에서 사용자 응답 시간을 단축할 수 있다.

루아 스크립트를 사용하고, 일부 로직을 개선했을 때 위와 같은 성능 향상을 볼 수 있었다.

  • 평균 응답 속도 89.5% 개선
  • 최대 응답 속도 20.9% 개선
  • 95% Line 84.1% 개선

문제점

그러나 선착순이 순서대로 들어가지 않는 문제가 여전히 있었다.

구현 4 (분산락 도입)

여전히 동시성 문제가 남아있었기 때문에 분산락을 도입해서 선착순을 보장해보고자 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public class EventService {
    private final UserRepository userRepository;
    private final EventRepository eventRepository;
    private final RedisTemplate<String, String> template;
    private final RedissonClient redissonClient; // RedissonClient 추가

    private static final int QUEUE_LIMIT = 100; // 대기열 크기

    @Transactional
    public void insertJoinEvent(Long userId, EventRequestDto eventRequestDto) {
        // Redisson 락 추가
        RLock lock = redissonClient.getLock("eventLock_" + eventRequestDto.getEventId());
        String luaScript = """
                local eventId = ARGV[1]
                local userId = ARGV[2]
                local uniqueKey = ARGV[3]
                local queueLimit = tonumber(ARGV[4])
                local score = tonumber(ARGV[5])
                local eventKey = 'event' .. eventId
                local duplicatesKey = eventKey .. ':duplicates'
                local winnersKey = 'winners'
                local issuedJoinEventCount = tonumber(redis.call('GET', 'issuedJoinEvent') or '0') -- nil이면 '0'으로
                  
                if issuedJoinEventCount >= queueLimit then
                    return 'QUEUE_FULL'
                end
                if redis.call('SISMEMBER', duplicatesKey, uniqueKey) == 1 then
                    return 'DUPLICATE_REQUEST'
                end
                if redis.call('ZSCORE', eventKey, userId) ~= false then
                    return 'DUPLICATE_USER_ID'
                end
                redis.call('ZADD', eventKey, score, userId) -- ZSet에 데이터 추가
                redis.call('SADD', duplicatesKey, uniqueKey)  -- Set에 데이터 추가
                redis.call('INCR', 'issuedJoinEvent')
                return 'SUCCESS'
                """;

        Long eventId = eventRequestDto.getEventId();
        String uniqueKey = eventRequestDto.getPhoneNumber() + eventRequestDto.getUsername();
        long score = System.currentTimeMillis(); // 타임스탬프를 Java에서 생성
        String[] args = {String.valueOf(eventId), String.valueOf(userId), uniqueKey, String.valueOf(QUEUE_LIMIT),
                String.valueOf(score)};
        // Lua 스크립트 실행
        String result = template.execute((RedisCallback<String>) redisConnection -> {
            byte[][] argsBytes = new byte[args.length][];
            for (int i = 0; i < args.length; i++) {
                argsBytes[i] = args[i].getBytes(StandardCharsets.UTF_8);
            }

            byte[] resultBytes = redisConnection.eval(
                    luaScript.getBytes(StandardCharsets.UTF_8),
                    ReturnType.VALUE,
                    0,
                    argsBytes
            );
            return resultBytes != null ? new String(resultBytes, StandardCharsets.UTF_8) : null;
        });
        // 분산 락으로 `QUEUE_FULL` 상태에서만 락을 걸고 처리
        if ("QUEUE_FULL".equals(result)) {
            try {
                if (lock.tryLock()) { // 락을 획득한 경우에만 실행
                    processQueue(eventId); // `processQueue`로 대기열 처리
                }
            } catch (Exception e) {
                log.error("QUEUE_FULL 상태에서 processQueue 처리 실패", e);
            } finally {
                // 현재 스레드가 락을 가지고 있는 경우에만 unlock 호출
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } else {
            switch (Objects.requireNonNull(result)) {
                case "DUPLICATE_REQUEST":
                    log.info("이미 발급 요청이 있습니다 (phoneNumber & username): {}", uniqueKey);
                    break;
                case "DUPLICATE_USER_ID":
                    log.info("이미 발급 요청이 있습니다 (userId): {}", userId);
                    break;
                case "SUCCESS":
                    log.info("요청이 접수되었습니다. userId: {}", userId);
                    break;
            }
        }
    }
    //...
}

분산락을 사용했을 때 결과적으로 온전한 선착순 100명을 뽑아낼 수는 있었다.

하지만 보다시피 응답속도도 매우 느려지고, CPU 사용률도 90%를 넘으면서 사실상 실패한 결과를 보여주고 있다.

락을 획득하고 해제하는 과정에 큰 오버헤드가 발생하기 때문에 가용성에 문제가 생긴 것이다.

멘토님 피드백

분산락을 쓰면서 부하를 받아내지 못하는 환경이라면, 굳이 쓸 필요가 없을 수 있다.

정합성과 가용성에 대해 고민을 해봐야 하는데, 순서의 보장과 성능은 트레이드 오프가 있을 수 밖에 없다.

상황마다 다를 수 있지만 보통 가용성에 더 집중해야 한다. 결제와 같은 상황이 아니기 때문에 가용성을 위함이라면 정합성을 일부 포기할 수 있는 것이다.

또한 구현 목표 자체가 가용성에 집중되어 있는 과제였기 때문에 가용성을 우선하기로 결정했다.

구현 방법 2

첫 번째 구현 방식의 구조로는 많은 시도를 해보았을 때 성능이 크게 개선된 방법이 없었다.

  • 평균 응답 속도 0.5초 이하
  • 최소 응답 속도 0.1초 이하
  • 최대 응답 속도 1초 이하
  • 95% Line 0.8초 이하
  • CPU 사용량 30% ~ 50% 유지

목표치를 위와 같이 잡았다. 이렇게 목표치를 잡은 이유는 일반적으로 이정도가 유저의 경험 측면에서 부드럽고 일관된 응답을 받을 수 있을 것이라고 생각했기 때문이다.

결과적으로 기존 구현 방식에서는 목표를 만족하는 방법이 나오지 않았다.

따라서 다른 구현 방식으로 선착순 이벤트 응모 로직을 구현하기로 했다.

두 번째 구현 방법은 티켓을 미리 발급해놓는 구조이다. 이 방법은 프론트와 일부 시나리오를 맞추어 가용성을 최대화하는 방식이다.

실제로 현업에서 서버의 가용성을 위해 클라이언트와 조율해 그 부담을 분담하는 방식을 채택하는 경우도 있다고 들었기 때문이다.

구조는 아래와 같다.

  • 스케줄러를 통해 이벤트 시작 전 티켓 100개를 미리 발급해놓는다. 그리고 이벤트의 시작을 세팅한다.
  • 티켓은 List 자료구조에 저장된다. 발급은 pop을 통해 진행한다. Li앞뒤에서 데이터를 빠르게 추가하고 꺼낼 수 있기 때문에 성능상 이점이 있다.
  • 유저가 응모하게 되면 티켓이 남아있을 경우 티켓을 발급한다. 이 때 중복검사도 함께 진행한다.
  • 티켓을 발급받은 유저의 경우 실제 응모가 가능한 페이지로 이동할 수 있다.
  • 티켓을 발급받지 못한 경우 가짜 응모만 하게 된다. 실제 응모는 이름과 전화번호를 입력받아 이루어지는데 티켓을 발급받지 못하는 경우에는 실제 응모를 할 수 있는 페이지에 접근할 수 없다.
  • 만약 티켓을 발급받은 유저가 실제 응모 페이지에서 응모를 하지 않거나, 화면을 꺼버리는 경우가 있을 수 있기 때문에 티켓에 TTL을 설정해 티켓을 되돌려 티켓 100장이 온전히 사용될 수 있도록 구성한다.

응모에 성공한 경우 Redis에 저장이 되고 비슷하게 스케줄러를 이용해 그 데이터를 추후 저장하게 된다.

실제 페이지 구조를 보면 처음 배너에 들어가서 응모할래요 버튼을 누르면 실상은 티켓을 발급받게 되는 것이다.

만약 티켓을 발급받지 못하면 단순하게 응모 완료 팝업이 뜨게 되고, 티켓을 발급받은 유저라면 실제 응모 페이지로 접근하게 된다.

이 방식을 사용하면 큰 부하를 견뎌내야 하는 것은 첫 이벤트 응모이다. 그런데 첫 이벤트 응모는 단순히 티켓을 발급하거나 발급하지 않는 것이 전부이다.

실제 응모 로직은 뒷 페이지에서 티켓을 발급받은 100명의 유저만이 가능하기 때문에 부하가 몰릴 때 여러 로직이 동작하지 않을 수 있어 큰 부하를 받아낼 수 있다.

구현 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Slf4j
@Service
@RequiredArgsConstructor
@EnableCaching
public class TicketService {
    private final RedisTemplate<String, String> template;

    // 티켓 발급 처리 및 중복 검사
    public String issueTicket(Long userId) {
        String ticketListKey = "tickets"; // 티켓 리스트를 저장할 key
        String joinUserSetKey = "join_user"; // 유저 중복 체크용
        String ticketRemainFlagKey = "no_ticket";
        String eventStartFlagKey = "eventStartFlag";

        String eventStartFlag = template.opsForValue().get(eventStartFlagKey);
        if (!"true".equals(eventStartFlag)) {
            return "이벤트가 아직 시작되지 않았습니다!";
        }

        String ticketRemainFlag = template.opsForValue().get(ticketRemainFlagKey);
        if ("true".equals(ticketRemainFlag)) {
            return "남은 티켓이 없습니다!";
        }

        // 티켓이 있을 경우 유저 ID가 이미 참여했는지 확인
        Boolean isJoinedUser = template.opsForSet().isMember(joinUserSetKey, userId.toString());

        if (Boolean.TRUE.equals(isJoinedUser)) {
            log.info("이미 이벤트에 참여한 유저입니다: " + userId);
            return "이미 이벤트에 참여한 유저입니다!!";
        }

        // Redis 에서 하나 꺼내기 (티켓이 없으면 null반환)
        Object ticket = template.opsForList().leftPop(ticketListKey);

        if (ticket == null) {
            template.opsForValue().set(ticketRemainFlagKey, "true");
            log.info("티켓이 없습니다.");
            return "남은 티켓이 없습니다!!";
        }

        // userId joinUserSetKey 에 추가하여 중복 방지
        template.opsForSet().add(joinUserSetKey, userId.toString());

        log.info("티켓 발급: " + ticket + " userId: " + userId);
        return ticket.toString(); // 티켓 반환
    }
}

당연하게도 실행되는 로직 자체가 크게 줄었기 때문에 성능이 크게 개선되었다.

CPU 사용량도 매우 안정적이고 응답 속도 또한 크게 개선된 것을 볼 수 있다.

구현 2 (루아 스크립트 사용)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketService {
    private final RedisTemplate<String, String> template;

    // 티켓 발급 처리 및 중복 검사
    public String issueTicket(Long userId) {
        String luaScript = """
        -- 이벤트 시작 여부 확인
        local eventStartFlag = redis.call('GET', ARGV[1])
        if eventStartFlag ~= "true" then
            return "이벤트가 아직 시작되지 않았습니다!"
        end

        -- 티켓 소진 여부 확인
        local ticketRemainFlag = redis.call('GET', ARGV[2])
        if ticketRemainFlag == "true" then
            return "남은 티켓이 없습니다!"
        end

        -- 유저 중복 검사
        local isJoinedUser = redis.call('SISMEMBER', ARGV[3], ARGV[4])
        if isJoinedUser == 1 then
            return "이미 이벤트에 참여한 유저입니다!!"
        end

        -- 티켓 발급
        local ticket = redis.call('LPOP', ARGV[5])
        if not ticket then
            -- 티켓이 소진되었을 경우 플래그 설정
            redis.call('SET', ARGV[2], "true")
            return "남은 티켓이 없습니다!"
        end

        -- 유저 ID 중복 방지를 위해 저장
        redis.call('SADD', ARGV[3], ARGV[4])

        return ticket
    """;

        // Lua 스크립트에서 사용하는 Redis 키와 인자를 설정
        String[] args = {
                "eventStartFlag",
                "no_ticket",
                "join_user",
                userId.toString(),
                "tickets"
        };

        // Lua 스크립트 실행
        String result = template.execute((RedisCallback<String>) redisConnection -> {
            byte[][] argsBytes = new byte[args.length][];
            for (int i = 0; i < args.length; i++) {
                argsBytes[i] = args[i].getBytes(StandardCharsets.UTF_8);
            }

            byte[] resultBytes = redisConnection.eval(
                    luaScript.getBytes(StandardCharsets.UTF_8),
                    ReturnType.VALUE,
                    0,
                    argsBytes
            );

            return resultBytes != null ? new String(resultBytes, StandardCharsets.UTF_8) : null;
        });

        return result;
    }
}

같은 로직으로 루아 스크립트를 사용했을 때 응답 속도에서는 큰 차이가 없었고, CPU 사용량에서 평균 6% 정도의 차이를 보였다.

우선 이 구현 방식을 적용했을 때 목표한 부분들을 모두 달성하고 오히려 더 크게 개선되었다. 기존에 문제가 되었던 최대 응답시간 또한 0.7초 정도로 안정적인 모습을 보였다.

구현 3 (검증 캐싱)

목표는 달성했지만, 더 개선할 수 있는 부분이 있을까 고민하다가 개선할 부분을 발견했다.

기존 로직에서 이벤트의 시작을 검증하거나, 티켓이 있는지에 대한 검증을 Redis에 저장해 매번 Redis와 통신하며 검증하도록 되어있다.

이 부분도 Redis와의 불필요한 통신이 될 수 있다.

따라서 이러한 검증을 로컬에서 처리하도록 하는 것이다. 그런데 여기서 고려해야할 부분이 있다.

검증을 로컬에서 처리하려면 로컬 변수에 검증에 필요한 값을 저장해야 한다. 그런데 만약 서버가 여러대일 경우를 가정하면 단순하게 구현할 수 없다.

예를 들면 어떤 서버에서는 티켓이 있다고 변수에 저장되어 있고, 어떤 서버에서는 티켓이 없다고 변수에 저장되어 있을 수 있는 것이다.

따라서 AtomicBoolean과 Redis Pub/Sub을 활용해 이 부분을 개선해보고자 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketService {
    private final RedisTemplate<String, String> template;
    private final ChannelTopic eventStartTopic;
    private final ChannelTopic ticketRemainTopic;
    private AtomicBoolean eventStartFlagCached = new AtomicBoolean(false);
    private AtomicBoolean ticketRemainFlagCached = new AtomicBoolean(true);

    // 티켓 발급 처리 및 중복 검사
    public String issueTicket(Long userId) {
        if (!eventStartFlagCached.get()) {
            return "이벤트가 아직 시작되지 않았습니다~!";
        }

        if (!ticketRemainFlagCached.get()) {
            return "남은 티켓이 없습니다~!";
        }

        String luaScript = """
            local isJoinedUser = redis.call('SISMEMBER', ARGV[1], ARGV[2])
            if isJoinedUser == 1 then
                return "이미 이벤트에 참여한 유저입니다!!"
            end

            local ticket = redis.call('LPOP', ARGV[3])
            if not ticket then
                return "남은 티켓이 없습니다!"
            end

            redis.call('SADD', ARGV[1], ARGV[2])

            return ticket
        """;

        String[] args = {
                "join_user",
                userId.toString(),
                "tickets"
        };

        String result = template.execute((RedisCallback<String>) redisConnection -> {
            byte[][] argsBytes = new byte[args.length][];
            for (int i = 0; i < args.length; i++) {
                argsBytes[i] = args[i].getBytes(StandardCharsets.UTF_8);
            }

            byte[] resultBytes = redisConnection.eval(
                    luaScript.getBytes(StandardCharsets.UTF_8),
                    ReturnType.VALUE,
                    0,
                    argsBytes
            );

            if (resultBytes != null) {
                String resultStr = new String(resultBytes, StandardCharsets.UTF_8);
                if ("남은 티켓이 없습니다!".equals(resultStr)) {
                    if (ticketRemainFlagCached.compareAndSet(true, false)) {
                        template.convertAndSend(ticketRemainTopic.getTopic(), "false");
                    }
                }
                return resultStr;
            }
            return null;
        });

        return result;
    }

    @EventListener
    public void handleEventStartMessage(String message) {
        this.eventStartFlagCached.set("true".equals(message));
    }

    @EventListener
    public void handleTicketRemainMessage(String message) {
        this.ticketRemainFlagCached.set("true".equals(message));
    }
}
  • AtomicBoolean
    • 멀티 쓰레드 환경에서 안전하게 값을 업데이트/저장한다.
    • 일관성있게 값을 업데이트 해야 한다.
  • Redis Pub/Sub
    • AtomicBoolean 변수를 사용하는 것 외에도 어떤 서버에서 상태가 변경될 때 다른 서버에서도 동일하게 변경되어야 하는 부분을 구현해야 했다.
    • 따라서 Redis Pub/Sub을 사용해 다른 서버의 구독자들에게 변경 사실을 알리고, 로컬의 AtomicBoolean 변수의 값들을 일관되게 업데이트 하도록 했다.

결과적으로 불필요한 Redis 통신을 감소시킬 수 있었고 실제 결과도 개선된 모습을 볼 수 있다.

  • 최대 응답 시간 16% 개선
  • 95% Line 61% 개선

최종 테스트

최종적으로 시간도 얼마 남지 않고 목표치도 충분히 달성했기 때문에 로컬 캐시까지 적용한 구현 방식에 만족을 하고 로컬 환경에서 해당 방식으로 어디까지 부하를 받아낼 수 있는지 테스트해보았다.

분당 20만 요청부터 최대 응답속도에서 불안정한 모습을 보였고, 분당 30만 요청은 받아내지 못하는 모습을 확인할 수 있었다.

최대 분당 25만 정도의 요청까지는 에러 없이 받아낼 수는 있지만 안정적으로 받아낼 수 있는 요청은 분당 20만 요청정도였다.

정리

이번 요구사항을 받고, 트래픽에 대한 부분을 처음 고민해보았다.

단순하게 기능 구현만 생각했었는데, 새로웠고 재밌다고 느낀 부분도 있다.

순간적으로 트래픽이 몰렸을 때 대응할 수 있는 방법, 부하 테스트, 그리고 단순히 어떤 기능, 데이터베이스들을 사용하는 것으로 개선할 수 있을 뿐 아니라 책임을 분담하여 구현 구조를 바꾸면서 가용성을 크게 늘릴 수 있다는 것도 알 수 있었다.

앞으로 개발하는데 있어 생각의 폭을 넓혀주는 경험 중 하나가 되지 않을까 싶다.

This post is licensed under CC BY 4.0 by the author.

프로메테우스, 그라파나

1차 배포 환경 구축하기