Home [football] Redis의 특징을 고려해 접속 가능한 최우선 웹소켓 선정 방식 구현
Post
Cancel

[football] Redis의 특징을 고려해 접속 가능한 최우선 웹소켓 선정 방식 구현

# 문제점


API 서버에서 웹소켓 접속에 대한 요청을 최초로 받고 여러 개로 존재하는 웹소켓 중 하나의 주소를 선정해 응답객체로 전달해줘야 하는 과정에서 가장 적절한 웹소켓 주소를 선정해야 하는 소요가 발생했습니다.

적절한 웹소켓 주소를 선정해야 하는 가장 큰 이유로는 하나의 서비스를 위한 서버를 분리한 구조에서 하나의 서버가 대부분의 트래픽에 대한 처리를 하는 과부하 현상을 방지하고 서버 다운의 위험을 낮추기 위함이였습니다. 이러한 트래픽 과부하 문제를 해결하기 위해서는 우선 웹소켓 접속 우선순위에 대한 기준이 필요했고 그 기준에 따라 최적의 웹소켓 주소를 저장해두고 관리하는 과정이 필요하다고 생각했습니다.

그래서 초기에 구성해본 동작 방법은 웹소켓 접속 요청이 발생한 경우 API 서버로 주기적으로 넘어오는 웹소켓들의 heartbeat 데이터 중 현재 접속중인 사용자 수를 비교해 해당 시점에서 가장 적은 접속자를 가진 웹소켓 주소를 선정한 뒤 해당 주소를 사용자에게 응답해주는 방식으로 동작하는 로직을 구현했습니다.

여기서 문제가 될 수 있는 부분은 최적의 채팅 서버를 선정하기 위해 Redis에 저장된 모든 서버 주소에 대한 키를 조회해야 한다는 것이였습니다.

과연 모든 키를 조회하는 것이 합리적은 구현 방법인지에 대해 고민해보고 다른 더 좋을 대안이 있을지 고민해봐야 했습니다.

# 해결방안


1-1. Redis 동작 원리에 대한 이해

Redis는 일반적으로 작업을 실행하는 데 1밀초 미만이 소요됩니다. 하지만 Redis는 싱글 쓰레드로 동작하기 때문에 하나의 커맨드가 많은 처리 시간을 요구한다면 그만큼 처리할 수 있는 건수에 대한 손해를 보게 됩니다.

하나의 커맨드가 1초 이상의 처리 시간을 소요한다면 그 시간만큼 처리할 수 있는 다른 커맨드는 대기해야 하는 구조!

이러한 문제를 가장 많이 발생시키는 녀석이 keys() 메소드입니다. keys()는 특정 문자열 패턴에 해당하는 키를 조회해 리스트로 리턴하기 위해 Redis에 존재하는 모든 키를 조회해야 합니다. 그러다 보니 저장된 데이터의 수가 많으면 많을수록 keys()가 처리되는 시간을 증가하고 이는 Redis의 빠른 처리 속도를 활용하지 못하고 사실상 서비스 장애 정도의 딜레이를 야기할 수 있습니다.

1-2. scan() 메소드

위에서 설명한 keys()의 단점을 보안하기 위해 scan()를 사용하는 것을 고려해볼 수 있었습니다.

scan()keys()처럼 한번에 모든 레디스 키를 읽어오는 것이아니라 count 값을 정하여 그 count 값만큼 여러번 레디스의 모든 키를 읽어오는 것입니다(기본 count는 10). 예를 들어 레디스에 읽어야하는 키값이 총 10000개라고 치고 count가 10개이면 1000번에 나눠서 이 키를 읽는 것입니다. 커서가 0이 될대까지 while문을 돌며 키를 나눠서 읽는 것입니다.

그래서 scan()을 활용한다면 keys()처럼 모든 조회가 끝날 때가지 다른 커맨드 처리를 하지 못하지 않고, 조회를 count 수만큼 분산시켜 처리하기 때문에 어느정도 처리 시간에 대한 딜레이를 방지할 수 있습니다.

2-1. Redis에서 제공해주는 다양한 자료구조

Redis는 빠른 처리속도는 물론 다양한 자료구조를 제공해준다는 장점도 있습니다. Redis가 제공해주는 자료구조는 다음과 같습니다.

  • Strings : Vinary-safe한 기본적인 key-value 구조

  • Lists : String element의 모음, 순서는 삽입된 순서를 유지하며 기본적인 자료구로 Linked List를 사용

  • Sets : 유일한 값들의 모임인 자료구조, 순서는 유지되지 않음

  • Sorted sets : Sets 자료구조에 score라는 값을 추가로 두어 해당 값을 기준으로 순서를 유지

  • Hahses : 내부에 key-value 구조를 하나더 가지는 Reids 자료구조

  • Bit arrays(bitMaps) : bit array를 다를 수 있는 자료구조

  • HyperLogLogs : HyperLogLog는 집합의 원소의 개수를 추정하는 방법, Set 개선된 방법

  • Streams : Redis 5.0 에서 Log나 IoT 신호와 같이 지속적으로 빠르게 발생하는 데이터를 처리하기 위해서 도입된 자료구조

2-2. Sorted Set

최적의 접속 가능한 웹소켓 서버를 저장하기 위해 가장 적합한 자료구조는 Sorted Set이라 판단했습니다.

하나의 Sorted Set 자료구조에 connection Count를 기준으로 가장 적은 서버주소부터 정렬을 해둔다면 이후에 최적 웹소켓 서버 주소를 리턴하기 위해서 Sorted Set에 접근해 가장 앞의 주소값만 조회한다면 좀 더 효율적인 방법이 될 것이라 판단했습니다.

3. 적용 코드

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
@Service
@RequiredArgsConstructor
public class RedisServiceImpl implements RedisService {

  // 모든 Websocket 서버 주소를 조회하기 위한 메소드
  @Override
  public Cursor<String> scanWebSocketServerKey() {

    return redisTemplate.opsForHash().getOperations()
        .scan(ScanOptions.scanOptions() // scan() 활용
            .match(WebSocketUtils.PREFIX_SERVER + "*")
            .count(3) // 커맨드 한번당 가져올 데이터 개수를 지정
            .build()
        );

  }
  
  // 최적의 websocket 서버 선정 메소드
  @Override
  public void setPrimaryWebSocketServerKeys() {

    // 전체 서버 정보가 담긴 key 조회
    Cursor<String> keys = scanWebSocketServerKey();

    // 연결 가능한 서버가 다운되어 조회된 키가 없는 경우
    if(!keys.hasNext()) {
      // 저장되어 있던 ZSet 데이터를 삭제
      redisTemplate.delete(WebSocketUtils.Z_SET_KEY);

    }

    while(keys.hasNext()) {

      String key = keys.next();

      Integer connectionCount = (Integer) redisTemplate.opsForHash()
          .get(key, WebSocketUtils.CONNECTION_COUNT);

      if (connectionCount == null) {

        throw new RuntimeException("웹 서버에 연결된 커넥션 정보가 없습니다.");

      }

      // Sorted Set 자료구조로 연결하기 가장 좋은 서버 정보를 따로 저장해둔다.
      redisTemplate.opsForZSet().add(WebSocketUtils.Z_SET_KEY, key, connectionCount);

    }

  }

  // 최적의 Websocket 서버를 조회하는 메소드
  @Override
  public String getPrimaryWebSocketServerKey() {

    // Sorted Set 으로 저장하기 때문에 가장 첫번째 녀석을 가져오면 된다.
    ZSetOperations<String, Object> zSetOperations = redisTemplate.opsForZSet();

    Set<Object> set = zSetOperations.range(WebSocketUtils.Z_SET_KEY, 0, 0);

    if (set == null || set.isEmpty()) {

      throw new RuntimeException("접속 가능한 서버 정보가 없습니다.");

    }

    Optional<Object> key  =  set.stream().findFirst();

    return (String) key.get();

  }
  
  
}

# 마치며


이처럼 성능 최적화를 위해 Redis의 동작 원리는 물론 제공해주는 여러 자료구조의 특징을 고려해 로직을 리팩토링하면서 Redis에 대한 깊은 이해는 물론, 애플리케이션과 연동하는 플랫폼을 더욱 적재적소에 활용하기 위해 가져야 할 자세를 배울 수 있었습니다.

# 참고자료


  • https://tjdrnr05571.tistory.com/11
  • https://aws.amazon.com/ko/elasticache/what-is-redis/
  • https://sabarada.tistory.com/134
  • https://tjdrnr05571.tistory.com/11
This post is licensed under CC BY 4.0 by the author.

[football] AWS를 활용한 배포 환경 구성

[football] 환경에 따른 application.yml 파일 구분