Canva가 S3 기반의 조밀한 바이너리 캐시를 사용해 대규모 세션 폐기를 빠르고 안정적으로 처리한 방법을 소개합니다.
수억 명의 사용자 세션을 관리하는 일은 까다로운 문제입니다. 모든 백엔드 요청은 로그인한 사용자가 누구인지 알아야 하기 때문입니다. Canva 규모에서는 이 질문에 매초 수십만 번 답해야 합니다. 최상의 성능과 안정성을 위해 세션 폐기 정보를 메모리에 직접 보관하지만, 규모가 커지면서 배포 중 이 캐시를 로드하는 일이 병목이 되었습니다.
Canva는 사용자 ID부터 권한과 역할까지 사용자 세션에 대해 알아야 하는 모든 정보를 저장하기 위해 브라우저 쿠키를 사용합니다. 이 쿠키를 암호화함으로써 게이트웨이는 매 요청마다 네트워크 데이터스토어와 통신하지 않고도 이러한 세부 정보를 신뢰할 수 있습니다. 하지만 사용자가 로그아웃하거나 권한이 변경되면, 거의 실시간으로 쿠키를 폐기하거나 업데이트할 수 있어야 합니다.
이를 위해 각 게이트웨이에는 폐기된 모든 세션의 기록이 필요합니다. 네트워크 데이터스토어를 확인하는 것보다 빠르고 안정적이므로 메모리 내 조회를 사용합니다. 세션 쿠키는 주기적으로 갱신되므로, 12시간치 폐기 정보를 메모리에 저장하고 이러한 갱신 중에는 더 느린 MySQL 조회에 의존합니다. 갱신이 필요한 토큰은 항상 데이터베이스와 대조되므로 메모리 내 캐시에 의존할 필요가 없습니다.
메모리 내 캐시를 읽는 비용은 작지만, 초기 적재가 문제가 되기 시작했습니다. 수백 개의 게이트웨이 포드가 시작 시 각각 MySQL에서 100만 건이 넘는 폐기 정보를 가져올 수 있었고, 배포는 데이터베이스를 향한 조직적인 폭주가 되었습니다. 대량의 읽기 복제본을 추가해 일시적으로 완화할 수는 있었지만, 더 나은 해결책이 필요했습니다. 이상적으로는 캐시의 메모리 크기도 줄일 수 있는 방법이어야 했습니다.
아래 다이어그램은 기존 시스템의 전체 흐름을 보여 줍니다.

기존 세션 폐기 아키텍처
배포 속도를 늦춰 이 문제를 해결하고 싶지는 않았습니다. 따라서 100만 건이 넘는 폐기 정보로 구성된 전체 캐시를 많은 게이트웨이 인스턴스가 한꺼번에 다운로드하는 문제를 근본적으로 다뤄야 했습니다. 과제는 게이트웨이 수준에서 빠르고 안정적인 요청별 메모리 내 세션 폐기 확인을 유지하면서, 배포 시 캐시를 로드해야 할 때 MySQL 데이터베이스에 과부하를 주지 않는 것이었습니다.
읽기를 확장하는 일반적인 해결책은 데이터베이스와 리더 사이에 캐시를 도입하는 것이지만, 캐싱 기술의 선택은 그리 명확하지 않습니다. Redis는 널리 쓰이는 캐싱 기술이므로 중간 캐시로 평가했습니다. 시작 시 각 게이트웨이 인스턴스는 전체 메모리 내 데이터셋을 Redis에 네트워크 호출로 요청하고, 이후 최신 상태를 유지하기 위해 새 폐기 정보를 주기적으로 폴링합니다. 주요 문제는 Redis가 일반적으로 완전히 내구성 있는 구성으로 배포되지 않으며, Redis 클러스터 자체도 계속 관리해야 한다는 점입니다. 캐시 일관성을 유지하기 위한 상당한 복잡성을 추가하면서, 문제를 한 데이터스토어에서 다른 데이터스토어로 옮기는 것에 불과합니다.
강력한 내구성 보장을 제공하고 대량의 데이터를 효율적으로 읽을 수 있는 무언가가 필요했습니다. 그 결과, 내구성 있게 저장된 대용량 파일의 다운로드를 저비용으로 제공하도록 설계된 S3에 이르렀습니다. 이는 우리의 사용 사례에 정확히 필요한 것이었지만, 정적인 바이너리 데이터 블롭이 아니라 이동하는 레코드 윈도우를 캐싱한다는 점에서 큰 과제도 있었습니다.
S3를 사용해 데이터를 캐싱하려면, 폐기 레코드의 슬라이딩 윈도우를 객체 스토리지에 적합한 형식으로 변환해야 했습니다. 슬라이딩 윈도우를 각각 S3의 하나의 객체에 해당하는 30분 세그먼트로 분할하기로 했습니다. 각 게이트웨이 인스턴스가 가장 최근 청크만 가져오면 되므로 S3에서 오래된 폐기 정보를 개별적으로 제거할 필요가 없습니다. 또한 각 30분 블록은 여러 시간에 걸친 전체 윈도우보다 적은 데이터를 포함하므로 더 작은 업데이트를 수행할 수 있습니다. 하지만 30분 청크를 사용하더라도 S3에 단 하나의 폐기 정보를 추가하려면 수십만 건의 레코드를 다운로드하고 다시 업로드해야 할 수 있습니다. 이에 대해서는 뒤에서 더 자세히 설명하겠습니다.

단일 폐기 정보의 바이너리 데이터 형식
각 폐기 정보에는 두 가지 핵심 정보가 있습니다. 적용 대상인 “주체”와 적용되는 로그인 타임스탬프입니다. 일반적으로 특정 타임스탬프 이전에 시작된 모든 세션을 폐기합니다. 약간의 비트 조작으로 이를 16바이트에 담을 수 있으며, 폐기 청크는 이 16바이트 요소의 평면 배열입니다. 이 배열을 정렬하면 각 청크를 효율적으로 이진 검색하여 특정 주체에 적용되는 폐기 정보를 찾을 수 있습니다. 이로 인해 캐시 로딩은 매우 빨라집니다. 발생하는 비용은 S3 버킷에서의 다운로드뿐입니다. 게이트웨이 서비스는 이 청크를 다른 표현으로 변환할 필요조차 없으며, 다운로드한 바이트를 직접 처리할 수 있습니다. 조밀한 바이너리 표현만 메모리에 보관함으로써, 각 폐기 정보를 여러 Java 객체로 추적하던 이전 구현과 비교해 메모리 내 캐시 크기도 8분의 1로 줄였습니다. 캐시의 메모리 사용량이 가장 큰 과제는 아니었지만, 수백만 건의 폐기 정보가 캐시의 12시간 윈도우에 포함될 수 있음을 고려하면 이는 여전히 중요한 개선이었습니다.
실제로는 여러 유형의 폐기를 지원해야 하므로 조금 더 복잡해집니다. 예를 들어 사용자를 로그아웃시키지 않고 사용자 쿠키에 캐싱된 일부 정보를 무효화하거나, 단일 사용자 대신 전체 브랜드를 대상으로 할 수 있습니다. 이를 처리하려면 플래그를 위해 몇 비트를 예약해야 했지만, 여전히 각 폐기 정보의 주체를 기준으로 정렬한다면 여러 유형의 폐기 정보를 같은 평면 배열에 저장할 수 있습니다.

폐기 정보 S3 버킷 내 데이터 구조
이 접근 방식의 주요 과제는 청크를 최신 상태로 유지하는 것입니다. 폐기 정보가 생성될 때마다 S3의 청크를 다시 쓰는 것은 엄청나게 비쌉니다. 대신 이 작업을 비동기 워커 프로세스에 위임했습니다. 워커는 데이터베이스를 계속 스캔하며 아직 S3에 업로드되지 않은 대량의 폐기 정보 배치를 가져옵니다. 그런 다음 최신 청크를 가져오고(충분한 시간이 지났다면 새 청크를 생성), 새 폐기 정보를 정렬된 배열에 삽입한 뒤 청크를 다시 업로드합니다.
고가용성을 보장하고 배포를 지원하기 위해, 언제든 이 워커 프로세스의 여러 복사본을 실행합니다. 단순한 구현에서는 폐기 정보가 조용히 누락되는 손실 업데이트와 같은 S3에 대한 데이터 경합이 발생할 수 있습니다. 이를 완화하기 위해 조건부 PUT 요청을 사용하여 낙관적 동시성 제어를 구현합니다. 여기에는 모든 청크 업데이트에, 처음 읽은 이후 청크가 변경되지 않았음을 확인하는 사전 조건을 추가하는 것이 포함됩니다. 새로 생성되는 청크에도 다른 프로세스가 동일한 청크를 이미 생성하지 않았음을 보장하기 위한 유사한 검사가 사용됩니다. PUT 조건은 각 읽기-수정-쓰기 작업이 S3의 폐기 정보 집합에만 추가하도록 보장하므로, 실행이 교차하더라도 데이터가 손실되지 않습니다.
또한 지속적인 충돌이 시스템에 부하를 더하지 않도록 최적화 차원에서 ZooKeeper 리더 선출을 사용합니다. 리더 선출 덕분에 워커 작업 간 경합은 드물지만, 정확성을 위해 여기에 의존할 수는 없습니다. 예를 들어 노드가 쓰기 작업 직전에 임의의 시간 동안 멈출 수 있습니다. 다시 실행될 때까지 다른 노드가 새 리더가 되어 자체 변경 사항을 기록했을 수 있으며, PUT 조건이 없다면 원래 노드는 깨어난 뒤 이를 덮어쓰게 됩니다.

워커 작업이 데이터베이스의 폐기 정보를 S3로 복사하는 방법
처음 보면 이 접근 방식은 확장되지 않는 것처럼 보일 수 있습니다. N개의 폐기 정보로 청크를 구축하려면, 추가하는 고정 크기 폐기 정보 배치마다 전체 청크를 처리해야 하므로 대략 O(N^2) 시간이 필요합니다. 더 나쁜 점은 앞서 언급한 손실 업데이트 문제에 부딪히지 않고 이 프로세스를 수평 확장하기가 쉽지 않다는 것입니다. 하지만 실제로는 각 배치에서 수백 건의 폐기 정보를 처리하면, 최적화하지 않은 구현으로도 초당 2000건 이상의 폐기 정보를 쓰는 처리량을 달성할 수 있습니다. 이는 가까운 미래에 필요할 것으로 예상하는 처리량보다 큽니다.
버킷의 각 청크에는 특정 30분 윈도우 내에 생성된 모든 폐기 정보가 포함됩니다. 지난 12시간 동안 생성된 폐기 정보만 필요하므로(모든 토큰은 이 시간 윈도우 내에 반드시 갱신됨), 게이트웨이 포드가 시작될 때 가장 최근 청크의 일부만 다운로드할 수 있습니다. 각 청크는 이름에 30분 윈도우 시작 타임스탬프를 직접 인코딩하므로, 기준 타임스탬프 이후의 키를 정렬된 순서로 검색해 적용 가능한 모든 청크를 효율적으로 찾을 수 있습니다.
또한 새 폐기 정보가 생성될 때 메모리 내 캐시를 지속적으로 최신 상태로 유지해야 합니다. 그렇지 않으면 배포 중에만 반영됩니다. 이를 위해 S3 조건부 GET 요청을 사용하여 실제로 변경된 경우에만 최신 청크를 다시 다운로드했습니다. 30분 윈도우에 폐기 정보가 100만 건 있더라도, 분당 몇 차례 16메가바이트를 다운로드하는 것에 불과합니다. 이는 게이트웨이가 프록시하는 요청 및 응답 데이터의 양과 비교하면 극히 일부입니다. 청크 윈도우의 끝이 12시간보다 오래되면 캐시에서 간단히 제거할 수 있습니다.
각 게이트웨이 포드는 여전히 시작 시 같은 수의 무효화 정보를 가져와야 하지만, 이 데이터를 조밀한 바이너리 버전으로 S3에서 스트리밍하는 것은 MySQL에 10억 행이 넘는 데이터를 제공하도록 요청하는 것보다 훨씬 빠르고 확장성이 뛰어납니다. 각 게이트웨이에는 수십 메가바이트의 데이터에 불과하지만, 전체 플릿을 고려하면 수십 기가바이트가 됩니다.

새 시스템을 통해 폐기 정보가 흐르는 방식
새 세션 폐기 시스템으로 마이그레이션한 이후 배포 속도를 개선하고 세션 폐기 데이터베이스의 읽기 복제본 수를 줄일 수 있었으며, 중복성을 위해 두 개만 유지했습니다. 새 바이너리 형식은 각 폐기 정보마다 Java 힙에 여러 객체를 저장하는 오버헤드가 사라졌기 때문에 캐시의 메모리 사용량도 87.5% 줄였습니다. 이전에는 데이터베이스 부하가 여러 시간 범위 내 이전 쓰기 횟수와, 특정 시점에 캐시 다운로드를 시도하는 게이트웨이 인스턴스 수에 따라 달라졌습니다. 새 솔루션에서는 부하가 쓰기 처리량과 전체 사이트 트래픽에 따라 예측 가능하게 확장됩니다.
이 프로젝트에서 가장 크게 얻은 교훈은 이론과 실제에서의 확장 간 차이입니다. 처음에는 단일 워커가 수십만 건의 레코드를 계속 정렬하는 것이 비효율적으로 들릴 수 있지만, 경험적 테스트를 통해 워커의 처리량이 실제 요구 사항을 초과한다는 사실을 확인했습니다. 상수 계수는 매우 중요합니다. 현대 시스템은 조밀한 배열 처리에 매우 뛰어나므로, 단일 배열에서 수십만 건의 폐기 정보를 처리할 때도 워커 구현의 실제 병목은 네트워크 지연 시간입니다.
AI 코딩 에이전트의 시대에는 선택한 클라우드 플랫폼에서 분산 시스템 설계의 모의 구현을 시험하기가 매우 쉽습니다. 우리의 경우 문제를 위한 여러 솔루션을 구현하고 실제 인프라에서 예상 규모로 테스트했습니다. 이를 통해 확장성 요구 사항을 충족한다는 것을 증명했기에, 우리에게 가장 적합한 접근 방식을 자신 있게 선택할 수 있었습니다. 개념 증명을 개발한 뒤에는 광범위한 엔드투엔드 테스트 모음과 신중한 사람의 검토를 활용해 프로덕션 준비 버전이 사용자에게 안전한지 확인했습니다.
이 솔루션 구축에 협력해 준 Joeby Neil(새 탭 또는 창에서 열림)과 지원을 제공해 준 Martin Doms(새 탭 또는 창에서 열림)에게 감사드립니다. 또한 이 글에 대한 피드백을 제공하기 위해 시간을 내 준 Dennis Kao(새 탭 또는 창에서 열림), Michael Yates(새 탭 또는 창에서 열림), Zac Sims(새 탭 또는 창에서 열림), Stu Liston(새 탭 또는 창에서 열림), Min Coombes(새 탭 또는 창에서 열림), Simon Newton(새 탭 또는 창에서 열림)에게도 감사드립니다.
흥미로운 기술 과제에도 함께하고 싶다면, 저희와 함께하세요(새 탭 또는 창에서 열림)!