대규모 트래픽 환경에서의 캐시 마이그레이션

Hyeongjun Yu
17 min readMay 29, 2023
대규모 트래픽 환경에서의 캐시 마이그레이션

초기 서비스를 시작할 때는 대부분 대규모 트래픽에 대해서 고민하는 것은 오버 엔지니어링이 될 수있습니다. 하지만, 서비스가 폭발적으로 성장하는 단계가 되거나 많은 트래픽을 요청받아 처리하는 서비스의 경우 이에 맞는 아키텍처에 대해 다시 고민하게 됩니다. 대규모 트래픽 처리를 요구하는 상황에서 시스템 설계가 잘못되어 확장하기 어렵거나 대책을 마련하지 못하면 사용자 경험을 해치게 되거나, 서비스 장애로까지 이어지기도 합니다.

대규모 트래픽을 처리하는 서비스에서 사용자의 데이터를 빠르게 delivery 할 수 있는 캐시(Cache)는 큰 역할을 합니다. 하지만, 초기 서비스에서 쉽게 놓칠 수 있는 확장성의 부재로 캐시의 capacity를 늘리거나 물리적인 이동이 필요한 경우 자칫하면 서비스의 큰 장애로 이어질 수 있습니다.

아키텍처에서의 일반적인 캐시 역할

저는 대규모 트래픽이 오가는 글로벌 메신저 회사에서 6년간 코어팀으로 근무하면서 위와 같은 확장성에 대해 고민할 수 있는 기회가 많았습니다. 이번 글에서는 이와 유사한 상황으로, 캐시의 물리적인 이동과 capacity를 늘려야 하는 상황에서 서비스에 영향 없이 어떻게 캐시를 안정적으로 마이그레이션(Migration) 하고 개선했는지 소개하겠습니다.

대규모 트래픽 메시징 서비스에서의 캐시

메시징 서비스의 일반적인 형태

글로벌 메신저의 특성상 많은 나라로부터 다양한 형태, 텍스트, 이미지, 비디오, 오디오, 바이너리(Binary)의 형태로 데이터(Data)가 전달됩니다. 전달되는 과정을 좀 더 세부적으로 풀어 쓰면 아래와 같은 흐름으로 전달됩니다.

  1. 데이터 upstream
  2. 데이터 downstream
  3. 데이터 delivery
대규모 트래픽 서비스에서의 캐시 요청/응답 흐름

만약, 데이터 downstream 시점에서 매번 데이터의 원본이 저장된 스토리지를 확인한다면 데이터의 전달 속도는 늦어지므로 사용자 경험이 좋지 못합니다. 따라서 사용자 경험을 위해서 데이터는 빠르게 전달되어야 하기 때문에, 캐시는 데이터 downstream 시점에 유저의 데이터를 확인하여 delivery 합니다.

데이터의 특성과 유저의 서비스 패턴 등 각 서비스의 특수한 요소에 따라 시스템 아키텍처는 달라질 수 있습니다. 이러한 이유로, 같은 서비스더라도 트래픽의 규모에 따라 설계는 달라질 수 있으므로 유연한 설계가 중요해집니다. 제가 운영했던 플랫폼은 대규모 트래픽 서비스였으므로, 유저의 서비스 사용 패턴에 따라 캐시를 상황에 따라 다르게 적용하였습니다.

사용자의 서비스 사용에 따라 다른 데이터 접근

데이터 활용 관점에서, 일반적으로 사용자의 서비스를 사용하는 패턴은 크게 3가지로 나뉘게 됩니다.

  1. 사용자가 데이터를 업로드 한 직후, 데이터에 접근하는 경우
  2. 사용자가 데이터를 업로드 한 직후, 데이터에 접근하진 않지만 빈도가 높은 경우
  3. 사용자가 데이터를 업로드 한 직후, 데이터에 접근하는 빈도가 낮은 경우

캐시를 만약, 이 모든 상황에 동일하게 적용한다면 비효율적으로 동작하게 되고 비용이 높아질 수 있으므로 좋은 시스템 설계가 되지 못합니다. 사용자의 패턴에 따라 캐시는 같은 서비스에 적용하더라도 유연하게 적용될 수 있어야 합니다.

대규모 트래픽 서비스에서의 캐시 요청/응답 흐름

위와 같은 상황을 인지한다면, 캐시는 아래와 같이 적용될 수 있습니다.

  1. 즉시 접근되는 데이터의 경우, upstream 시점에 캐시에 push 한 후 곧바로 hit 될 수 있도록 한다
  2. 즉시 접근되는 않는 데이터의 경우, 다운로드 시점에 캐시에 hit 되지 않았을 때, pull 되도록 한다

물론, 2번의 상황의 경우 첫 다운로드 시점 즉, on-demand 방식으로 캐시에 곧바로 hit 되지 않으므로 스토리지에 접근하는 상황이 발생할 수 있습니다. 하지만, 그 이후 자주 접근이 되는 데이터라면 지속적으로 캐시에 hit가 되므로 스토리지 I/O를 줄일 수 있으므로 비용 또한 크게 줄어들게 될 것입니다.

기술은 항상 Trade-off를 감안하여 적용되므로, 아키텍트 관점에서는 비용과 안정성, 유저 경험을 고려하여 설계됩니다.

캐시 마이그레이션

대규모 트래픽이 실시간으로 발생되는 메시징 서비스에서 캐시는 상당히 중요한 역할을 합니다. 이러한 상황에서 캐시 서버의 노후화와 capacity를 늘려야 하는 필요성이 생겼습니다. 이를 위해서는 기존 설계의 문제점을 먼저 정리하고, 개선해야 할 기능들을 정의할 필요가 있었습니다.

기존 설계의 문제점

기존 설계의 문제점은 크게 두 가지였습니다.

확장하기 힘든 구조의 해싱 알고리즘

첫 번째로는 기존 해시 서버의 해싱 알고리즘은 서버 대수가 분모가 되어 특정 식별자를 기준으로 나누어 해싱 하는 구조로, 만약 해시 서버의 대수가 변경된다면 기존의 캐시에 접근했던 방식이 달라지므로 모든 데이터의 hit 율이 현저히 떨어진다는 문제가 있었습니다. 따라서, 캐시 서버의 capacity를 확장해야 하는 상황에서는 캐시 서버의 해싱 알고리즘을 다시 재정립할 필요가 있었습니다.

서비스 도중 해시 서버를 관리하기 어려운 구조

두 번째로는 Real-time 시점 즉, 서비스 도중에 캐시 서버의 변경 혹은 장애 상황이 발생되었을 경우 백엔드 어플리케이션의 재시작 없이 자율적으로 컨트롤하지 못하여 캐시 서버를 넣거나 빼지 못한다는 문제점이 있었습니다.

이 두 가지 문제의 핵심적인 공통 문제는 확장성의 부재였습니다. 따라서, 이 문제점들을 개선하기 위해서 가장 최우선 순위로는 확장성, 그리고 이를 운영하기 위한 operation에 초점을 맞추고 개선 작업을 시작하였습니다.

Consistent Hashing

Consistent Hashing을 도입한 이유

해싱 알고리즘에는 각 상황에 따라 여러 알고리즘 방식이 있습니다. 하지만 앞서, 위 문제점들을 해결하기 위해서는 확장성을 최우선 순위로 두어야 했으므로, 스케일링(Scaling)에 따라 확장되더라도 동일한 노드로 접근할 수 있는 Consistent Hasing 알고리즘을 도입하기로 했습니다.

그렇다면, Consistent Hasing은 무엇인지 또 이로 인해 얻을 수 있는 이점은 무엇인지에 대하여 기술해 보겠습니다.

개념

Consistent Hashing 기본 개념

분산 시스템에서 Consistent Hasing은 다음의 시나리오를 해결하는 데 도움이 됩니다.

  1. 캐시 서버에 대한 탄력적 확장을 제공합니다.
  2. NoSQL 데이터베이스 혹은 캐시와 같은 서버 노드 집합을 확장합니다.
Consistent Hashing 알고리즘

우리의 목표는 다음과 같은 캐시 시스템을 설계하는 것입니다.

  1. 요청되는 해시 키(Key)를 “n” 캐시 서버 집합 간에 균일하게 배포할 수 있어야 합니다.
  2. 캐시 서버를 동적으로 추가하거나 제거할 수 있어야 합니다.
  3. 캐시 서버를 추가 또는 제거할 때 서버 간에 최소한의 데이터 이동이 필요합니다.

Consistent Hasing은 확장 또는 축소할 때마다 모든 키를 다시 정렬하거나 모든 캐시 서버를 조작할 필요가 없도록 하여 수평적 확장성 문제를 해결할 수 있습니다.

작동 원리

Consistent Hashing 작동원리
  1. 해시 키 공간 만들기: [0, 2³²-1] 범위의 정수 해시 값을 생성하는 해시 함수가 있다고 가정합니다.
  2. 해시 공간을 Hash Ring으로 표현하겠습니다. 1단계에서 생성된 정수가 마지막 값이 둘러싸이도록 링에 배치된다고 가정합니다.
  3. Key Space(Hash Ring)에 캐시 서버 배치를 배치하고 해시 함수를 사용하여 각 캐시 서버를 링의 특정 위치에 매핑합니다. 예를 들어 4개의 서버가 있는 경우 해시 함수를 사용하여 IP 주소의 해시를 사용하여 다른 정수에 매핑할 수 있습니다.
  4. 서버의 키 배치가 결정됩니다.
  5. Hash Ring에 서버를 추가하거나 제거하더라도 캐시 서버를 조작할 필요가 없습니다.

Production에 적용되는 예제

Consistent Hashing 예제

특정 해시 링에서 해시 키와 서버를 배치하였다고 가정해 보겠습니다.

해시 키가 시스템에서 트리거 되면 할당된 가장 가까운 서버에서 데이터를 찾으려고 시도합니다. 이 순환 또는 배치는 시스템 설계에 따라 조정될 수 있습니다. 이러한 각 캐시 서버는 시스템 설계에서 “노드(Node)”라고 하며 여기서는 A, B, C 및 D로 표시되며 시계 방향으로 배치되고 그 뒤에 키가 존재합니다.

이제 시스템에서 “Cairo, Eygpt”에 대한 데이터 요청을 받으면 해당 노드, 즉 “A”에서 해당 정보를 먼저 찾습니다. 마찬가지로 “런던, 영국 및 도쿄, 일본” 키의 경우 가장 가까운 해당 위치 또는 노드는 시계 방향으로 “D”이므로 해당 특정 노드와 상호 작용하여 데이터를 검색합니다.

기존 해싱과 달리 시스템이 서버 오류, 추가 또는 제거 문제에 직면하면 요청 또는 데이터 키가 가장 가까운 서버 또는 노드에 자동으로 연결되거나 할당됩니다.

기존의 해싱 방식은 서버 문제 또는 이슈 발생 시 네트워크를 통한 요청을 사용하고 처리하기에는 충분하지 않았습니다. 고정된 수의 서버가 있고 키와 서버의 매핑이 한 번에 발생한다고 가정합니다.

서버 추가의 경우 새로운 서버에 대한 객체의 재매핑(Remapping) 및 해싱과 많은 계산이 필요합니다. 반면에 Consistent Hashing에서 노드를 비선형으로 배치하면 시스템이 변경되는 경우 노드가 서로 상호 작용할 수 있습니다.

그러나 종종 분배 또는 로드가 Hash Ring에 있는 모든 노드에 대해 동일하거나 비례하지 않아 분배가 불균형하게 되는 경우가 있습니다. Consistent Hashing 시스템이 서버/노드의 추가 또는 제거 상황에서 어떻게 대응하고 시스템에 불균형을 일으키지 않도록 하는지 좀 더 알아보겠습니다.

핫스팟

고르지 않게 분산된 데이터 요청의 부하를 견디는 노드는 핫스팟(Hotspot)이 됩니다. 해시 링의 모든 이전 요청을 처리하기 때문입니다. 이 문제를 해결하기 위해 시스템 엔지니어는 가상 노드(Virtual Node)를 사용해서 해시 링을 활성화하여 모든 활성 노드 간에 요청을 균등하게 분배할 수 있습니다.

Consistent Hashing에서의 서버 추가 및 제거

Consistent Hashing 에서의 서버 추가 및 제거

링에 새로운 노드를 추가하면, 예를 들어 “Srushtoka & Freddie” 키 사이에 새로운 노드를 추가합니다. 처음에 <Node 5>는 위 그림과 같이 두 키를 모두 처리하고 있었습니다. 이제 새로운 서버 <Node 6>가 이후에는 “Freddie” 키에 대한 해시 또는 할당이 <노드 5>가 아닌 <노드 6>에 할당 또는 매핑됩니다. 그러나 “Srushtika” 키 할당은 <노드 5>에 매핑된 상태로 유지됩니다.

링에 기존 서버를 제거하는 경우에도 이와 같은 원리로 진행됩니다. 따라서, 해시 링은 서버를 추가 또는 제거하거나 노드의 장애가 발생하는 경우 전체 프로세스에 영향이 가지 않도록 합니다. 또한, 재할당이 발생되는 상황이 오더라도 기존 해싱 메커니즘에 비해 많은 시간이 소요되지 않습니다.

주의해야할 점과 이점

캐시 서버의 클러스터가 있고 트래픽 부하에 따라 탄력적으로 확장 또는 축소해야 할 수 있어야 합니다. 예를 들어, 추가 트래픽을 처리하기 위해 크리스마스 혹은 새해 기간 동안 더 많은 서버를 추가하는 경우가 일반적인 케이스입니다. 따라서, 트래픽 부하에 따라 탄력적으로 확장 또는 축소해야 하는 캐시 서버 집합이 준비되어야만 합니다.

위와 같이 탄력적으로 캐시 클러스터를 운용하는 상황에서 Consistent Hasing은 빛을 발합니다. 이로 인해 얻을 수 있는 장점을 정리하면 아래와 같습니다.

  • 데이터베이스 혹은 캐시 서버 클러스터의 Elastic 스케일링이 가능합니다
  • 서버 간의 데이터를 복제하거나 파티셔닝(Partitioning)을 하기 수월해집니다
  • 데이터를 분할하더라도 핫스팟을 완화하는 균일한 배포가 가능합니다
  • 시스템 전체의 고가용성을 가능하게 합니다

유연하게 대응할 수 있는 설계

Consistent Hashing으로의 해싱 알고리즘 변경으로 확장하기 쉬운 형태의 캐시 서버는 준비되었습니다. 하지만, 준비된 캐시 서버 군으로 기존의 캐시 클러스터를 대체하거나 추가하기 위해서 추가적인 준비가 더 필요하게 됩니다. 서비스 중단 없이 캐시 서버를 마이그레이션 하고 기존의 클러스터를 변경하기 위해서는 백엔드 어플리케이션에서 서비스 재시작 없이 설정을 읽어서 반영하는 즉, Hot-reload를 지원하도록 해야 합니다. 만약 서비스가 대규모 트래픽을 기반으로 한다면 더욱이 이 작업을 준비하고 진행하는데에 문제가 없는지 세밀하게 점검할 필요가 있습니다.

마이그레이션을 위한 준비

먼저, 백엔드 어플리케이션에서 서비스 중단 없이 변경된 캐시 서버를 추가하거나 제거하기 위해 많은 부분을 설정으로 빼는 작업을 했습니다. 이 과정에서는 반드시 설정으로 컨트롤해야 하는 정보들만 대상으로 하여야 하며, 그 이유가 모호하지 않고 명확해야 합니다.

시나리오와 대응

위 설정화 작업이 완료된 후, 성공적인 시나리오와 도중 실패되는 시나리오로 나누어 케이스에 따라 대응할 수 있도록 준비했습니다.

대규모 트래픽 서비스에서의 성공적인 캐시 마이그레이션 시나리오

성공적인 시나리오

  1. 각 캐시 서버 군을 region과 같은 특정 요소를 기준으로 차례대로 캐시 서버 마이그레이션을 진행한다
  2. 새롭게 구성되는 캐시 클러스터로 데이터 마이그레이션이 완료되어 새로운 캐시 클러스터의 hit ratio가 100%에 가까워진다
  3. 기존의 캐시 클러스터로의 요청이 적어지며 hit ratio는 0%에 가까워진다
  4. 기존의 캐시 클러스터를 서비스 재시작 없이 설정으로 제거한다
  5. 이제 모든 데이터의 요청은 새롭게 구성되는 캐시 클러스터에서 delievery 하게 된다
대규모 트래픽 서비스에서의 캐시 마이그레이션 실패시 시나리오

실패하는 시나리오

  1. 각 캐시 서버 군을 region과 같은 특정 요소를 기준으로 차례대로 캐시 서버 마이그레이션을 진행한다
  2. 새롭게 구성되는 캐시 클러스터로 데이터 마이그레이션이 진행되는 도중, 기존 데이터 해싱과 섞이면서 데이터가 깨지는 현상이 발생한다
  3. 새로운 캐시 클러스터를 모두 제거하고, 기존의 캐시 클러스터로만 요청되도록 Rollback을 진행한다
  4. 기존의 백엔드 어플리케이션 서버 혹은 스토리지 I/O가 높아지면서 Dead Lock 상황이 발생한다
  5. 시스템 리소스를 모니터링하여, 상황을 지켜본 후 백엔드 어플리케이션 서버를 기존 대비 20~50% 추가 투입한다
  6. 스토리지 I/O를 감당할 수 있도록 Circuit Breaker를 통해 일부 요청을 임시로 throttling 한다
  7. 시스템이 안정화되면 분석 작업을 진행하여 원인을 파악한다

성공적인 시나리오와 실패할 수 있는 시나리오를 먼저 고민하고, 이 과정에서 놓친 작업과 케이스는 없는지 스스로, 그리고 팀원들과의 리뷰를 통해 점검했습니다. 시나리오에 문제가 없다면 이에 필요한 기능들을 나열하여 차례대로 구현했습니다.

테스트

대규모 트래픽을 기반으로 하는 서비스에 테스트 없이, 캐시 마이그레이션을 바로 진행하는 경우 아무런 문제 없이 성공할 확률은 극히 적습니다. 많은 시나리오와 이에 대한 대응을 준비하였다고 해도 엔지니어가 모든 상황을 짐작할 수 없고, 인간은 언제나 실수를 하므로 작은 이슈라도 발생할 가능성이 클 수밖에 없습니다.

대규모 트래픽 서비스에서의 캐시 마이그레이션 테스팅 방식

따라서, 저는 Production 환경에서 캐시 마이그레이션을 하기 앞서, 테스트를 2가지로 나누어서 진행했습니다.

  1. Development 환경에서 작은 규모로 시뮬레이션을 진행한다
  2. Production 환경에서 가장 요청량이 적은 서버 군을 대상으로 Canary 테스트를 여러 번 진행한다

위의 2가지 테스트를 위해 모두 Production 환경과 동일한 모니터링 및 알람 시스템을 갖추고 진행했습니다

개발 환경에서의 시뮬레이션

Development 환경에서 작은 규모로 시뮬레이션을 진행하는 경우에는 Mock 데이터를 기반으로 하는 트래픽을 일으켜서 작은 규모 대비 높은 수준의 트래픽을 받도록, 즉 스트레스 테스트도 같이 병행했습니다. 하지만 이 시뮬레이션에서는 실제 Production 환경과 동일하게 테스트해 볼 수 없는 단점이 있었습니다.

Production 환경에서의 Canary 테스트

Canary 테스트

개발 환경에서의 테스트가 커버하지 못했던 부분은 실제 유저의 트래픽이 아닌 Mock 데이터 트래픽이었기 때문에, Production 환경에서 캐시 마이그레이션 하는 당시의 유저의 시간, 그 당시의 이벤트, 날씨 등등 여러 상황적 요소가 고려되지 못한다는 점입니다. 이러한 테스트는 Development 환경에서는 커버하기 어려운 테스트라고 판단하여, Production 환경에서 Canary 테스트를 여러 번 하는 것으로 보완하고자 했습니다. 다만, Canary 테스트는 Production 환경인 만큼 서비스에 영향을 줄 수 있으므로 요청량이 가장 적은 region의 서버 군을 대상으로 하고 가장 적은 요청량이 발생되는 시간을 기준으로 테스트를 진행했습니다.

마이그레이션 작업

성공적인 캐시 마이그레이션을 위해 성공했을 때, 그리고 실패했을 때의 모든 시나리오를 작성하고 그에 맞는 기능을 개발한 뒤 테스트 또한 작은 규모로 여러 번 진행했기 때문에 큰 이변이 없는 경우 마이그레이션 작업에 차질이 없을 것이라고 생각했습니다. 실제로 Production 환경에서 이 작업을 진행한 이후로 기존의 캐시 클러스터에서 새로운 캐시 클러스터로 마이그레이션이 되기까지 약 한 달의 기간이 소요되었습니다. 다양한 클라이언트 혹은 레거시(Legacy) 클라이언트 코드로 인해 잔류할 수 있는 트래픽이 조금은 남아있었기 때문에 오랜 기간이 걸리게 됩니다.

새로운 캐시 클러스터를 투입한 이후로 지속적인 모니터링을 진행하고, 알람을 세밀하게 관리하였기 때문에 적절한 시점에 기존의 캐시 클러스터를 제거할 수 있었습니다. 또한, 캐시 마이그레이션을 통해 안정적인 서비스를 유지하기 위해, 성공하는 것을 목표로 하는 것이 아닌 실패율을 줄이는 것에 초점을 맞추었기 때문에 실패 시나리오에서의 유연한 대응책을 마련할 수 있었습니다. 결국 이러한 과정을 통해 한 달의 기간 동안 단 1건의 이슈 없이 캐시 마이그레이션을 성공적으로 마칠 수 있었습니다.

마치며

이번 글에서는 대규모 트래픽을 기반으로 하는 서비스에서의 캐시가 갖는 의미에 대해 소개하고 이러한 서비스의 설계에서의 어떤 확장성 부재가 발생되는지, 그리고 캐시를 확장하고자 할 때 발생되는 이슈와 이를 어떻게 해결해 나아갔는지에 대해서 설명했습니다.

일반적으로 이러한 변경사항은 대규모 트래픽을 기반으로 하는 서비스에서는 크게 부담될 수 있는 변경사항이기 때문에 항상 생각의 오류가 없는지 의심해 봐야 합니다. 또한, 캐시 마이그레이션이라는 단편적인 목표를 달성하는 것에 그치는 것이 아닌 향후 이와 유사한 니즈(Needs)가 생겼을 때 “현재 시스템으로 대응할 수 있는가?” 혹은 “확장할 수 있는가?”에 대해서 고민을 많이 했습니다. 만약, 캐시 마이그레이션을 단편적인 목표로 두었다면 기존의 해싱 알고리즘을 기반으로 단순히 서버 대수를 늘리는 것에 그쳤을 것입니다.

대규모 서비스의 아키텍처를 고민할 때는 trade-off를 해야 하는 상황에 많이 부딪히게 됩니다. 이러한 상황에서는 단편적인 목표 달성보다는 목표 달성을 위해 실패할 수 있는 시나리오를 미리 계획하고 이를 줄이는 것을 목표로 삼는 것이 문제를 해결하는데 효율적인 접근법이 될수 있습니다.

이 글을 통해 저의 경험이 많은 분들께 도움이 되기를 바라며 앞으로도 성공적인 프로덕트를 위해 고민하시는 많은 분들께 저의 경험과 지식을 나눌 수 있기를 희망해 보며 글을 마치겠습니다.

--

--