Redis 조회, 100번 나눠 부르지 말고 한 번에 부르자 — MGET

트래픽 급증으로 인한 장애

세일이 시작이 되자 트래픽이 확 튀었다. 어느정도 예상했던 범위이지만 특정 페이지에서  조회가 급격히 증가하면서 APM 상에서 API 응답 지연이 눈의 띄게 확인됐다. 해당 페이지는 여러 항목을 한 화면에 모아 보여주는 페이지기 때문에 Redis 캐시에서 값을 꽤 여러개 읽어와야했는데, 트래픽이 평소의 몇 배로 튀니 그 지연이 드러났다.

 

원인이 발생했었던 코드의 일부분을 보니 아래와 같은 Redis 조회 코드가 존재했다.

```kotlin
val values = keys.map { key -> redis.get(key) }
```

 

캐시에서 읽어야 할 키를 `하나씩, 순서대로` 조회하고 있었다.  평소 트래픽에선 키가 몇 개 안 되니 티가 안 났는데, 세일 트래픽이 몰리면서 동시에 처리해야 할 조회 수가 늘어나자 이 방식의 비효율이 그대로 API 응답 지연으로 튀어나왔다.

 

결론부터 말하면 Redis가 느린 게 아니라, 네트워크 왕복(RTT)을 필요한 것보다 훨씬 많이 만들고 있었고, MGET을 이용해 이를 개선할 수 있었다.

 

실험 환경

Spring Boot 앱으로 같은 조건(로컬 Redis, 미리 캐싱해준 키 1000개)에서 세 가지 클라이언트를 비교했다.

  • Jedis : `GET` 키 개수 만큼 반복 호출 VS `MGET` 한 번
  • Lettuce : `GET`키 개수 만큼 반복 호출 VS `MGET` 또는 파이프라인으로 한 번에 전송
  • Redission : `RBucket.get()` 을 키 개수만큰 반복 호출 VS `RBatch`로 한 번에 전송

 

jedis - `GET` 키 개수 만큼 반복 호출

피크 응답 시간 - 1.70s

 

jedis - `MGET` 한 번 호출

피크 응답 시간 - 16.3ms

 

Lettuce - `GET` 키 개수 만큼 반복 호출

피크 응답 시간 - 4.03s

 

Lettuce - `MGET` 또는 파이프라인으로 한 번에 전송

피크 응답 시간 - 32.0ms

 

 

Redisson - RBucket.get()` 을 키 개수만큰 반복 호출 

피크 응답 시간 - 2.51s

 

Redisson - RBatch`로 한 번에 전송

피크 응답 시간 - 16.1ms

 

각 항목을 로컬에서만 비교해봐도 차이가 엄청 났다. 이러니 실제 운영 환경 서비스에서는 얼마나 많은 지연이 있었을까..

 

Redis 자체는 매우 빠르다. 단일 스레드로 커맨드를 처리하는데, `GET` 하나 처리하는 데 걸리는 시간이 마이크로초 단위다. 그런데 실제 지연은 Redis가 일하는 시간이 아니라 요청을 보내고 응답을 받기까지 왔다 갔다 하는 시간에서 발생한다.

 

사실 굉장히 단순한 내용인데, AI로 개발하고 검토하지 않는다면 놓치기 쉬운 부분일것 같다.

 

MGET, 파이프라인, RBatch 

실제로 로컬 환경에서 테스트할 때는 여러 redis 클라이언트를 이용했고, 유사하지만 서로 다른 함수를 사용했다.

  • `MGET` : Redis가 기본 제공하는 커맨드 자체, 여러 키의 값을 한 번에 달라는 하나의 명령이다. 사용이 간단하지만 `GET`  조회시에 만 사용할 수 있다.
  • `파이프라인` : 클라이언트가 여러 명령(꼭 같은 종류가 아니어도 됨 - `SET`, `GET`, `INCR` 섞어도 됨)을 큐에 쌓아뒀다가 한 번에 전송하는 더 일반적인 기법이다. `MGET`에서 많이 사용한다.
  • `RBatch` : Redisson이 제공하는 파이프라인 격 기능이다. Redisson 자체는 사실 단순 커맨드 클라이언트가 아니라  분산락, 분산 맵, 분산 큐 같은 고 수준 자바 객체를 Redis 위에 얹어 주는 라이브러리이다. 그 안에서 여러 연산을 모았다가 한 번에 보내는 `RBatch`가 있고, 원리는 파이프라인과 똑같다.

프로토콜 레벨에서의 동작 차이

3가지 명령어 모두 결과는 1번은 RTT로 동일하지만 Redis 서버 입장에서  둘은 완전히 다르게 동작한다.

 

MGET - 서버 입장에서 명령어 하나에 불과하다. 클라이언트가 `MGET Key1 Key2 Key3`를 한 줄로 보내면, Redis가 이걸 하나의 커맨드로 받아서 내부적으로 각 키를 찾은 뒤 배열 하나로 응답한다.

 

파이프라인/RBatch - 서버 입장에서는 여전히 명렁어가 N개다. 다만 클라이언트가 한 명령 보내고 응답 기다렸다가 다음 명령 보내는 걸 생략하고 N개 명령을 한꺼번에 소켓에 밀어넣은 뒤 응답이 순서대로 돌아오는 걸 한꺼번에 읽는다.

 

따라서, Redis는 여전히 `GET`을 3번 처리한다. 다만 클라이언트가 매번 응답을 기다리지 않고 몰아서 보내고 받기 때문에 RTT만 1번으로 줄어든다.

정리하면 

  • MGET - 서버가 여러 개 조회라는 의미의 명령 하나를 처리
  • 파이프라인/RBatch - 서버는 여전히 명령을 N번 처리하지만, 클라이언트가 왕복을 매번 기다리지 않고 몰아서 보낸다.

MGET의 우위

앞서 MGET, 파이프라인, RBatch 모두 RTT를 1번으로 줄이는 효과는 모두 똑같았다. 다만 순수 조회만 필요한 경우 `MGET`이 좀더 중요한 장점이 존재한다.

  • 프로토콜 오보헤드가 좀더 적다 - MGET은 Redis입장에서 1번의 명령이지만, 파이프라인은 N번의 명령이다.
  • MGET은 원자적(atomic)이다. - Redis가 하나의 명령으로 처리하기 때문에 그 사이에 다른 클라이언트의 명령이 끼어들 수 없다.

원자적(atomic)이라는 장점이 조금 중요한데, 그렇다면 파이프라인으로 보낸 명령들 사이에 다른 클라이언트의 명령이 끼어드는게 가능할까?

 

Redis의 이벤트 루프는 클라이언트 소켓에서 한 번에 읽어들인 만큼(보통 수 KB 버퍼) 을 파싱해서 그안에 완성된 명령이 여러 개 있으면 전부 연달아 처리 한 뒤에야 다음 클라이언트로 넘어간다. 파이프라인 명령들이 한 번의 `write()`로 통째로 전송돼 Redis가 한 번에 읽을 수 있는 크기라면 사실상 끊기지 않고 처리돼 끼어들 틈이 없다.

 

그런데 파이프라인이 너무 커서 한 번의 소켓 읽기로 다 못 읽거나, 네트워크 상황 때문에 여러 TCP 세그먼트로 쪼개져서 도착하면 아래와 같은 상황이 가능해진다.

 

그렇기 때문에 Redis 공식 문서도 파이프라인은 원자성을 보장하지 않는다. 라고 명시하고 있다. 작은 GET 몇백 개 정도는 실무적으로 거의 항상 한 방에 처리되니 체감상 문제없지만, 절대 안  끼어든다. 는 보장이 아니라 대체로 그렇게 동작하는 구현 상 경향이다. 따라서 진짜 원자성이 필요하면 `MULTI`/`EXEC` 트랜잭션이나 Lua 스크립트를 써야한다.

 

Pipelining is not atomic. Commands in a pipeline can partially succeed.

* 파이프라인 원자성 글 참고

 

클러스터 환경이라면?

만약 Redis Cluster를 쓴다면 `MGET`은 순순히 동작하지 않을 수 있다.

Redis Cluster는 전체 키 공간을 16384개의 해시 슬롯으로 나누고, 각 노드가 슬롯 일부를 담당한다. 어떤 키가 어느 슬롯에 속하는지는 `CRC16(key) % 16384`로 정해진다.  그런데 `MGET key1 key2 key3` 같은 멀티키 명령은 관련된 키가 전부 같은 솔롯(=같은 노드)에 있어야만 실행된다.  key1 노드A, Key2 노드 B에 있으면 아래와 같이 에러가 발생한다.

 

/*
* 실제 jedis 내부 getSlot 함수에서 CRC16 연산 코드 확인
*/
  public static int getSlot(String key) {
    if (key == null) {
      throw new NullPointerException("Slot calculation of null is impossible");
    }

    key = JedisClusterHashTag.getHashTag(key);
    // optimization with modulo operator with power of 2 equivalent to getCRC16(key) % 16384
    return getCRC16(key) & (16384 - 1);
  }

 

 

(error) CROSSSLOT Keys in request don't hash to the same slot

 

클러스터 환경에선 조회하려는 키 500개가 여러 노드에 흩어져 있을 가능성이 높고, 그러면 `MGET` 한 방으로는 끝나지 않는다.

이 경우, 해시 태그(hash tag)를 이용하면 된다.

`{}`로 감싼 부분이 있으면 Redis는 그 안의 문자열만 가지고 슬롯을 계산한다.

 

user:1000:profile   → 전체 문자열로 슬롯 계산
user:{1000}:profile → "{1000}" 부분만으로 슬롯 계산
user:{1000}:points  → 위와 똑같은 슬롯 (같은 "{1000}")

 

실제 해시코드를 사용한 경우와 아닌 경우 아래의 테스트코드를 통해서 확인할 수 있었다.

 @GetMapping("/crossslot")
    fun crossslot(
        @RequestParam(defaultValue = "key1") key1: String,
        @RequestParam(defaultValue = "key2") key2: String,
    ): CrossSlotResult {
        val slot1 = JedisClusterCRC16.getSlot(key1)
        val slot2 = JedisClusterCRC16.getSlot(key2)
        log.info("GET /crossslot key1={} (slot={}) key2={} (slot={})", key1, slot1, key2, slot2)

        return try {
            Jedis("127.0.0.1", 7001).use { jedis ->
                val values = jedis.mget(key1, key2)
                log.info("crossslot succeeded: sameSlot={} values={}", slot1 == slot2, values)
                CrossSlotResult(key1, slot1, key2, slot2, slot1 == slot2, values = values)
            }
        } catch (e: JedisDataException) {
            log.warn("crossslot rejected by cluster: {}", e.message)
            CrossSlotResult(key1, slot1, key2, slot2, slot1 == slot2, error = e.message)
        } catch (e: JedisConnectionException) {
            log.error("failed to connect to redis cluster at 127.0.0.1:7001", e)
            CrossSlotResult(
                key1, slot1, key2, slot2, slot1 == slot2,
                error = "클러스터에 연결할 수 없습니다. docker/redis-cluster-demo.sh 로 먼저 클러스터를 띄워주세요: ${e.message}",
            )
        }
    }

 

테스트코드 - Slot 에러 로그
테스트코드 - 해시태그를 사용한 경우

 

따라서, 같은 사용자에 대한 키들을 `user:{1000}:profile` , `user:{1000}:points` 처럼 같은 해시 태그로 묶는다면 그 키들은 항상 같은 슬롯(같은 노드)에 모이게 되고, `MGET`으로 안전하게 한 번에 조회할 수 있다.

 

 

그리고 흥미로운점이 하나 있었는데, Redis 클라이언트 별로 MGET 호출에 대한  크로스슬롯 반응이 완전히 달랐다.

 

  • `Jedis(노드 하나에 직접 연결)` - 크로스 슬롯 에러 발생((error) CROSSSLOT Keys in request don't hash to the same slot)
  • `JedisCluster` - 클라이언트가 자체 에러 발생(JedisClusterOperationException: Keys must belong to same hashslot.)
  • `Lettuce(RedisAdvancedClusterCommands)` - 키 단위로 쪼개서 각 노드에 개별 조회보내서 결과를 원래 순서로 합쳐서 성공
  • `Redisson` - 키 단위로 쪼개서 각 노드에 개별 조회보내서 결과를 원래 순서로 합쳐서 성공

뿐만 아니라 파이프라인/RBatch도  키 단위로 쪼개서 각 노드에 개별 조회 후 결과를 합쳐서 보여주기 때문에 동일한 원리로 성공시킨다.

 

클라이언트 별  MGET  및 파이프라인/RBatch 확인

Jedis - 실패

 

JedisCluster - 실패

 

Lettuce - 성공

 

Redisson - 성공

 

일반 클라이언트로 파이프라인 사용 시 - 실패

 


정리,

구분 일반 클라이언트 사용 시 클러스터 클라이언트 사용 시
MGET CROSSSLOT 에러 발생 슬롯별로 분할 후 결과 병합
Pipeline / RBatch CROSSSLOT은 없지만 MOVED 응답 발생 슬롯별로 분배 후 결과 병합
사용자 관점 명령 실패 또는 일부 실패 하나의 명령처럼 정상 동작

 

단, 파이프라인/RBatch 안에 그 자체로 이미 멀티키인 명령(위 3번 항목)이 있으면, 클러스터 인지형 클라이언트를 써도 그 명령만은 CROSSSLOT을 피할 수 없다.

 

결국, 클러스터 환경일 때 노드별로 알아서 나눠 보내주는 기능은 그 기능을 구현한 클러스터형 클라이언트를 썻을때 만 발생한다.

그리고 그런 클라이언트를 쓰더라도 키가 여러 노드에 흩어져 있으면 왕복이 노드 개수만큼(N번) 늘어나는건 마찬가지다.

다만 이 왕복들은 순차가 아니라 병렬로 나가기 때문에 체감 지연은 N배가 아닌 그중 제일 느린 응답 하나 만큼 늘어날것이다.

따라서, 왕복 1번이라는 이점을 온전히 누리고자 한다면 관련 키들을 해시 태그로 같은 슬롯(노드)모아 두는 설계가 필요하다.

 

참고