Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

서버리스 환경의 최대 난제, 데이터베이스 연결 문제 완전 정복

서버리스용 데이터베이스를 설계할 때 가장 큰 고민은 요청 단위 과금(per-request pricing)을 수익성 있게 지원하는 인프라를 구축하는 것이었습니다. 우리는 Upstash가 이 목표를 달성했다고 믿습니다. 하지만 제품을 출시한 후 또 하나의 중요한 과제에 직면하게 되었습니다. 바로 데이터베이스 연결(Connection) 문제입니다.

왜 서버리스에서 연결 관리가 어려운가?

서버리스 함수(Serverless Functions)는 0부터 무한대까지 확장됩니다. 트래픽이 몰리면 클라우드 제공자는 새로운 컨테이너(람다 함수)를 병렬로 생성해 백엔드를 스케일 아웃합니다. 이때 함수 내부에서 새로운 데이터베이스 연결을 생성하면 순식간에 데이터베이스의 연결 한도에 도달할 수 있습니다.

반대로 람다 함수 외부에서 연결을 캐싱하려고 하면 다른 문제가 발생합니다. AWS가 람다 함수를 동결(freeze)할 때 해당 연결을 종료하지 않기 때문입니다. 그 결과 유휴 상태의 좀비(zombie) 연결이 대량으로 쌓여 여전히 시스템에 위협이 됩니다.

이 문제는 Redis에만 국한된 것이 아닙니다. TCP 연결에 의존하는 모든 데이터베이스(MySQL, PostgreSQL, MongoDB 등)에 공통적으로 적용되는 문제입니다. 실제로 서버리스 커뮤니티에서는 serverless-mysql 같은 해결책을 만들어 왔지만, 이들은 모두 클라이언트 측 솔루션입니다. 반면 Upstash는 서버 측을 직접 구현하고 유지 관리할 수 있는 강점이 있습니다.

그래서 우리는 연결을 모니터링하고 유휴 연결을 회수(evict)하는 방식으로 문제를 완화하기로 결정했습니다. 알고리즘은 다음과 같습니다. 각 데이터베이스에는 최대 동시 연결 수에 대해 소프트 리밋(soft-limit)과 하드 리밋(hard-limit)이라는 두 가지 한도가 설정됩니다. 데이터베이스가 소프트 리밋에 도달하면 유휴 연결 종료를 시작하고, 하드 리밋에 도달하기 전까지는 새로운 연결 요청을 계속 수락합니다. 하드 리밋에 도달하면 그때부터 새로운 연결을 거부합니다.

연결 회수(Eviction) 알고리즘

if( current_connection_count < SOFT_LIMIT ) {
    ACCEPT_NEW_CONNECTIONS
}

if( current_connection_count > SOFT_LIMIT && current_connection_count < HARD_LIMIT ) {
    ACCEPT_NEW_CONNECTIONS
    START_EVICTING_IDLE_CONNECTIONS
}

if( current_connection_count > HARD_LIMIT ) {
    REJECT_NEW_CONNECTIONS
}

참고로 Upstash 문서에 명시된 최대 동시 연결 한도는 위 알고리즘의 소프트 리밋 기준입니다.

임시 연결(Ephemeral Connections): 클라이언트 측 대응법

위 알고리즘을 배포한 후 모든 리전에서 연결 거부 건수가 크게 감소했습니다. 하지만 더 안전하게 운영하고 싶다면 사용자 측에서도 문제를 해결할 수 있습니다. 연결을 재사용하는 대신, 함수 내부에서 Redis 연결을 열고 작업이 끝나면 즉시 닫는 방식입니다.

exports.handler = async (event) => {
  const client = new Redis(process.env.REDIS_URL);
  /*
    do stuff with redis
    */
  await client.quit();
  /*
    do other stuff
    */
  return {
    response: "response",
  };
};

위 코드는 동시 연결 수를 최소화하는 데 도움이 됩니다. 많은 분들이 새 연결 생성 시 발생하는 지연 시간 오버헤드를 궁금해하는데, Redis 연결은 매우 가볍다고 알려져 있습니다.

Redis 연결은 정말 가벼울까? 벤치마크 결과

Redis 연결이 얼마나 가벼운지 확인하기 위해 벤치마크 테스트를 진행했습니다. 두 가지 접근 방식의 지연 시간을 비교했습니다.

1. 임시 연결(EPHEMERAL CONNECTIONS): 연결을 재사용하지 않고, 명령어마다 새로운 연결을 생성한 후 즉시 닫습니다. 클라이언트 생성, ping(), client.quit()의 지연 시간을 함께 기록합니다. 아래 코드의 benchEphemeral() 메서드를 참고하세요.

2. 연결 재사용(REUSE CONNECTIONS): 연결을 한 번만 생성하고 모든 명령어에 동일한 연결을 재사용합니다. 이 경우 ping() 작업의 지연 시간만 기록합니다. 아래 코드의 benchReuse() 메서드를 참고하세요.

async function benchReuse() {
  const client = new Redis(options);
  const hist = hdr.build();
  for (let index = 0; index < total; index++) {
    let start = performance.now() * 1000; // to μs
    client.ping();
    let end = performance.now() * 1000; // to μs
    hist.recordValue(end - start);
    await delay(10);
  }
  client.quit();
  console.log(hist.outputPercentileDistribution(1, 1));
}

async function benchEphemeral() {
  const hist = hdr.build();
  for (let index = 0; index < total; index++) {
    let start = performance.now() * 1000; // to μs
    const client = new Redis(options);
    client.ping();
    client.quit();
    let end = performance.now() * 1000; // to μs
    hist.recordValue(end - start);
    await delay(10);
  }
  console.log(hist.outputPercentileDistribution(1, 1));
}

직접 벤치마크를 실행해 보고 싶으시다면 공개된 저장소(repo)를 활용하세요.

이 벤치마크 코드를 AWS EU-WEST-1 리전에서 두 가지 다른 환경으로 실행했습니다. 첫 번째는 SAME ZONE, 즉 클라이언트와 데이터베이스가 같은 가용 영역(Availability Zone)에 있는 환경입니다. 두 번째는 INTER ZONE, 즉 클라이언트와 데이터베이스가 서로 다른 가용 영역에 있는 환경입니다. 데이터베이스 서버로는 Upstash Standard 타입을 사용했습니다.

그 결과, 새로운 연결을 생성하고 닫는 오버헤드(임시 연결 방식)는 99번째 백분위수 기준 75 마이크로초에 불과했습니다. 인터존 환경에서도 오버헤드는 거의 유사했습니다(80 마이크로초).

이어서 동일한 테스트를 AWS 람다 함수 내부에서 반복했습니다. 결과는 달랐습니다. 특히 람다 함수의 메모리를 낮게 설정(128MB)했을 때 Redis 연결의 오버헤드가 더 커졌고, 람다 함수 내부에서는 최대 6~7 밀리초의 지연 시간 오버헤드가 관찰되었습니다.

Redis 연결에 대한 우리의 결론은 다음과 같습니다.

  • 충분한 CPU 성능을 갖춘 시스템에서는 Redis 연결이 실제로 매우 가볍습니다. t2.micro에서도 마찬가지입니다.
  • AWS 람다의 기본 구성에서 CPU 성능은 매우 낮아, 람다 함수의 전체 실행 시간 대비 TCP 연결 비용이 크게 증가합니다.
  • 람다 함수를 기본/최소 메모리로 사용한다면, 함수 외부에서 Redis 연결을 캐싱하는 것이 좋습니다.

동결된 컨테이너가 만드는 좀비 연결 문제

일부 AWS 람다 환경에서 연결이 상당한 오버헤드를 가질 수 있음을 확인한 후, 우리는 람다 내부의 '연결 재사용'에 대해 추가 테스트를 진행했습니다. 그리고 또 다른 문제를 발견했습니다. 아무도 보고하지 않았던 엣지 케이스였습니다.

문제가 발생하는 타임라인은 다음과 같습니다.

STEP 1 - 0초: 연결을 람다 함수 외부에 캐싱하며 요청을 보냅니다.

if (typeof client === "undefined") {
  var client = new Redis("REDIS_URL");
}

module.exports.hello = async (event) => {
  let response = await client.get("foo");
  return { response: response + "-" + time };
};

STEP 2 - 5초: AWS가 잠시 후 컨테이너를 동결합니다.

STEP 3 - 60초: Upstash는 유휴 연결에 대해 60초 타임아웃을 적용합니다. 따라서 연결을 종료하지만, 클라이언트가 동결 상태라 ACK를 받을 수 없습니다. 그结果 서버 측 연결은 FIN_WAIT_2 상태로 들어갑니다.

STEP 4 - 90초: Upstash 서버가 연결을 완전히 종료하여 FIN_WAIT_2 상태에서 빠져나옵니다.

STEP 5 - 95초: 클라이언트가 동일한 요청을 보내면 ETIMEDOUT 예외가 발생합니다. 클라이언트는 연결이 열려 있다고 판단하지만 실제로는 그렇지 않기 때문입니다. 🤦🏻 🤦🏻 🤦🏻

STEP 6 - 396초: 마지막 요청 5분 후, AWS가 컨테이너를 완전히 종료합니다.

STEP 7 - 400초: 클라이언트가 다시 동일한 요청을 보내면 이번에는 정상 동작합니다. 컨테이너가 처음부터 새로 생성되어 초기화 단계가 생략되지 않고, 새로운 연결이 만들어지기 때문입니다.

위에서 볼 수 있듯이, AWS는 컨테이너를 해동하고 기존 연결을 재사용합니다. 하지만 해당 연결은 이미 서버 측에서 종료된 상태였고, 함수가 동결되어 있어 이 사실을 전달받지 못했습니다. 즉, Upstash가 유휴 연결을 회수하는 시점과 AWS가 유휴 함수를 처리하는 시점 사이의 동기화 문제입니다. AWS가 함수를 종료한 후에만 유휴 연결을 회수한다면 이런 문제는 발생하지 않습니다.

우리는 AWS가 유휴 함수를 약 300초 후에 종료한다고 가정하고, Upstash의 연결 타임아웃을 310초로 변경했습니다. 변경 후 이 문제는 사라졌습니다. 다만 AWS가 유휴 함수를 언제 종료하는지 투명하게 공개하지 않기 때문에, 지속적인 테스트와 모니터링을 통해 문제 재발 여부를 확인해야 합니다.

이 문제는 serverless-mysql 라이브러리에서 보고된 이슈와 상당히 유사합니다. 해당 이슈의 댓글에서는 ETIMEDOUT 예외 발생 시 요청을 재시도하라고 제안했지만, 재시도에는 두 가지 단점이 있습니다. 첫째, 실제 네트워크 문제로 타임아웃된 쓰기(write) 요청을 재시도하면 중복 처리될 수 있습니다. 둘째, 실패한 요청으로 인한 추가 지연 시간이 발생합니다.

GraphQL도 좋은 대안입니다

연결 문제에서 완전히 벗어나는 방법 중 하나는 연결이 필요 없는(connectionless) API를 사용하는 것입니다. Upstash는 Redis 프로토콜 외에 GraphQL API도 지원합니다. GraphQL은 HTTP 기반이므로 연결 한도 문제 자체가 없습니다. 지원되는 명령어는 문서를 참고하세요. 단, GraphQL API는 Redis 프로토콜 대비 약 5밀리초의 지연 시간 오버헤드가 있다는 점은 유의해야 합니다.

결론: 상황별 최적 전략

Upstash는 서버리스 애플리케이션에 원활한 경험을 제공하기 위해 데이터베이스를 맞춤형으로 최적화합니다. 새로 도입한 서버 측 알고리즘은 AWS 람다가 대량으로 생성하는 비활성 연결을 제거합니다. 람다 함수 내부에서 Redis 클라이언트를 열고 닫는 방식으로 연결 수를 최소화할 수도 있지만, 함수 메모리가 1GB 미만이라면 지연 시간 오버헤드가 발생할 수 있습니다.

서버리스 사용 사례에 대한 우리의 최종 권장 사항은 다음과 같습니다.

  • 지연 시간에 민감한 경우(예: 6밀리초도 큰 부담인 경우) → Redis 클라이언트를 재사용하세요.
  • 동시 클라이언트 수가 매우 많은 경우(1,000개 초과) → Redis 클라이언트를 재사용하세요.
  • 지연 시간에 민감하지 않은 경우 → 함수 내부에서 Redis 클라이언트를 열고 닫으세요.
  • 함수 메모리가 1GB 이상인 경우 → 함수 내부에서 Redis 클라이언트를 열고 닫으세요.

피드백이 있다면 Twitter나 Discord를 통해 알려주세요.