3줄 요약

  1. Canva 엔지니어링 블로그가 수억 명의 사용자 세션을 관리하는 방법을 공개했다(2026-07-22, Llew Vallis). 모든 백엔드 요청은 어느 로그인 사용자가 보냈는지 알아야 하고, 이 확인이 초당 수십만 번 일어난다.
  2. 진짜 병목은 조회가 아니라 시딩이었다. 배포할 때마다 수백 개의 게이트웨이 파드가 저마다 100만 건이 넘는 폐기 레코드를 MySQL에서 한꺼번에 끌어와, 데이터베이스에 조율되지 않은 쇄도(stampede)를 일으켰다.
  3. 해법은 12시간짜리 슬라이딩 윈도를 30분 단위 청크로 잘라, 각 폐기를 16바이트로 압축한 이진 배열로 S3에 저장하는 것이다. 인메모리 캐시 크기를 8배(87.5%) 줄였고, 읽기 리플리카를 2개로 감축했으며, 배포 속도를 끌어올렸다.

세션 하나를 확인하는 데 드는 비용

Canva는 사용자 세션에 필요한 정보를 브라우저 쿠키에 담는다. 사용자 ID부터 권한과 역할까지 전부 쿠키에 있다. 쿠키를 암호화해 두었기 때문에, 게이트웨이는 요청마다 네트워크 너머의 데이터스토어에 묻지 않고도 이 정보를 신뢰할 수 있다. 문제는 사용자가 로그아웃하거나 권한이 바뀌었을 때다. 그럴 때는 쿠키를 거의 실시간으로 폐기하거나 갱신해야 한다.

이를 위해 각 게이트웨이는 폐기된 세션의 기록을 들고 있어야 한다. Canva는 인메모리 조회를 쓴다. 네트워크 데이터스토어를 확인하는 것보다 빠르고 안정적이기 때문이다. 세션 쿠키는 주기적으로 갱신되므로, 메모리에는 12시간치 폐기만 담아 두고 갱신 시점에는 느린 MySQL 조회에 기댄다. 갱신이 필요한 토큰은 언제나 데이터베이스와 대조되므로, 인메모리 캐시에만 의존하지 않아도 된다.

읽기 자체는 값쌌지만, 그 캐시를 채우는 일이 골칫거리가 됐다. 수백 개의 게이트웨이 파드가 시작할 때마다 저마다 100만 건이 넘는 폐기를 MySQL에서 당겨오니, 배포가 곧 데이터베이스로 몰리는 쇄도가 됐다. 읽기 리플리카를 잔뜩 붙여 임시로 눌러 둘 수는 있었지만, 더 나은 해법이 필요했다. 이왕이면 인메모리 캐시 크기까지 줄여 주는 쪽으로.

Canva의 기존 세션 폐기 아키텍처

인메모리 캐시를 다시 설계하다

배포를 느리게 만들어 문제를 피하고 싶지는 않았다. 그러니 수많은 게이트웨이 인스턴스가 100만 건이 넘는 캐시 전체를 동시에 내려받는 상황 자체를 다뤄야 했다. 게이트웨이의 빠르고 안정적인 요청별 인메모리 확인은 그대로 두면서, 배포 때 MySQL이 짓눌리는 것만 막는 것이 과제였다.

읽기를 확장하는 표준적인 방법은 데이터베이스와 읽는 쪽 사이에 캐시를 하나 두는 것이다. 다만 어떤 캐싱 기술을 쓸지는 그리 자명하지 않다. Redis가 인기 있는 선택지라 중간 캐시로 검토했다. 게이트웨이 인스턴스가 시작할 때 Redis에 전체 데이터셋을 (네트워크 호출로) 요청하고, 이후 주기적으로 새 폐기를 폴링해 최신 상태를 유지하는 구조다. 문제는 Redis가 보통 완전한 내구성 구성으로 배포되지 않는다는 점, 그리고 Redis 클러스터 자체를 운영해야 한다는 점이었다. 그렇게 하면 문제를 한 데이터스토어에서 다른 데이터스토어로 옮기는 데 그치면서, 캐시 일관성을 지키느라 복잡도만 크게 늘어난다.

강한 내구성 보장과 대용량 데이터의 효율적 읽기를 함께 주는 무언가가 필요했다. 그렇게 S3에 닿았다. S3는 크고 내구성 있게 저장된 파일을 낮은 비용으로 내려받게 해 주도록 설계됐다. 용도에는 딱 맞았지만 큰 난관이 하나 있었다. Canva가 캐싱하는 대상은 정적 이진 덩어리가 아니라 움직이는 레코드 윈도였다.

S3로 데이터를 캐싱하려면 폐기 레코드의 슬라이딩 윈도를 객체 저장소에 맞는 형식으로 바꿔야 했다. Canva는 슬라이딩 윈도를 30분 단위 세그먼트로 나누고, 각 세그먼트를 S3 객체 하나에 대응시켰다. 오래된 폐기를 S3에서 하나씩 지울 걱정은 없다. 게이트웨이 인스턴스가 가장 최근 청크만 가져오면 되기 때문이다. 덕분에 갱신도 잘게 나뉜다. 30분 블록 하나에 담기는 데이터가 여러 시간짜리 윈도 전체보다 작으니까. 그래도 30분 청크라 해도 폐기 하나를 S3에 추가하려면 수십만 건의 레코드를 통째로 내려받았다가 다시 올려야 한다. 이 대목은 뒤에서 더 다룬다.

폐기 하나를 16바이트에 담다

폐기 하나의 이진 데이터 형식

각 폐기에는 두 가지 결정적인 정보가 들어간다. 누구에게 적용되는가(프린시펄, principal이라 부른다), 그리고 어느 로그인 시각까지 적용되는가다(보통 특정 시각 이전에 시작된 모든 세션을 폐기한다). 약간의 비트 조작으로 이 둘을 16바이트에 담았고, 폐기 청크는 이 16바이트 원소들의 평평한 배열이 된다. 이 배열을 정렬해 두면 각 청크를 이진 탐색해 특정 프린시펄에 해당하는 폐기를 효율적으로 찾을 수 있다.

이 덕에 캐시 로딩이 놀랄 만큼 빨라진다. 치르는 비용이라곤 S3 버킷에서의 다운로드뿐이기 때문이다. 게이트웨이 서비스는 이 청크를 다른 표현으로 변환할 필요조차 없이, 내려받은 바이트 위에서 곧바로 동작한다. 조밀한 이진 표현만 메모리에 들고 있으니, 각 폐기를 여러 개의 Java 객체로 추적하던 이전 구현과 견주어 인메모리 캐시 크기도 8배 줄었다. 캐시의 메모리 사용량이 가장 큰 문제는 아니었지만, 12시간 윈도 안에 수백만 건의 폐기가 들어올 수 있다는 점을 생각하면 이것도 중요한 개선이었다.

실제로는 조금 더 복잡하다. 여러 종류의 폐기를 지원해야 하기 때문이다. 이를테면 사용자를 로그아웃시키지 않으면서 쿠키에 캐시된 일부 정보만 무효화하고 싶을 수도 있고, 한 사용자가 아니라 브랜드 전체를 겨냥하고 싶을 수도 있다. 이를 위해 몇 비트를 플래그로 남겨 두었다. 그래도 각 폐기를 프린시펄 기준으로 정렬하는 한, 한 평평한 배열에 여러 종류의 폐기를 함께 담을 수 있다.

폐기 S3 버킷의 데이터 구조

청크를 최신으로 유지하기

이 방식의 큰 난관은 청크를 최신으로 유지하는 데 있다. 폐기가 생길 때마다 S3의 청크를 다시 쓰는 것은 대단히 비싸다. 그래서 이 작업을 비동기 워커 프로세스에 맡겼다. 워커는 데이터베이스를 끊임없이 훑으며 아직 S3에 올라가지 않은 폐기를 큰 배치로 가져온다. 그런 다음 가장 최근 청크를 가져오거나(충분한 시간이 지났으면 새로 만들고), 새 폐기들을 정렬된 배열에 끼워 넣은 뒤 청크를 다시 올린다.

높은 가용성과 배포를 위해 이 워커 프로세스는 언제나 여러 벌이 돈다. 순진하게 구현하면 S3에 대한 데이터 경쟁이 생긴다. 이를테면 폐기가 조용히 사라지는 갱신 유실이다. 이를 막으려고 조건부 PUT 요청으로 낙관적 동시성 제어를 구현했다. 모든 청크 갱신에 “이 청크가 처음 읽은 뒤로 바뀌지 않았다"는 전제 조건을 걸고, 새로 만드는 청크도 다른 프로세스가 같은 청크를 이미 만들지 않았는지 비슷하게 확인한다. 이 PUT 조건은 모든 읽기-수정-쓰기가 S3의 폐기 집합에 오직 덧붙이기만 하도록 보장하므로, 실행이 서로 얽혀 돌아가도 데이터를 잃지 않는다.

여기에 더해 ZooKeeper 리더 선출을 최적화로 썼다. 계속되는 충돌이 시스템에 부하를 더하는 것을 막기 위해서다. 리더 선출 덕에 워커 작업들이 서로 경쟁하는 일은 드물지만, 정확성을 여기에 의존할 수는 없다. 예컨대 한 노드가 쓰기 직전에 임의의 시간 동안 멈출 수 있다. 다시 깨어났을 때는 다른 노드가 새 리더가 되어 자기 변경을 이미 써 넣었을 수 있는데, PUT 조건이 없었다면 원래 노드가 깨어나며 그것을 덮어썼을 것이다.

워커 작업이 데이터베이스의 폐기를 S3로 옮기는 과정

언뜻 이 방식은 확장되지 않을 것처럼 보인다. N개의 폐기로 청크를 만드는 데 O(N²) 시간이 드는데, 고정 크기 배치를 더할 때마다 청크 전체를 처리해야 하기 때문이다. 게다가 앞서 말한 갱신 유실 탓에 수평 확장도 쉽지 않다. 그런데 실제로는 배치마다 수백 건씩 처리하면, 최적화하지 않은 구현으로도 초당 2,000건이 넘는 쓰기 처리량이 나온다. 당분간 필요하리라 예상되는 양보다 많다.

게이트웨이가 청크를 내려받는 법

버킷의 각 청크에는 특정 30분 창 안에 생긴 모든 폐기가 담긴다. 필요한 것은 지난 12시간 안에 생긴 폐기뿐이므로(모든 토큰이 이 시간 안에 갱신되는 것이 보장된다), 게이트웨이 파드가 시작할 때 가장 최근 청크의 일부만 내려받으면 된다. 각 청크는 30분 창이 시작된 시각을 이름에 직접 담고 있어서, 잘라낼 시각 이후의 키를 정렬 순서로 찾으면 해당하는 청크를 효율적으로 골라낼 수 있다.

인메모리 캐시는 새 폐기가 생기는 대로 계속 최신으로 유지해야 한다. 그러지 않으면 배포 때만 반영될 테니까. 이를 위해 S3 조건부 GET 요청을 써서, 최신 청크가 실제로 바뀌었을 때만 다시 내려받았다. 설령 30분 창에 폐기가 100만 건 있어도 이는 몇 분에 한 번 16메가바이트에 지나지 않는다. 게이트웨이가 프록시하는 요청·응답 데이터 양에 비하면 바다에 물 한 방울이다. 청크 창의 끝이 12시간보다 오래되면 캐시에서 그냥 버리면 된다.

각 게이트웨이 파드가 시작할 때 당겨야 하는 무효화 건수 자체는 그대로다. 하지만 조밀한 이진 판을 S3에서 스트리밍하는 편이, 10억 행이 넘는 데이터를 MySQL에 요청하는 것보다 훨씬 빠르고 확장성이 좋다. 게이트웨이 하나로 보면 수십 메가바이트지만, 전체 플릿으로 보면 수십 기가바이트가 된다.

새 시스템에서 폐기가 흐르는 경로

성과와 교훈

새 세션 폐기 시스템으로 옮긴 뒤, Canva는 배포 속도를 높였고 세션 폐기 데이터베이스의 읽기 리플리카를 중복성을 위한 2개만 남기고 줄일 수 있었다. 새 이진 형식은 인메모리 사용량도 87.5% 줄였다. 각 폐기를 Java 힙에 여러 객체로 저장하던 부담을 더는 지지 않기 때문이다. 예전에는 데이터베이스 부하가 여러 시간에 걸친 이전 쓰기의 수와, 특정 순간에 캐시를 내려받으려는 게이트웨이 인스턴스의 수에 좌우됐다. 새 해법에서는 부하가 쓰기 처리량과 전체 사이트 트래픽에 맞춰 예측 가능하게 늘어난다.

이 프로젝트에서 가장 크게 남은 것은 이론상의 확장과 실제의 확장이 다르다는 점이다. 언뜻 보면 워커 하나가 수십만 건의 레코드를 끊임없이 정렬하는 것이 비효율로 들리지만, 실측 결과 워커의 처리량은 실무 요구를 넘어섰다. 상수 인자가 어마어마하게 중요하다. 요즘 시스템은 조밀한 배열을 워낙 잘 다뤄서, 한 배열에 수십만 건의 폐기를 담아도 워커 구현의 병목은 오히려 네트워크 지연이다.

AI 코딩 에이전트의 시대에는, 원하는 클라우드 플랫폼 위에서 분산 시스템 설계의 목업 구현을 가지고 노는 일이 놀랄 만큼 쉬워졌다. Canva도 이 문제에 여러 해법을 각각 구현해, 예상 규모에서 실제 인프라로 시험했다. 확장성 요건을 충족한다는 것을 증명해 두었기에, 자기들에게 가장 잘 맞는 방식을 자신 있게 고를 수 있었다. 개념 증명을 만든 뒤에는, 방대한 엔드투엔드 테스트와 꼼꼼한 사람 리뷰로 프로덕션 판이 사용자에게 안전한지 확인했다.

가장 눈여겨본 것은

내가 곱씹은 대목은 O(N²) 워커를 두고 “실제로는 충분하다"고 말하는 자리다. 알고리즘 복잡도만 보면 폐기 하나를 더할 때마다 청크 전체를 다시 훑는 이 구조는 확장 실패의 교과서적 예시처럼 보인다. 그런데 Canva는 그것을 고치는 대신 실제 인프라에서 재어 보았고, 초당 2,000건이라는 실측값이 “예상 수요보다 많다"는 결론을 대신 내줬다. 병목이 계산이 아니라 네트워크에 있다는 사실이, 최악의 복잡도라는 지레짐작을 눌렀다.

또 하나는 도구가 판단을 바꾼 방식이다. 여러 설계를 실제 규모로 나란히 돌려 보는 비용이 낮아지자, 어느 것이 이론상 우아한가가 아니라 어느 것이 우리 트래픽에서 실제로 버티는가로 선택 기준이 옮겨 갔다. 목업을 싸게 만들 수 있는 시대의 설계 결정이 어떻게 실측 위주로 기우는지를, 이 글은 담담하게 보여 준다.

출처

Canva Engineering Blog, Llew Vallis, 2026년 7월 22일. Joeby Neil과 공동 작업. 원문: https://www.canva.dev/blog/engineering/session-revocations-at-scale/