Redis

node-redis 업그레이드: SADD 오류와 안전한 이전법

SADD·SRANDMEMBER 호출 계약이 달라진 장애를 15분 안에 롤백한 경험과 고수준 API·반환값·연결 수명주기 검증법을 설명한다.

이 글의 목차Redis는 그대로였는데 호출 계약이 달라졌다10
  1. Redis는 그대로였는데 호출 계약이 달라졌다
  2. 15분 동안은 복구만 생각했다
  3. 당시에는 실제 Redis 명령을 그대로 보냈다
  4. 지금이라면 고수준 API를 먼저 사용한다
  5. 반환 형태가 다른 명령을 같은 방식으로 다루면 안 된다
  6. 메이저 업그레이드는 명령 몇 개만 확인하면 끝나지 않는다
  7. 재현 테스트는 Redis 응답까지 포함한다
  8. 배포 전에 확인할 순서
  9. 결론
  10. 참고한 공식 문서

회사에서 Node.js와 라이브러리를 올린 뒤 서버가 느려지고 일부 기능을 정상적으로 사용하기 어려운 Major 장애가 발생했다.

Major는 서비스의 중요한 기능에 큰 영향이 있어 빠른 복구가 필요한 심각도를 뜻한다.

Redis는 데이터를 메모리 중심으로 빠르게 읽고 쓰는 저장소로, 캐시·세션·작업 큐와 중복 없는 목록 등에 사용된다.

node-redis는 Node.js 애플리케이션의 메서드 호출을 Redis 서버 명령으로 바꾸고, 서버 응답을 JavaScript 값으로 돌려주는 공식 클라이언트다.

원인을 따라가 보니 Redis 서버 자체보다 애플리케이션과 node-redis 사이에 둔 래퍼가 문제였다.

래퍼는 외부 라이브러리를 직접 호출하지 않고 우리 코드가 쓰기 편한 형태로 감싸는 얇은 계층이다.

SADD, SRANDMEMBER 같은 Redis Set 명령의 인자와 반환 처리 방식이 바뀌었는데, 기존 래퍼는 새 API에서도 예전과 같은 계약이 유지된다고 가정했다.

Set은 중복되지 않는 값을 모아 두는 Redis 자료 구조다.

나는 먼저 라이브러리 변경을 롤백해 15분 안에 서비스를 복구하고, 문제가 된 Set 명령을 Redis의 실제 명령 형식에 맞춰 다시 정리했다.

당시에는 sendCommand()로 명령과 인자를 명시해 경계를 안정화했다.

하지만 현재의 node-redis에서는 지원되는 명령에 고수준 API가 제공되므로, 지금 다시 설계한다면 sendCommand()를 모든 명령의 기본값으로 삼지는 않을 것이다.

이 글은 당시 복구 방법과 지금의 재발 방지 방법이 왜 다른지까지 함께 다룬다.

Redis는 그대로였는데 호출 계약이 달라졌다

Redis 명령의 기본 의미는 단순하다.

SADD key member [member ...]는 집합에 하나 이상의 값을 추가하고, 그중 새로 추가된 값의 개수를 반환한다.

SMEMBERS key는 집합에 든 모든 값을 반환한다.

SRANDMEMBER key는 임의의 값 하나를 반환하지만, count를 함께 주면 여러 값을 배열로 반환한다.

SRANDMEMBERcount가 양수면 중복 없는 값을 최대 그 수만큼 돌려주고, 음수면 중복을 허용해 절댓값만큼 반환한다.

예를 들어 이벤트에 참여한 사용자 ID를 중복 없이 보관하거나, 특정 기능을 사용할 수 있는 계정 목록을 확인할 때 Set이 잘 맞는다.

들어온 순서를 유지해야 하는 작업 대기열이라면 List나 Stream을, 점수 순위가 필요한 리더보드라면 Sorted Set을 검토한다.

SMEMBERS는 집합의 모든 원소를 한 번에 읽는 O(N) 명령이다.
여기서 N은 멤버 수이므로, 회원 전체처럼 계속 커지는 집합을 요청마다 읽으면 Redis 처리시간과 네트워크 응답 크기도 함께 커진다.

큰 집합을 점진적으로 훑어야 한다면 커서 기반의 SSCAN을 검토하고, 화면에 정말 모든 멤버가 필요한지도 먼저 확인한다.

자료 구조를 잘못 고른 문제와 클라이언트 API 계약이 달라진 문제는 구분해야 한다.

이 사례에서 Set의 선택과 Redis 명령의 의미는 그대로였고, JavaScript 인자와 반환값을 변환하는 경계가 어긋났다.

문제는 Redis 명령 자체보다 이 명령을 JavaScript 메서드로 표현하는 방식이었다.

라이브러리 버전이 바뀌면 배열 인자를 하나로 받을지 여러 인자로 펼쳐 받을지, 숫자를 문자열로 바꿔 줄지, 반환값을 어떤 JavaScript 타입으로 바꿀지가 달라질 수 있다.

애플리케이션은 같은 래퍼 메서드를 호출했지만 래퍼 안의 변환 규칙이 새 클라이언트 API와 맞지 않았다.

애플리케이션의 배열과 숫자 입력을 Redis 래퍼에서 문자열 인자 배열로 정규화해 실제 Redis 명령으로 보내고 반환 형태를 다시 맞추는 흐름
업그레이드에서 지켜야 할 것은 메서드 이름보다 입력과 반환값의 계약이다.

15분 동안은 복구만 생각했다

장애가 난 상태에서 새 API를 완전히 이해하려고 버티면 사용자가 기다리는 시간이 길어진다.

되돌릴 수 있는 변경이고 데이터 형식이 이미 바뀐 상황이 아니라면, 우선 검증된 버전으로 돌아가는 편이 안전하다.

업무 기록에는 Major 등급 장애의 대응 시간이 0.25시간으로 남아 있다.

0.25시간은 15분이다.

그 시간 안에 라이브러리 변경을 롤백해 서비스부터 복구했다.

롤백 뒤에는 문제가 “Redis 전체”가 아니라 Set 계열 명령의 인자와 반환 처리에 있다는 범위까지 좁혔다.

당시 정확한 이전·이후 node-redis 버전, 오류 메시지와 스택 트레이스는 공개 가능한 기록에 남아 있지 않다.

따라서 이 장애를 특정 메이저 버전의 알려진 버그라고 단정하지 않는다.

확정되는 사실은 클라이언트 API 차이가 기존 래퍼의 가정과 충돌했고, 롤백과 래퍼 수정으로 대응했다는 점이다.

당시에는 실제 Redis 명령을 그대로 보냈다

수정한 래퍼는 중첩 배열을 평탄화하고 모든 멤버를 문자열로 변환한 뒤 sendCommand()에 넘겼다.

평탄화는 ['a', ['b', 'c']]처럼 겹친 배열을 ['a', 'b', 'c']처럼 한 단계로 만드는 처리다.

Redis의 통신 프로토콜로 보낼 인자를 문자열 배열로 고정해, 고수준 메서드의 인자 해석 차이를 피했다.

당시 장애 뒤 정리한 Set 래퍼의 단순화 예시TypeScript
type RedisMember = string | number;
type RedisMemberInput = RedisMember | RedisMember[];

function normalizeRedisMembers(
  members: RedisMemberInput[],
): string[] {
  return members.flat().map((member) => {
    if (
      typeof member !== 'string'
      && typeof member !== 'number'
    ) {
      throw new TypeError('Redis Set 멤버는 문자열 또는 숫자여야 합니다.');
    }

    return String(member);
  });
}

async function sadd(
  key: string,
  ...members: RedisMemberInput[]
) {
  const values = normalizeRedisMembers(members);

  return client.sendCommand([
    'SADD',
    key,
    ...values,
  ]);
}

async function smembers(key: string) {
  return client.sendCommand([
    'SMEMBERS',
    key,
  ]);
}

members.flat()은 한 단계 중첩된 배열을 펼친다.

입력 타입은 문자열과 숫자로 제한하고, 런타임의 typeof 검사에서도 null과 객체를 거부한다.

검증을 통과한 숫자만 String()으로 Redis 명령 인자에 맞게 변환하므로 잘못된 값이 "[object Object]""null"로 조용히 저장되지 않는다.

sendCommand()는 배열의 첫 값부터 Redis 명령 이름과 인자로 그대로 보낸다.

이 방식은 당시 고수준 API의 차이를 빠르게 우회하고 실제 명령 형태를 코드에 드러내는 데 도움이 됐다.

다만 반환값 변환까지 자동으로 안전해지는 것은 아니다.

현재 node-redis 공식 문서도 sendCommand()HGETALL 예시가 고수준 hGetAll()과 달리 평평한 문자열 배열을 반환할 수 있음을 보여 준다.

명령을 직접 보낼수록 애플리케이션이 기대하는 반환 타입을 래퍼와 테스트에서 직접 책임져야 한다.

지금이라면 고수준 API를 먼저 사용한다

현재 node-redis는 Redis의 원래 명령 이름과 camelCase 형태를 모두 제공한다.

camelCase는 SMEMBERSsMembers처럼 여러 단어의 첫 글자를 대문자로 이어 쓰는 JavaScript 이름 규칙이다.

지원되는 Set 명령이라면 아래처럼 클라이언트의 고수준 API를 먼저 사용할 수 있다.

현재 node-redis의 지원 API를 쓰는 예시TypeScript
await client.sAdd('feature:members', [
  'alpha',
  'beta',
]);

const members = await client.sMembers('feature:members');
const one = await client.sRandMember('feature:members');
const many = await client.sRandMemberCount('feature:members', 2);

이 코드는 현재 API의 사용 방향을 보여 주는 공개용 예시이며, 당시 장애 코드의 정확한 버전별 시그니처를 재현한 것은 아니다.

설치한 버전의 TypeScript 타입과 공식 문서를 함께 확인해야 한다.

sendCommand()는 현재 공식 README에서 클라이언트가 아직 알지 못하는 명령이나 인자를 보낼 때 쓰는 탈출구로 안내된다.

따라서 새 구현에서는 다음 원칙이 더 낫다.

  1. 클라이언트가 지원하는 명령은 고수준 API를 우선 사용한다.
  2. 애플리케이션에 필요한 이름과 반환 타입은 얇은 래퍼로 고정한다.
  3. 지원되지 않은 명령만 sendCommand()로 보내고 반환값을 직접 검증한다.
  4. 라이브러리를 올리기 전에 래퍼의 계약 테스트를 새 버전에서 실행한다.

당시 sendCommand()로 통일한 판단이 틀렸다는 뜻은 아니다.

장애 복구 시점에는 빠르게 모호함을 제거하는 선택이었고, 현재의 장기 유지보수에서는 공식 지원 API와 타입 변환을 활용하는 편이 더 안전하다는 뜻이다.

반환 형태가 다른 명령을 같은 방식으로 다루면 안 된다

Set 명령은 이름이 비슷해도 반환 계약이 다르다.

명령역할확인할 반환값
SADD하나 이상의 멤버를 추가한다새로 추가된 멤버 수인 정수다
SMEMBERS모든 멤버를 읽는다순서가 보장되지 않는 값 목록이다
SRANDMEMBER key임의의 멤버 하나를 읽는다값 하나 또는 키가 없을 때 빈 결과다
SRANDMEMBER key count여러 임의 멤버를 읽는다count 부호에 따라 중복 규칙이 다른 목록이다

Set은 순서가 없는 집합이므로 SMEMBERS 결과의 배열 순서가 항상 같다고 테스트하면 안 된다.

테스트에서는 정렬한 뒤 비교하거나 집합으로 바꿔 같은 멤버가 있는지 확인한다.

SADD 반환값도 전체 멤버 수가 아니라 이번 호출로 새로 추가된 멤버 수다.

이미 있는 값 두 개와 새 값 하나를 넣으면 결과는 1이다.

SRANDMEMBER는 인자 유무에 따라 단일 값과 목록이 달라지므로 래퍼 메서드를 둘로 나누면 호출하는 코드가 실수하기 어렵다.

예를 들어 randomMember()randomMembers(count)처럼 반환 타입이 이름에서 드러나게 만든다.

메이저 업그레이드는 명령 몇 개만 확인하면 끝나지 않는다

node-redis의 공식 v3에서 v4 이전 문서는 버전 4를 큰 구조 변경으로 설명한다.

그 변경에는 기본 콜백에서 Promise로의 전환, 자동 연결 제거, createClient() 설정 변경과 일부 이벤트 제거가 포함된다.

Promise는 나중에 완료될 비동기 작업의 성공값이나 실패를 표현하는 JavaScript 객체다.

이 문서는 당시 장애의 정확한 버전이 v3에서 v4였다는 증거가 아니다.

다만 Redis 명령 시그니처만 고쳐도 연결 수명주기나 오류 처리 방식에서 문제가 남을 수 있다는 현재의 점검 근거로 사용할 수 있다.

아래 항목을 함께 확인해야 한다.

  • 클라이언트를 만든 뒤 connect()를 명시적으로 호출해야 하는지 확인한다.
  • 콜백과 Promise 중 어느 방식으로 오류를 전달하는지 확인한다.
  • 구독과 메시지 이벤트의 등록 방식이 바뀌었는지 확인한다.
  • 종료 메서드가 연결의 남은 명령을 기다리는지 즉시 끊는지 확인한다.
  • Cluster를 사용할 때 sendCommand() API가 일반 클라이언트와 다른지 확인한다.

Cluster는 Redis 데이터를 여러 노드에 나누어 저장하는 구성이다.

현재 node-redis README도 Cluster에서 직접 명령을 보내는 API가 다르다고 별도로 경고한다.

재현 테스트는 Redis 응답까지 포함한다

업무 기록으로 확인되는 결과는 롤백과 래퍼 수정까지이며, 아래 계약 테스트는 지금 같은 이전을 준비할 때 추가할 권장안이다.

Set 래퍼가 지켜야 할 계약의 예시TypeScript
it('중첩된 멤버를 추가하고 새 멤버 수를 반환한다', async () => {
  const added = await sets.add(
    'sample:set',
    ['alpha', ['beta']],
  );

  expect(added).toBe(2);
});

it('전체 멤버는 순서와 관계없이 같다', async () => {
  const members = await sets.members('sample:set');

  expect(new Set(members)).toEqual(
    new Set(['alpha', 'beta']),
  );
});

it('없는 키의 반환 형태를 고정한다', async () => {
  const members = await sets.members('missing:set');

  expect(members).toEqual([]);
});

모의 객체만 사용하는 단위 테스트로는 라이브러리가 실제 Redis 응답을 어떻게 변환하는지 놓칠 수 있다.

모의 객체는 실제 Redis 대신 미리 정한 값을 돌려주는 테스트용 가짜 객체다.

핵심 래퍼 테스트는 CI에서 실제 Redis 컨테이너를 띄워 실행하는 편이 좋다.

CI는 코드를 올릴 때 빌드와 테스트를 자동으로 실행하는 환경이다.

지원하는 Redis 버전과 node-redis 버전의 조합을 명시하면 다음 업그레이드에서 무엇이 바뀌었는지 비교하기 쉽다.

배포 전에 확인할 순서

  1. 현재와 목표 node-redis의 정확한 버전을 고정한다.
  2. 공식 이전 안내와 변경 내역에서 연결, 명령, 반환, 종료 방식을 확인한다.
  3. Redis 래퍼가 감싼 명령 목록과 호출 수를 찾는다.
  4. 단일 값, 배열, 숫자와 빈 결과를 포함한 계약 테스트를 만든다.
  5. 실제 Redis를 사용하는 통합 테스트에서 현재 버전의 결과를 기준으로 저장한다.
  6. 목표 버전에서 같은 테스트를 실행하고 차이가 나는 명령만 수정한다.
  7. 작은 트래픽부터 배포하고 오류율, Redis 지연과 연결 수를 비교한다.
  8. 문제가 생기면 바로 되돌릴 버전과 명령을 준비한다.

package-lock.json 같은 잠금 파일은 실제 설치 버전을 고정한다.

잠금 파일을 신뢰할 수 있게 설치하는 방법은 npm ci로 재현 가능한 Node.js 빌드 만들기에 정리했다.

오류를 재현 가능한 최소 조건으로 줄이는 과정은 재현 가능한 디버깅 노트를 만드는 방법에서 이어진다.

결론

이 장애는 Redis의 Set 자료 구조가 갑자기 달라져서 생긴 문제가 아니었다.

라이브러리 버전이 바뀌면서 애플리케이션 래퍼가 기대한 인자와 반환 계약이 어긋난 문제였다.

당시에는 15분 안에 롤백해 서비스를 먼저 살리고, Set 명령을 sendCommand() 기반으로 명시해 경계를 정리했다.

그 조치는 장애 대응 기록과 Git 변경 이력으로 확인된다.

다만 현재의 node-redis는 지원 명령에 고수준 API와 타입 변환을 제공한다.

지금이라면 고수준 API를 기본으로 쓰고, sendCommand()는 지원되지 않은 명령에 제한하며, 실제 Redis를 붙인 계약 테스트로 업그레이드를 검증한다.

라이브러리를 바꿀 때 지켜야 할 것은 메서드 이름이 아니라 입력, 반환값, 연결 수명주기로 이루어진 애플리케이션의 계약이다.

참고한 공식 문서