Introduction
안녕하세요, 데브필입니다. 오늘은 우버 엔지니어링 팀의 최신 기술 사례를 소개해드리려 합니다. "How Uber Uses Integrated Redis Cache to Serve 40M Reads/Second?"이라는 제목의 이 글은 Uber가 자체 개발한 분산 데이터베이스 Docstore와 Redis 기반 통합 캐시 솔루션 CacheFront를 통해 초당 4천만 건의 읽기 요청을 처리하는 과정을 상세히 다루고 있습니다.
이 글은 단순히 우버가 무엇을 했는지 그 성과를 전하는데 초점을 맞추고 있지 않습니다. CacheFront의 설계 목표부터 아키텍처 개요, 캐시 읽기 처리 흐름, 캐시 무효화 전략, 그리고 확장성과 회복탄력성을 확보하기 위해 사용된 비교(compare) 캐시, 캐시 워밍(warming) 등 다양한 기술적 디테일을 깊이 있게 다루고 있기 때문인데요. 아키텍처에 캐시 설계를 한 번이라도 고려해본 분이라면 많은 인사이트를 얻어가실 수 있을 거예요.
뿐만 아니라 이들의 치밀한 설계와 창의적인 문제 해결 과정을 따라가다 보면, 대규모 시스템에서 발생할 수 있는 다양한 이슈들을 예측하고 대비하는 노하우를 엿볼 수 있습니다. 여러 전략을 통해 시스템을 개선해 나가는 모습에서 비슷한 도전적인 과제에 직면한 우리 개발자들에게 실질적인 영감을 줄 것이라 확신합니다.
저희는 독자 여러분이 이런 고급 지식과 인사이트를 습득하여 최고의 개발자로 성장하시기를 진심으로 돕고 싶습니다. 그럼 이제부터 우버의 첨단 캐시 솔루션으로 빠져보시죠 :)
이 글은 우버 블로그 원문을 재가공한 ByteByteGo Newsletter의 How Uber Uses Integrated Redis Cache to Serve 40M Reads/Second?를 번역했습니다.
2020년 Uber는 Docstore라는 자체 개발한 분산형 데이터베이스를 출시했습니다. 이 데이터베이스는 MySQL 위에 구축되었으며 수십 페타바이트의 데이터를 저장하면서 초당 수천만 건의 요청을 처리할 수 있었죠.
우버는 수 년에 걸쳐 모든 비즈니스 부문에서 Docstore를 채택해 서비스를 구축했습니다. 이러한 애플리케이션 대부분은 높은 워크로드를 지원하면서도 데이터베이스에서 낮은 지연 시간, 더 높은 성능 및 확장성을 필요로 했어요.
저지연 데이터베이스 읽기의 과제
모든 데이터베이스는 고도로 확장 가능한 설계와 함께 낮은 지연 시간의 읽기 액세스가 필요한 애플리케이션을 다룰 때 어려움에 직면하죠. 이러한 과제 중 일부는 아래와 같은 것들이 있습니다.
- 디스크에서 데이터 검색 속도에는 임계값이 있습니다. 그 이상으로는 애플리케이션의 데이터 모델과 쿼리를 최적화하여 지연 시간을 개선하더라도 더 이상의 성능을 짜낼 수 없죠.
- 수직 확장은 많은 도움이 될 수 있지만 더 나은 호스트로 업그레이드하여 더 많은 리소스를 할당하는 것에는 한계가 있습니다. 결국 데이터베이스 엔진이 병목(Bottleneck)으로 변하게 됩니다.
- 데이터베이스를 여러 파티션으로 분할하는 수평 확장은 좋은 접근 방식입니다. 그러나 시간이 지남에 따라 운영이 더 복잡해지고 핫 파티션과 같은 문제를 제거하지 못하죠.
- 수직 및 수평 확장 전략은 장기적으로 비용이 많이 듭니다(참고로 두 지역에 걸쳐 3개의 stateful 노드를 처리하려면 비용이 6배로 증가하는 것 아시나요?).
이러한 과제를 극복하기 위해 마이크로서비스 아키텍처에서는 일반적으로 캐싱을 활용합니다. Uber 역시 다양한 팀을 위한 분산 캐싱 솔루션으로 Redis를 제공하기 시작했습니다. 그들은 서비스가 데이터베이스와 캐시에 쓰고 캐시에서 직접 읽기를 처리하는 일반적인 캐싱 설계 패턴을 따랐습니다.
아래 다이어그램은 이에 대한 패턴을 보여주고 있는데요,
그러나 서비스가 캐시 관리를 담당하는 일반적인 캐싱 패턴은 우버 정도 규모라면 몇 가지 문제를 일으킵니다.
- 각 팀은 자체적으로 Redis 캐시 클러스터를 관리해야 합니다.
- 캐시 무효화 로직이 여러 마이크로서비스에 중복되어 있고 편차가 발생할 가능성이 있습니다.
- 서비스는 지역 장애 조치 시 캐시 복제를 유지해야 hot한 상태를 유지해야 합니다.
중요한 점은 캐싱이 필요한 모든 팀이 맞춤형 캐싱 솔루션을 저마다 구축하고 유지 관리하는 데 많은 노력을 기울여야 한다는 것입니다.
이를 피하기 위해 Uber는 CacheFront라는 통합 캐싱 솔루션을 구축하기로 결정했습니다.
CacheFront를 활용한 설계 목표
CacheFront를 구축하는 동안 Uber는 다음과 같은 몇 가지 중요한 설계 목표를 염두에 두었다고 합니다:
- 낮은 지연 시간의 읽기 요청을 지원하기 위해 수직 또는 수평 확장의 필요성 감소
- P50 및 P99 지연 시간 개선 및 지연 시간 스파이크 안정화
- 데이터베이스 엔진 계층에 대한 리소스 할당 감소
- 개별 팀이 자신의 필요에 따라 만든 수많은 맞춤형 캐싱 솔루션 대체. 대신 Redis 유지 관리 및 지원에 대한 소유권을 Docstore 팀으로 이전.
- 팀이 비즈니스 로직에만 집중할 수 있도록 서비스 관점에서 캐싱을 투명하게 만듦
- 핫 파티션을 피하기 위해 Docstore의 파티셔닝 스키마에서 캐싱 솔루션 분리
- 저렴한 호스트 머신으로 캐싱 계층의 수평 확장성을 지원하고 전체 운영을 비용 효율적으로 만듦
CacheFront를 활용한 고수준 아키텍처
이러한 설계 목표를 지원하기 위해 Uber는 Docstore와 연결된 통합 캐싱 솔루션을 만들었습니다. 아래 이미지는 CacheFront와 함께 Docstore의 고수준 아키텍처를 보여줍니다.
보시다시피 Docstore의 쿼리 엔진은 서비스의 진입점 역할을 하며 클라이언트에 대한 읽기 및 쓰기를 제공할 책임이 있습니다.
따라서 캐시를 디스크 기반 스토리지에서 분리할 수 있도록 캐싱 계층을 통합하기에 이상적인 장소였습니다. 쿼리 엔진은 캐시 항목을 무효화하는 메커니즘과 함께 캐시된 데이터를 저장하기 위해 Redis에 대한 인터페이스를 구현했습니다.
캐시된 읽기 처리
CacheFront는 읽기와 관련하여 cache aside 또는 look aside 전략을 사용합니다. 아래 단계는 작동 방식을 설명합니다.
- 쿼리 엔진 계층이 하나 이상의 행에 대한 읽기 요청을 받음
- 쿼리 엔진은 Redis에서 행을 가져오려고 시도하고 응답을 사용자에게 스트리밍합니다.
- 다음으로 데이터베이스에서 나머지 행을 검색합니다(필요한 경우).
- 쿼리 엔진은 캐시에서 찾을 수 없는 행으로 Redis를 비동기식으로 채웁니다.
- 남은 행을 사용자에게 스트리밍합니다.
아래 이미지를 참조하면 더 명확히 프로세스를 이해할 수 있습니다.
CDC를 사용한 캐시 무효화
아마 지금까지 수백만 번 들었을 수 있겠지만, 캐시 무효화(cache invalidation)는 컴퓨터 과학에서 어려운 두 가지 중 하나입니다.
가장 간단한 캐시 무효화 전략 중 하나는 TTL(Time-to-Live)로, TTL 시간을 넘으면 캐시 항목이 만료되도록 하는 것입니다. 이는 많은 경우에 작동할 수 있지만 대부분의 사용자는 변경 사항이 TTL보다 빠르게 반영되기를 기대하죠. 그러나 기본 TTL을 매우 작은 값으로 낮추면 캐시 적중률이 떨어지고 효과가 감소할 수 있습니다.
캐시 무효화를 더 관련성 있게 만들기 위해 우버는 Docstore의 변경 데이터 캡처 및 스트리밍 서비스인 Flux를 활용했습니다. Flux는 각 데이터베이스 클러스터에 대한 MySQL 바이너리 로그 이벤트를 추적하고 이벤트를 소비자 목록에 게시하여 작동합니다. 다양한 노드 간에 복제, 데이터 레이크 수집 및 데이터 일관성 검증을 지원합니다.
캐시 무효화를 위해 데이터 이벤트를 구독하고 Redis에서 새 row을 무효화/upsert하는 새 컨슈머가 생성되었습니다. 아래 이미지는 캐시 무효화가 있는 읽기 및 쓰기 경로를 보여줍니다.
이 접근 방식에는 몇 가지 주요 장점이 있었는데요,
- TTL에 따라 분 단위가 아닌 데이터베이스 변경 후 초 단위로 캐시를 데이터베이스와 일관되게 만들 수 있습니다.
- 또한 바이너리 로그를 사용하면 커밋되지 않은 트랜잭션이 캐시를 오염시킬 수 없습니다.
그러나 해결해야 할 몇 가지 문제도 있었습니다.
1. 캐시 쓰기 중복 제거
읽기(read) 경로와 쓰기(write) 경로 사이에 캐시에 동시에 쓰기가 발생하기 때문에 최신 값을 덮어써서 오래된 행을 캐시에 쓸 수 있었습니다. 이를 방지하기 위해 MySQL에 설정된 행의 타임스탬프를 기반으로 쓰기 중복을 제거했습니다. 이 타임스탬프는 버전 번호 역할을 했으며 EVAL 명령을 사용하여 Redis의 인코딩된 행 값에서 구문 분석되었습니다.
2. 더 강력한 일관성을 지닌 요구 사항
Flux를 사용한 CDC로 하는 캐시 무효화가 TTL에 의존하는 것보다 빨랐지만 TTL이 여전히 더 안정적인 일관성을 제공했습니다. 그러나 일부 사용 사례에서는 reading-own-writes 를 보장하는 것과 같은 더 강력한 일관성 보장이 필요했습니다.
이러한 경우를 위해, 사용자가 해당 쓰기가 완료된 직후 캐시된 행을 명시적으로 무효화할 수 있도록 하는 쿼리 엔진에 전용 API를 만들었습니다. 이렇게 하면 캐시가 일관성을 갖추기 위해 CDC 프로세스가 완료될 때까지 기다릴 필요가 없었습니다.
CacheFront로 확장성과 탄력성 확보 CacheFront의 기본 요구 사항은 읽기 및 캐시 무효화를 지원하기 시작하면서 준비되었습니다.
그러나 Uber는 또한 이 솔루션이 자신들의 규모에서 작동하기를 원했습니다. 그들은 또한 전체 플랫폼에 대한 중요한 탄력성 요구 사항이 있었습니다.
CacheFront로 확장성과 탄력성을 달성하기 위해 그들은 여러 전략을 활용했습니다.
캐시 비교(Compare cache)
측정은 시스템이 예상대로 작동한다는 것을 증명하는 열쇠입니다. CacheFront도 마찬가지였습니다.
그들은 CacheFront에 캐시에 대한 읽기 요청을 섀도잉하는 특수 모드를 추가하여 캐시와 데이터베이스의 데이터를 비교하여 둘 다 동기화되었는지 확인할 수 있도록 했습니다. 오래된 행과 같은 불일치는 추가 분석을 위해 메트릭으로 기록됩니다.
아래 다이어그램은 캐시 비교 시스템의 높은 수준의 설계를 보여줍니다.
이 시스템의 결과를 바탕으로 Uber는 캐시가 99.99% 일관성이 있음을 발견했습니다.
캐시 워밍(Cache warming)
다중 지역 환경에서 캐시는 항상 따뜻할 때만 효과적입니다. 그렇지 않으면 지역 장애 조치로 인해 캐시 미스가 발생하고 데이터베이스에 대한 요청 수가 크게 증가할 수 있습니다.
Docstore 인스턴스가 액티브-액티브 배포로 두 개의 다른 지리적 지역에서 생성되었기 때문에 콜드 캐시는 장애 조치 시 데이터베이스에 많은 부하가 걸릴 가능성이 높아 비용을 절감하기 위해 스토리지 엔진을 축소할 수 없음을 의미했습니다.
이 문제를 해결하기 위해 Uber 엔지니어링 팀은 지역 간 Redis 복제를 사용했습니다.
그러나 Docstore에도 자체 지역 간 복제 기능이 있었습니다. 두 복제 설정을 동시에 운영하면 캐시와 데이터베이스 간에 일관된 데이터가 발생할 수 있으므로 새로운 캐시 워밍 모드를 추가하여 Redis 지역 간 복제를 향상시켰습니다.
캐시 워밍 모드의 작동 방식은 다음과 같습니다:
- 원격 지역에 키를 복제하기 위해 Redis 쓰기 스트림을 추적합니다.
- 원격 지역에서는 캐시를 직접 업데이트하지 않습니다. 대신 복제된 키에 대해 쿼리 엔진 계층에 읽기 요청을 발행합니다.
- 캐시 미스가 발생하면 쿼리 엔진 계층이 데이터베이스에서 데이터를 읽고 캐시에 씁니다. 응답 스트림은 폐기됩니다.
아래 다이어그램은 이 접근 방식을 자세히 보여줍니다:
값 대신 키를 복제하면 캐시의 데이터가 해당 지역의 데이터베이스와 일관되도록 합니다. 또한 두 지역 모두에 동일한 캐시된 행 세트가 있도록 하여 장애 조치 시 캐시를 따뜻하게 유지합니다.
샤딩(Sharding)
Uber 내 Docstore의 일부 대규모 고객은 매우 많은 수의 읽기-쓰기 요청을 생성할 수 있습니다. 하지만 최대 노드 수로 제한되는 단일 Redis 클러스터 내에서 이 모든 것을 캐시하는 것은 어려웠죠.
이를 완화하기 위해 단일 Docstore 인스턴스가 여러 Redis 클러스터에 매핑되도록 허용했습니다. 이는 단일 Redis 클러스터에서 여러 노드가 다운될 경우 데이터베이스에 대한 대규모 요청 급증을 방지하는 데 도움이 되었다고 합니다.
그러나 단일 Redis 클러스터가 다운되어 데이터베이스에 핫 샤드가 생성될 수 있는 경우가 여전히 있었습니다. 이를 방지하기 위해 데이터베이스 샤딩 스키마와 다른 스키마를 사용하여 Redis 클러스터를 샤딩했습니다. 이렇게 하면 단일 Redis 클러스터가 다운될 때 부하가 여러 데이터베이스 샤드 간에 분산됩니다.
아래 다이어그램은 이 시나리오를 더 자세히 설명하고 있습니다.
서킷 브레이커(Circuit breaker)
Redis 노드가 다운되면 해당 노드에 대한 get/set 요청이 불필요한 지연 시간 패널티를 생성합니다.
이 패널티를 피하기 위해 Uber는 이러한 요청을 단락시키기 위해 슬라이딩 윈도우 기반의 서킷 브레이커를 구현했습니다. 이 서킷 브레이커는 특정 시간마다 버킷에 대해 각 노드의 오류 수를 계산하고 슬라이딩 윈도우 너비 내의 오류 수를 계산하죠
슬라이딩 윈도우 접근 방식을 이해하려면 아래 다이어그램을 참조하세요:
서킷 브레이커는 오류 수에 따라 노드에 대한 요청의 일부를 단락하도록 구성됩니다. 임계값에 도달하면 회로 차단기가 작동하고 슬라이딩 윈도우가 지나갈 때까지 노드에 더 이상 요청을 할 수 없습니다.
결론
Uber의 Docstore와 통합된 Redis 캐시 구현 프로젝트는 상당히 성공적이었습니다. 그들은 확장 가능하고 지연 시간을 개선하고 부하를 줄이며 비용을 절감할 수 있는 투명한 캐싱 솔루션을 만들었습니다.
결과를 보여주는 몇 가지 지표는 다음과 같습니다:
- P75 지연 시간은 75% 감소했고 P99.9 지연 시간은 67% 이상 감소했으며 지연 시간 스파이크도 제한되었습니다.
- Flux와 비교 캐시를 사용한 캐시 무효화는 99.9%의 캐시 일관성을 제공했습니다.
- 샤딩과 캐시 워밍은 설정을 확장 가능하고 내결함성이 있게 만들어, 초당 600만 건 이상의 읽기와 99%의 캐시 적중률을 가진 사용 사례가 원격 리전으로 성공적으로 장애 조치할 수 있도록 했습니다.
- 초당 600만 건의 읽기라는 동일한 사용 사례에 스토리지 엔진에 약 6만 개의 CPU 코어가 필요했던 반면, CacheFront를 사용하면 불과 3,000개의 Redis 코어로 동일한 결과를 달성했기 때문에 비용이 크게 절감되었습니다.
- 현재 CacheFront는 프로덕션 환경에서 초당 4천만 건 이상의 요청을 지원하며 그 수는 매일 증가하고 있습니다.
레퍼런스
우버의 캐시 여정, 어떠셨나요? 저는 현재 회사 내에 레디스 기반의 글로벌 캐시 아키텍처를 설계하는 일을 하고 있는데요. 이 과정에서 고민했던 것들을 이 글을 통해 많이 해소할 수 있었답니다 :) 여러분들께도 도움이 되는 글이었기를 진심으로 바라겠습니다.
Top 1% 개발자로 거듭나는 확실한 처방전, 데브필입니다.
댓글
의견을 남겨주세요