Vercel에서 가장 중요한 서비스 중 하나인 빌드 웜 풀(build warm pool)을 Redis에서 DynamoDB로 이전한 과정을 소개합니다. 운영 환경에서 직접 진행하되, 각 단계마다 롤백이 가능한 피처 플래그(feature flag) 기반의 단계별 전환 방식을 택해 안전하게 마이그레이션을 완료했습니다.
Vercel의 모든 빌드는 빌드 웜 풀에서 시작됩니다. 웜 풀은 미리 준비된 대기 컨테이너 집합으로, 새로운 컴퓨팅 자원이 올라올 때까지 기다리지 않고 빌드를 즉시 시작할 수 있게 해줍니다. 풀은 어떤 컨테이너가 준비됐는지, 각 컨테이너가 인증에 사용하는 토큰은 무엇인지, 그리고 실행 중인 각 빌드를 과금 대상 배포와 연결하는 매핑 정보를 상태로 관리합니다. 처음 설계할 당시에는 이 모든 상태를 Redis에 저장했습니다. 빠르고, 그 시점에 합리적인 선택이었습니다.
하지만 시간이 지나면서 이 상태 데이터가 부채로 바뀌기 시작했습니다. 토큰과 컨테이너 상태 정보는 유실되더라도 재구성이 가능하지만, 과금 매핑은 그렇지 않습니다. 그런데 이 모든 데이터가 임시 캐시로 운용하던 저장소에 쌓여 있었습니다. 이 상태 데이터는 내구성 있는 저장소에 있어야 했고, 그것이 DynamoDB로의 이전을 결정한 이유입니다.
문제는 풀이 단 한 순간도 멈추지 않는다는 점이었습니다. 컨테이너는 24시간 내내 올라오고, 폴링하고, 작업을 받아 처리하고, 만료됩니다. 세상을 잠시 멈추고 데이터를 복사한 뒤 재시작하는 방식은 애초에 불가능했습니다. 마이그레이션은 운영 트래픽이 흐르는 상태에서, 단계별로, 각 단계마다 피처 플래그를 달고 필요시 언제든 롤백할 수 있는 형태로 진행해야 했습니다.
처음에는 Redis가 좋은 선택이었습니다. 빠르고, 익숙하며, 풀의 초기 접근 패턴에 잘 맞았습니다. 그러나 시간이 흐르면서 데이터의 중요도가 그것을 담고 있는 저장소를 훌쩍 넘어섰습니다. Redis가 불가용 상태가 되거나 데이터를 잃으면, 풀은 컨테이너 인증도, 준비 상태 추적도, 처리 중인 작업 조회도 제대로 할 수 없게 됩니다.
토큰을 잃으면 약 10분 안에 재구성할 수 있습니다. 하지만 매핑을 잃으면 그 빌드는 영영 과금되지 않습니다. 어떤 배포에 속한 빌드인지 기록하는 곳이 달리 없기 때문입니다.
내구성 있는 저장소를 검토한 끝에 DynamoDB를 선택했습니다. 온디맨드 스케일링은 배포 트래픽의 급격한 변동에 잘 맞고, TTL을 기본으로 지원하며, 높은 동시성에서도 커넥션 관리가 필요 없습니다. 다만 Redis 수준의 지연 시간은 기대할 수 없었습니다.
Redis 안에서 풀의 상태는 다음과 같은 구조였습니다.
토큰은 집합(set)에 저장해 멤버십 확인에 쓰고, 정렬된 집합(sorted set)에도 저장해 만료 처리에 활용했습니다
각 라이프사이클 상태(pending, polling, building)마다 별도의 정렬된 집합이 있었고, 상태 변경은 한 집합에서 제거하고 다음 집합에 추가하는 작업으로 처리했습니다
문자열 하나로 작업 중인 컨테이너와 배포를 연결했습니다
이 모든 연산은 각각 약 1밀리초 수준으로 빠르게 처리됐고, 코드는 자연스럽게 그 속도에 기대도록 설계됐습니다. 각 웜 풀을 순서대로 채우는 공급 루프(supply loop)는 한 번 실행할 때마다 수백 번의 count 호출을 통해 pending, polling 상태의 컨테이너 수를 집계했습니다.
이것이 웜 풀의 성장 방식이었습니다. 손쉬운 쓰기 연산이 하나씩 쌓이면서, 반드시 보존해야 할 데이터가 속도를 위해 설계된 자료 구조 안에 자리 잡게 된 것입니다. 마이그레이션은 바로 이 구조를 풀어내는 것에서부터 시작됐습니다.
대부분의 마이그레이션 계획은 데이터베이스 자체를 교체 대상으로 봅니다. 접근 패턴을 파악하고, 데이터를 복사하고, 두 저장소의 내용이 일치하는지 검증합니다. 저희도 처음에는 그 방향으로 계획을 세웠습니다. 그래서 스키마를 설계하기 전에, 코드가 실제로 이 상태에 무엇을 요구하는지 먼저 목록으로 정리했습니다.
컨테이너가 작업을 폴링할 때 토큰 유효성 검증
새 컨테이너가 올라올 때 토큰 추가
기한이 지난 토큰 만료 처리
풀 크기 조정을 위한 토큰 수 집계
컨테이너 라이프사이클 상태 전환
상태별 컨테이너 수 집계
컨테이너 콜백에 연결된 배포 조회
만료된 컨테이너 제거
거의 모든 연산은 이미 어떤 컨테이너를 다루는지 알고 있고, 토큰에서 시작하는 경우는 폴링뿐입니다. 그래서 컨테이너를 모델의 중심에 두었습니다. 컨테이너 ID를 정렬 키(sort key)로 삼고, 토큰은 레코드의 필드 하나로 저장했습니다. 토큰은 해시 처리해 저장했기 때문에, 테이블을 읽어도 실제로 사용 가능한 인증 정보가 노출되지 않습니다. 이제 토큰 검증은 해당 컨테이너를 조회해 해시를 비교하는 것으로 충분합니다.
키 조회만으로 처리할 수 없는 접근 패턴도 두 가지 있었습니다. 상태 집계는 만료 기한이 지난 컨테이너를 제외해야 하므로 시간 인식 인덱스가 필요했고, 배포 조회는 과금에 직결되기 때문에 일관된 읽기가 필요했습니다. 매핑 정보는 전용 소형 테이블로 분리했습니다.
컨테이너 상태 조회, 상태 전환, 토큰 삭제는 모두 직접적인 키 조회로 처리할 수 있게 됐습니다. 상태 집계는 시간 인식 인덱스를 대상으로 단일 쿼리를 실행하면 충분합니다. 상태당 쿼리 하나로, 만료된 컨테이너는 처음부터 걸러진 결과를 얻을 수 있습니다.
Redis에서는 자료 구조 자체가 필요한 의도를 암묵적으로 담아줬다면, DynamoDB에서는 키, 인덱스, 조건부 쓰기, TTL 동작 방식을 모두 명시적으로 정의해야 했습니다. 그래서 Redis의 구조를 그대로 옮기는 대신, 컨테이너를 중심 모델로 새로 설계하고 접근 패턴에 맞는 인덱스를 추가했습니다.
롤아웃은 피처 플래그 기반의 단계별 전환으로 진행했습니다. Redis 단독 → 이중 쓰기 → 섀도 읽기 → DynamoDB 우선 → DynamoDB 단독 순서였습니다. 마지막 단계까지 가능한 한 롤백 여지를 열어두었습니다. Redis 쓰기를 완전히 제거하는 마지막 단계만이 쉽게 되돌리기 어려운 유일한 단계였지만, 남아 있는 토큰은 어차피 10분 안에 모두 만료됩니다.
먼저 기존 Redis 연산의 횟수와 지연 시간을 기준값으로 측정했습니다. 이후에 비교할 대상 기준선이 필요했기 때문입니다. 새로운 DynamoDB 메서드는 실제로 호출되기 며칠 전에 먼저 코드에 합쳤습니다. 다음으로 이중 쓰기를 배포했습니다. Redis가 여전히 진실의 원천(source of truth)이고, DynamoDB 실패는 장애로 처리하지 않고 로그만 남겼습니다. 그다음 섀도 읽기 단계에서는 두 저장소에 모두 쿼리해 결과를 비교했습니다. 마지막으로 DynamoDB를 기본 읽기 저장소로 전환했고, 새 경로가 충분히 검증될 때까지 Redis 쓰기는 유지했습니다.
두 저장소의 비교를 통해 확인할 수 있었던 것은 저장된 값이 서로 일치하는지였고, 이는 테스트만으로는 알 수 없는 부분이었습니다. 다만 타이밍에 대해서는 아무것도 알 수 없었습니다. 저희가 검증한 항목은 다음과 같습니다. 모든 쓰기가 두 저장소에 정상적으로 반영되는지, 컨테이너가 상태를 전환할 때 만료 시점이 계속 갱신돼 오래된 상태가 쌓이지 않는지, 토큰 삭제 후 컨테이너 레코드의 나머지 정보가 온전히 유지되는지, 집계 값이 풀 운영에 지장 없을 만큼 충분히 일치하는지를 확인했습니다.
비교 작업과 함께, Redis가 운영 트래픽을 처리하는 동안 매치율, 쓰기 오류, 쿼리별 지연 시간, 만료 건수를 추적하는 대시보드를 구축했습니다. 이 덕분에 컷오버 실패로 드러나기 전에, 원인 파악이 훨씬 수월한 단계에서 이상 징후를 포착할 수 있었습니다. 불일치가 발견될 때마다 실제 버그인지, 이중 쓰기 경쟁 조건인지, 아니면 예상된 차이인지 원인을 끝까지 추적한 뒤 다음 단계로 넘어갔습니다. 각 단계의 전환 여부는 이 대시보드가 결정했고, 운영 트래픽 아래서도 수치가 안정적으로 유지될 때마다 다음 단계에 대한 확신이 한층 높아졌습니다.
3월에 특정 리전의 빌드가 중단됐고, 원인을 추적하자 저희가 직접 만든 비교 장치가 문제였습니다. 두 저장소를 동시에 조회해 비교하는 추가 부하가 원인이었습니다. 이 상황은 사전 검토 단계에서도 거론된 적이 있었고, 바로 그 이유로 비교 횟수에 제한을 걸어두었지만 충분하지 않았습니다. 사흘 뒤 해당 장애를 언급한 풀 리퀘스트가 올라왔고, 상태 집계에 필요한 인덱스가 추가됐습니다. 이 인덱스가 없을 때 집계 연산은 O(n)의 작업이었고, 비교 과정에서 이 집계가 끊임없이 반복됐던 것입니다. 인덱스가 추가된 후 롤아웃을 재개할 수 있었습니다.
같은 달 말, 마이그레이션 대상이던 Redis 인프라가 실제로 다운됐습니다. 웜 풀과 토큰 처리는 이미 DynamoDB를 진실의 원천으로 읽고 있었기 때문에 정상적으로 유지됐습니다. 빌드는 장애의 영향을 받았지만, 아직 전환되지 않은 파이프라인 상위 서비스를 통해서였습니다. 마이그레이션이 끝나기도 전에 우리가 피하려 했던 그 장애가 먼저 찾아왔지만, 이미 이전을 마친 상태 데이터는 끄떡없었습니다.
4월, 공급 루프가 지연되기 시작했습니다. 기본 읽기를 전환할 당시 섀도 데이터는 이상이 없어 보였고, 대시보드의 쿼리별 지연 시간도 허용 범위 안에 있었습니다. 문제는 둘 다 측정하지 못한 동작에 있었습니다.
루프를 그림으로 그려 시간이 어디서 소비되는지 파악했습니다.
getWarmPoolTokenCount의 P95 기준으로, 해당 확인 작업은 Redis에서 1.29ms, DynamoDB에서 5.13ms였습니다. 당시에는 쿼리당 2~3배 느린 정도로 간략히 표현했는데, 이것이 과소 평가였음이 나중에 드러났습니다.
한 번만 치르는 몇 밀리초 차이는 아무것도 아닙니다. 하지만 루프는 컨테이너마다, 한 번 실행에 수백 번씩 그 비용을 치르고 있었습니다. 그 결과 루프 실행 시간이 최악의 경우 수 분까지 늘어났고, 풀이 수요를 따라잡을 수 없게 됐습니다. 공급 루프는 N+1 쿼리 문제를 안고 있었고, Redis는 그것을 덮어버릴 만큼 빠르기만 했던 것입니다.
"이 루프는 밀리초 수준의 읽기가 필요하다"고 어디에도 명시된 적이 없었습니다. 그 가정은 설계 안에 암묵적으로 녹아 있었고, 읽기 하나에 1밀리초라면 굳이 돌아볼 이유가 없었습니다.
DynamoDB가 Redis의 밀리초 단위 지연을 따라잡는 것은 불가능했습니다. 그래서 속도를 쫓는 대신, 그 요구 조건 자체를 설계에서 없애기로 했습니다.
첫 번째 시도는 배치 처리였습니다. 스케치 하단에 그려진 구조와 같습니다. 두 저장소 간의 속도 차이는 어느 백분위를 보느냐에 따라 달라지는데, 격차가 가장 큰 P90에서는 약 17배였습니다. 그래서 배치 설계는 컨테이너 17개당 상태를 한 번만 조회하도록 했고, 읽기 비용을 배치 전체에 분산시켰습니다. 이 숫자는 측정값에서 직접 가져온 것입니다. 하지만 바로 그 고정값 때문에 배치 방식을 포기했습니다. 지연 시간 비율을 하드코딩하면 부하에 따라 값이 달라지고, 결국 저장소 속도에 대한 또 다른 암묵적 의존성이 생기기 때문입니다. 동시성 방식은 이런 고정값이 애초에 필요 없었습니다.
실제로 배포한 방식은 루프의 공급 호출을 동시에 실행하는 것이었습니다. 각 웜 풀이 앞 풀의 처리가 끝날 때까지 기다릴 필요가 없고, 각 호출은 마지막으로 확인한 상태를 기반으로 동작합니다. 읽기가 겹쳐 실행되면서 배치 방식과 같은 효과를 얻을 수 있었습니다. 어떤 실행도 단일 읽기에 직렬화되지 않습니다. 최악의 경우, 풀 상태를 오래된 정보로 판단해 컨테이너를 몇 개 더 생성할 수 있지만, 이는 풀 리퀘스트에 감수할 수 있는 비용으로 명시했습니다. 이 재설계 덕분에 더 내구성 높은 저장소를 유지하면서도 사용자가 실제로 중요하게 여기는 것, 즉 빌드가 빠르고 안정적으로 시작되는 것을 지킬 수 있었습니다. 수정 사항도 롤아웃과 같은 방식으로 반영됐습니다. 한 번에 하나씩, 측정하면서. 팀원 중 한 명의 말처럼, "이전의 잘못된 추정을 바로잡았으니 각 반복마다 위험 부담이 줄어든다."
알고 보니 공급 루프는 Redis 시절에도 가끔 1분 이상 지연된 적이 있었는데, 이 사실은 DynamoDB 문제를 진단하는 과정에서야 드러났습니다. 텔레메트리 데이터에는 줄곧 그 흔적이 남아 있었지만, 풀이 요청 경로가 아닌 수요 앞단에 위치해 있었기 때문에 마이그레이션이 강제하기 전까지 아무도 들여다볼 이유를 찾지 못했습니다. 결과적으로 재설계는 이 프로젝트가 시작되기도 전부터 존재했던 결함을 함께 제거했습니다.
마이그레이션은 4월에 완료됐고, 웜 풀 경로에서 Redis 호출이 사라졌습니다. 데이터 복사는 사실 쉬운 부분이었습니다. 진짜 작업은 저장소 위에 쌓여 있던 암묵적 가정을 찾아내고, 그 위에서 작동하던 루프를 재설계하는 일이었습니다. 이제 모든 빌드가 의존하는 상태 데이터는 그것을 보관하기 위해 설계된 저장소에 있고, 이를 관리하는 루프는 더 이상 단일 읽기에 직렬화되지 않습니다.
4월 회고에서 이 교훈을 한 문장으로 정리했습니다. "P90 기준으로 쿼리 지연 시간이 1ms에서 15ms로 늘어나는 것만으로도 웜 풀 관리 로직 전체가 다운될 수 있다." 마이그레이션을 시작한 2월에는 아무도 이 문장을 쓰지 않았습니다.