Skip to content
Published on

배포가 곧 부하 테스트다 — 캐시를 채우는 비용을 설계하지 않으면 생기는 일

공유하기
Authors

들어가며 — 문제는 초당 요청이 아니라 배포였다

세션 처리는 보통 이렇게 최적화합니다. 세션 정보를 암호화해 쿠키에 담으면 게이트웨이는 요청마다 데이터베이스를 조회할 필요가 없습니다. 요청당 네트워크 왕복 하나를 없앤 셈이니 초당 수십만 건을 처리하는 시스템에서는 큰 이득입니다.

그런데 로그아웃이나 권한 변경이 일어나면 이미 발급된 쿠키를 무효화해야 합니다. 그래서 게이트웨이마다 "무효화된 세션 목록"을 인메모리로 들고 있어야 합니다. 조회는 여전히 공짜에 가깝습니다.

Canva가 2026년 7월 22일 공개한 Session revocations at scale이 다루는 문제는 그 다음입니다. 그 인메모리 목록을 처음에 어떻게 채우느냐입니다.

원문 표현으로, 수백 개의 게이트웨이 파드가 각각 100만 건이 넘는 무효화 기록을 시작할 때 MySQL에서 끌어오면서 "배포가 데이터베이스에 대한 조직적인 쇄도"가 되었습니다.

정상 상태 비용과 시작 비용은 서로 다른 문제다

이 이야기의 핵심은 여기 있습니다. 우리가 캐시를 평가할 때 보는 지표는 대부분 정상 상태에 대한 것입니다. 적중률, 조회 지연, 메모리 사용량. 이 지표들이 전부 훌륭해도 시스템은 무너질 수 있습니다.

인메모리 캐시에는 지표에 잘 안 잡히는 두 번째 비용이 있습니다.

  • 정상 상태 비용 — 요청당 조회. 여기서는 사실상 0입니다.
  • 시작 비용 — 프로세스가 뜰 때 전체 데이터셋을 어딘가에서 가져오는 비용. 파드 하나로 보면 대수롭지 않지만, 배포는 파드 하나가 아니라 전 함대를 동시에 재시작합니다.

시작 비용은 파드 수에 비례하고, 배포 빈도만큼 반복되며, 하필 롤아웃이라는 가장 예민한 순간에 발생합니다. 정상 상태 부하와 완전히 다른 축입니다.

Canva가 임시로 쓴 대응이 정확히 이 성격을 보여 줍니다. 읽기 복제본을 많이 늘려서 버텼습니다. 읽기 복제본은 정상 상태 조회량이 아니라 배포 순간의 스파이크를 감당하기 위해 존재하는 상태였던 것입니다. 배포하지 않는 시간에는 대부분 놀고 있었다는 뜻입니다.

12시간이라는 숫자가 어디서 나왔나

무효화 목록을 인메모리에 얼마나 오래 들고 있어야 할까요. 영원히는 아닙니다.

Canva의 세션 쿠키는 주기적으로 갱신됩니다. 갱신 시점에는 어차피 데이터베이스를 조회하므로, 갱신을 거친 토큰은 인메모리 캐시에 의존하지 않습니다. 그래서 12시간치 무효화만 메모리에 두고, 갱신이 필요한 토큰은 느린 MySQL 조회로 확인합니다.

이 설계가 좋은 이유는 캐시 크기의 상한이 토큰 수명에서 유도된다는 점입니다. 사용자 수가 아니라 시간 창이 크기를 결정합니다. 사용자가 두 배가 되면 무효화 발생률이 두 배가 되지만, 창의 길이는 그대로입니다.

캐시 설계에서 "무엇을 버려도 되는가"에 답이 있으면 크기 문제는 대개 풀립니다. 여기서는 갱신 경로가 안전망이므로 12시간보다 오래된 것을 버려도 정확성이 깨지지 않습니다.

Redis를 넣지 않은 이유

읽기를 확장하는 표준 해법은 데이터베이스와 독자 사이에 캐시를 하나 넣는 것입니다. Canva도 Redis를 검토했습니다. 게이트웨이가 시작할 때 Redis에 전체 데이터셋을 요청하고, 이후 새 무효화를 주기적으로 폴링하는 그림입니다.

기각한 이유가 두 가지인데, 둘 다 기술 선택 일반에 적용됩니다.

첫째, Redis는 보통 완전한 내구성 구성으로 배포되지 않습니다. 무효화 목록이 날아가면 로그아웃된 세션이 되살아납니다. 성능 캐시가 아니라 보안 경계입니다.

둘째, 원문 표현으로 "문제를 한 데이터스토어에서 다른 데이터스토어로 옮기면서 캐시 일관성을 유지하기 위한 상당한 복잡성을 더할 뿐"입니다. Redis 클러스터 자체를 운영해야 하는 부담은 그대로 남습니다.

계층을 하나 더 얹는 해법의 함정이 여기 있습니다. 새 계층은 새 실패 모드, 새 운영 부담, 새 일관성 문제를 함께 데려옵니다. 원래 계층의 부담은 줄었지만 시스템 전체의 부담은 늘 수 있습니다.

슬라이딩 윈도를 객체 저장소에 담는 법

그래서 S3를 골랐습니다. 강한 내구성 보장과 큰 파일의 효율적인 대량 다운로드는 정확히 필요한 성질이었습니다.

문제는 S3가 정적인 덩어리를 잘 다루는 반면 이 데이터는 계속 흘러가는 창이라는 점입니다. 항상 최근 12시간이고, 매 순간 앞쪽이 들어오고 뒤쪽이 빠집니다.

해법은 창을 30분 단위 조각으로 자르고 조각 하나를 S3 객체 하나에 대응시키는 것이었습니다. 이 결정에서 세 가지가 한꺼번에 따라옵니다.

  • 오래된 무효화를 개별적으로 지울 필요가 없습니다. 게이트웨이가 최근 조각들만 가져오면 됩니다.
  • 갱신 단위가 작아집니다. 12시간 전체가 아니라 30분치만 다시 씁니다.
  • 조각 이름에 30분 창의 시작 시각을 넣어 두면, 키를 정렬 순서로 훑어 컷오프 이후 조각만 골라낼 수 있습니다.

마지막 항목이 특히 실용적입니다. S3에는 "최근 것만 주세요"라는 질의가 없지만, 키를 시간순으로 정렬되게 이름 지으면 접두사 나열만으로 같은 효과를 얻습니다.

16바이트 — 표현이 곧 성능이다

이 글에서 가장 배울 만한 부분입니다.

무효화 하나가 담아야 하는 정보는 두 가지입니다. 누구에게 적용되는가(principal), 그리고 어느 로그인 시각까지 적용되는가. 비트를 아껴 배치하면 16바이트에 들어갑니다. 조각은 이 16바이트짜리 원소의 평평한 배열입니다.

여기에 정렬을 더합니다. principal 기준으로 정렬해 두면 조각 안에서 이진 탐색이 가능합니다. 결과적으로 게이트웨이는 다운로드한 바이트를 다른 표현으로 변환하지 않고 그대로 사용합니다.

이전 구현은 무효화 하나를 여러 개의 Java 객체로 추적했습니다. 밀집 이진 표현으로 바꾸자 인메모리 캐시 크기가 8분의 1, 즉 87.5퍼센트 줄었습니다.

실제로는 무효화 종류가 여러 가지입니다. 로그아웃시키지 않으면서 쿠키에 캐시된 정보만 무효화하거나, 개인이 아니라 브랜드 전체를 대상으로 하는 경우가 있습니다. 그래서 플래그용 비트를 몇 개 남겨 두었고, principal 기준 정렬만 유지하면 한 배열에 여러 종류를 섞어 둘 수 있습니다.

정리하면 이렇습니다. 캐시를 채우는 비용을 줄인 것은 새 계층이 아니라 역직렬화가 필요 없는 표현이었습니다. 다운로드가 끝나면 캐시도 끝납니다.

제곱 복잡도 워커가 충분했던 이유

조각을 최신 상태로 유지하는 쪽에는 다른 문제가 있습니다. 무효화가 생길 때마다 조각을 다시 쓰면 비용이 감당되지 않습니다.

그래서 비동기 워커가 이 일을 맡습니다. 데이터베이스를 계속 훑어 아직 S3에 올라가지 않은 무효화를 큰 배치로 가져오고, 최신 조각을 받아 정렬 배열에 끼워 넣은 뒤 다시 올립니다.

원문이 스스로 지적하듯 이건 언뜻 확장되지 않아 보입니다. 조각에 N건을 채우려면 고정 크기 배치마다 조각 전체를 처리해야 하므로 N에 대해 제곱에 가까운 시간이 듭니다. 게다가 갱신 손실 문제 때문에 수평 확장도 쉽지 않습니다.

그런데 실측하니 배치당 수백 건씩 처리하는 최적화되지 않은 구현으로도 초당 2000건 이상의 쓰기 처리량이 나왔고, 이는 예측 가능한 미래의 요구를 넘습니다. 원문의 결론이 정확합니다. 워커는 계산이 아니라 네트워크 지연에 병목되어 있었습니다.

이론적 복잡도는 입력이 커질 때의 기울기를 말할 뿐, 여러분의 입력 범위에서의 실제 시간을 말하지 않습니다. 수십만 개짜리 밀집 배열을 정렬하는 일은 현대 CPU에게 거의 공짜입니다. 상수가 지배하는 구간에서는 상수를 재야 합니다.

정확성은 조건부 PUT에 있고 리더 선출은 최적화다

워커는 가용성과 배포를 위해 여러 벌 돕니다. 순진하게 구현하면 S3에 대한 경쟁 상태가 생기고, 읽고 고쳐 쓰는 사이에 다른 워커의 갱신이 조용히 사라집니다.

Canva는 두 가지를 씁니다.

조건부 PUT으로 낙관적 동시성 제어를 구현합니다. 모든 조각 갱신에 "처음 읽은 이후로 이 조각이 바뀌지 않았다"는 전제 조건을 붙이고, 새 조각을 만들 때도 다른 프로세스가 같은 조각을 이미 만들지 않았는지 확인합니다. 이 전제 조건이 읽고 고쳐 쓰기를 항상 추가만 하는 연산으로 만들어, 실행이 어떻게 뒤섞여도 데이터가 사라지지 않도록 보장합니다.

ZooKeeper 리더 선출은 지속적인 충돌이 시스템에 부하를 주지 않게 하는 최적화입니다.

이 두 문장의 순서가 중요합니다. 원문은 리더 선출에 정확성을 의존할 수 없다고 명확히 씁니다. 노드가 쓰기 직전에 임의의 시간 동안 멈출 수 있고, 깨어났을 때는 이미 다른 노드가 리더가 되어 자기 변경을 써 놓았을 수 있습니다. PUT 조건이 없다면 잠에서 깬 노드가 그것을 덮어씁니다.

분산 시스템에서 리더 선출은 거의 항상 이 위치에 있어야 합니다. 충돌을 줄이는 장치이지 충돌을 불가능하게 하는 장치가 아닙니다. 정확성은 저장소가 제공하는 원자적 조건부 연산에 걸어야 합니다.

참고 자료

이 글의 수치(무효화 100만 건 이상, 12시간 창, 30분 조각, 16바이트, 메모리 87.5퍼센트 감소, 초당 2000건 이상, 읽기 복제본 2대)는 모두 위 Canva 원문에 적힌 값을 그대로 옮긴 것입니다. 직접 재현한 측정치가 아닙니다.