얼마 전까지 마루 웹앱의 모든 변경은 Django API 변경과 똑같은 길로 나갔습니다. Docker 이미지를 빌드하고, 푸시하고, Fly.io의 머신 전부에 롤링 배포하는 식이었죠. 웹 프론트엔드(Expo 웹 export와 Hugo가 만드는 마케팅 페이지)가 API 옆에 이미지로 복사되어 있었기 때문에 다른 방법이 없었습니다. CSS 수정 하나에 7분쯤 걸리고 머신 11대가 재시작됐습니다. 8월 어느 날은 이걸 여섯 번 했습니다.
이 글은 그걸 무엇으로 바꿨는지 순서대로 따라갑니다. 배포 스크립트가 프론트엔드 트리 둘을 빌드하고, 버전 붙은 경로 아래 오브젝트 스토리지에 올리고, 포인터 객체 하나를 바꾸고, 머신 전부가 새 버전을 서빙할 때까지 기다린 다음, Cloudflare 캐시를 API로 퍼지합니다. 반대편에서는 작은 Django 모듈이 요청마다 포인터를 읽어 거기 적힌 버전을 서빙합니다. API는 아예 재시작하지 않습니다.
예전 방식이 왜 문제였나#
가장 큰 비용은 재시작이었습니다. 머신 11대가 롤링된다는 건 콜드 부팅 11번이라는 뜻이고, 공유 CPU에서 Django가 뜨는 데 13초 정도 걸리는데 그중 7초는 그냥 Django를 import하는 시간입니다. 실제 방문자가 그 비용을 치르지 않도록 배포 스크립트 끝에 머신 전부를 한 번씩 찍는 워밍업 단계를 뒀는데, 결국 모든 웹 릴리스가 가장 느린 머신을 기다리는 걸로 끝났습니다. 그 콜드 부팅이 Fly.io에서 얼마나 비싼지는 0으로 스케일 다운, 그리고 재시작마다 30초짜리 장애에 따로 써 뒀습니다. 요약하면 재시작 하나하나가 방문자가 504를 받을 수 있는 구간이라서, 아무것도 재시작하지 않는 배포 경로는 웹 릴리스에서 그 구간을 아예 없애 줍니다.
두 번째 문제는 Hugo 출력물이 둘로 갈라져 있었다는 겁니다. HTML은 이미지에 들어갔지만 RSS 피드와 검색 인덱스는 바로 버킷으로 갔습니다. 같은 소스에서 빌드되긴 하는데 같은 시점은 아니라서 서로 어긋날 수 있었고, 어긋나기 전까지는 아무도 몰랐습니다.
세 번째는 습관에 가깝습니다. 릴리스 한 번에 7분과 재시작이 들면 작은 릴리스를 안 하게 됩니다. 수정 서너 개를 하나씩 내보내는 대신 쌓아 두게 되고, 그게 바로 한 줄짜리 변경이 망가진 무언가와 함께 나가는 방식이죠.
버킷 구조#
전부 Tigris의 정적 버킷 안 web/ 경로 아래에 있습니다.
web/current ← 버전 문자열 하나가 담긴 텍스트 파일
web/a1b2c3d-20260914021500/manifest.json ← 이 버전의 파일 전체 목록
web/a1b2c3d-20260914021500/dist/... ← Expo 웹 export
web/a1b2c3d-20260914021500/hugo/... ← Hugo 출력물
web/9f8e7d6-20260912093012/... ← 이전 버전, 그대로 남아 있음버전은 짧은 git SHA에 UTC 타임스탬프를 붙인 것이라 어떤 커밋이 언제 올라갔는지 바로 읽힙니다. 릴리스가 라이브로 갈 때 바뀌는 건 web/current 하나뿐입니다.
배포 스크립트가 하는 일#
전체가 scripts/deploy_web.py 스크립트 하나이고, 저장소 루트에서 실행합니다. 순서대로:
1. 더러운 트리는 거부합니다. 제일 먼저 git status --porcelain을 돌리고 뭔가 나오면 종료합니다. 디스크에 있는 게 그대로 올라가는데, 예전 Hugo 배포 스크립트가 그렇게 커밋 안 된 문구를 내보낸 적이 있거든요. 뭘 하는지 알고 쓰는 경우를 위한 --allow-dirty 플래그가 있지만 아직 써 본 적은 없습니다. 그다음 git rev-parse --short HEAD와 현재 시각으로 버전 문자열을 만듭니다.
2. 두 트리를 빌드합니다. 앱은 npm run build:web:prod를 돌리는데, 프로덕션 API와 WebSocket URL을 박아 넣은 expo export --platform web에 이어 Brotli 사전 압축과 관리자용 블록 에디터 번들을 빌드합니다. 사전 압축은 설명할 가치가 있습니다. Cloudflare가 실시간으로 Brotli 압축을 해 주긴 하는데 낮은 품질 레벨이라, 이 번들에서는 gzip보다 오히려 나빴습니다(1.66MB 대 2.04MB). 빌드 시점에 품질 11로 한 번만 압축하면 1.28MB가 되기 때문에, 스크립트가 1KB 넘는 JS, CSS, JSON 파일마다 .br 쌍둥이를 옆에 써 둡니다. 마케팅 사이트는 hugo-site/에서 hugo --gc --minify를 돌려 public/에 씁니다.
3. 업로드합니다. mobile-app/dist와 public/을 훑어서 모든 파일을 스레드 16개로 web/<version>/dist/...와 web/<version>/hugo/...에 올립니다. 객체마다 파일명에서(.br을 먼저 떼고) 추측한 Content-Type을 붙이고, Brotli 쌍둥이에는 Content-Encoding: br도 붙입니다. 파일이 전부 올라간 뒤에야 상대 경로 목록 두 개가 담긴 JSON인 manifest.json을 씁니다. 이 순서가 중요합니다. Django 쪽은 매니페스트 없는 버전을 존재하지 않는 것으로 취급하기 때문에, 반쯤 올라간 버전은 절대 현재 버전이 될 수 없습니다. 지금은 314개 파일이고 업로드에 6초 정도 걸립니다.
4. 포인터를 바꿉니다. put_object 한 번으로 web/current에 버전 문자열을 씁니다. 그게 릴리스입니다.
5. 머신들을 기다립니다. API 머신마다 포인터를 5초 캐시하니까, 바꾼 직후 잠깐은 일부가 아직 옛 버전입니다. 스크립트는 /api/web-version/을 0.5초마다 폴링하면서 새 버전을 담은 연속 응답을 셉니다. Fly가 머신 전체에 부하를 분산하므로 일치하는 응답이 16번 연속(머신 수보다 많이) 나오면 전부 포인터를 다시 읽었다고 판단합니다. 옛 응답이 하나라도 오면 카운트가 리셋됩니다. 90초 뒤엔 포기하고 직접 확인하라고 알려 줍니다. 첫 실제 전환이 30초 조금 넘게 걸려서 제한을 더 낮추지 않았습니다.
6. Cloudflare를 퍼지합니다. API 호출 한 번이고, 아래에서 다룹니다. 실패하면 경고를 찍고 계속 갑니다.
7. Discord에 올립니다. 런타임 에러 알림이 쓰는 웹훅에 버전과 실행한 사람을 한 줄로 보냅니다. 에러가 몰려올 때 방금 뭐가 바뀌었는지 대조해 볼 수 있게요. 롤백은 주황색으로 올라갑니다.
처음부터 끝까지 릴리스 한 번에 1분 조금 넘게 걸리고, 대부분은 Expo export 시간입니다.
Django 쪽#
프론트엔드를 서빙하는 건 원래부터 Django였습니다. API가 아닌 URL은 전부 해당 라우트의 Expo export에서 프리렌더된 페이지를 돌려주는 뷰가 처리하고, 마케팅·개인정보처리방침·지원 페이지는 Hugo 출력물에서 옵니다. 이 뷰들은 예전엔 디스크의 파일을 열었습니다. 지금은 web_bundle.py라는 작은 모듈 하나를 거치는데, 그 안의 WebFiles 클래스를 모든 뷰가 대신 호출합니다.
body = web_files.read("dist", "tabs/home.html")요청마다 세 가지를 풉니다. 현재 버전(web/current에서 읽고 5초 캐시), 그 버전의 매니페스트(그 버전이 현재인 동안 캐시), 그리고 파일 본문(프로세스당 48MB 정도의 LRU라서 페이지 HTML과 메인 번들은 메모리에 남습니다). 404가 싼 이유가 매니페스트입니다. 경로가 목록에 없으면 버킷에 물어보지 않고 없다고 답합니다. 클라이언트가 Brotli를 받으면 .br 쌍둥이 파일을 대신 읽고 인코딩 헤더를 붙입니다. 에셋은 파일명에 콘텐츠 해시가 있으니 1년짜리 immutable 캐시 헤더를 달고 나갑니다.
같은 모듈이 Expo 라우트 테이블도 만듭니다. /tabs/home이 tabs/home.html로 매핑되는 식이고, 버전별로 캐시되기 때문에 새 배포가 나가면 첫 요청에서 다시 만들어지고 그 전에는 안 만듭니다.
그리고 배포 스크립트가 폴링하는 엔드포인트가 있습니다.
def web_version(request):
response = JsonResponse({"source": web_files.source(), "version": web_files.version()})
response["Cache-Control"] = "no-store"
return response/api/web-version/은 프로덕션에서는 bucket:<version>을, 이미지에서 서빙 중이면 filesystem을 답합니다. Fly 머신마다 자기 자신에 대해 답하기 때문에 열두 번쯤 찍어 보면 전체가 수렴했는지 알 수 있습니다. 뭔가 이상해 보일 때 어떤 머신이 뭘 라이브라고 생각하는지 확인하는 가장 빠른 방법이기도 합니다.
Cloudflare 퍼지#
marucommunity.com의 HTML은 Cloudflare 엣지에 5분간 캐시됩니다. 퍼지를 안 하면 배포는 머신에 올라가 있는데 대부분의 방문자는 최대 5분 동안 옛 페이지를 계속 받게 되니, 애초 목적이 무색해집니다.
퍼지는 API 호출 한 번입니다.
urllib.request.Request(
f"https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
data=json.dumps({"hosts": ["marucommunity.com", "www.marucommunity.com"]}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)페이지를 일일이 나열하지 않으려고 URL이 아니라 호스트 단위로 퍼지합니다. 에셋은 퍼지할 필요가 아예 없습니다. 파일명에 콘텐츠 해시가 붙어 있어서 새 빌드는 새 파일명을 가리키고 옛 파일은 그냥 참조되지 않게 되니까요. 그 파일명을 참조하는 HTML만 새로 고치면 됩니다.
스크립트가 퍼지 전에 머신을 기다리는 건 일부러 그런 겁니다. 먼저 퍼지하면 Cloudflare가 아직 옛 버전인 머신에서 캐시를 다시 채울 수 있고, 그 페이지가 엣지에 5분 더 앉아 있게 됩니다.
퍼지가 실패하면 스크립트는 경고를 찍고 그래도 끝까지 갑니다. 캐시는 5분 안에 알아서 만료되니 최악의 경우라도 느린 릴리스이지 망가진 릴리스는 아닙니다. 마지막 단계에서 배포가 중단되는 것보다는 그편이 낫습니다.
라이브 전에 빌드 확인하기#
--no-flip은 3단계까지만 하고 멈춥니다. 버전은 버킷에 있는데 아무도 서빙하지 않는 상태죠. 확인하려면 머신 한 대에 WEB_BUNDLE_VERSION=<version>을 설정하면 되는데, web/current가 뭐라고 하든 그 머신은 그 빌드에 고정됩니다. 괜찮아 보이면 --rollback <version>으로 현재 버전으로 만듭니다. 이름은 롤백이지만 실제로는 “current를 이 버전으로 가리켜라"이고, 앞으로 가나 뒤로 가나 같은 동작입니다.
롤백#
옛 버전은 버킷에 남아 있으니, 롤백은 이전 버전으로 --rollback <version>을 하는 것이고 --list가 최신순으로 뭐가 있는지 보여줍니다. 앞으로 가는 배포와 똑같이 기다리고 퍼지합니다. 아직 써 본 적은 없습니다. 지금은 옛 버전을 지우는 건 아무것도 없는데, 작기도 하고 없는 것보다 있는 편이 낫습니다.
그대로인 것#
API는 여전히 deploy-all.sh로 예전 방식대로 배포하고, 그래야 합니다. 상태가 있고 마이그레이션이 실행되는 곳이니까요. Dockerfile도 여전히 mobile-app/dist와 public/을 이미지에 복사합니다. 이건 의도한 겁니다. 버킷 설정이 없거나(로컬 개발, 테스트 스위트) 버킷을 읽을 수 없으면 WebFiles가 디스크의 그 디렉터리로 넘어가기 때문에, 스토리지 장애가 나도 방문자는 에러 페이지 대신 마지막 이미지에 실려 나간 프론트엔드를 받습니다. 버킷을 잠시 빼야 할 일이 생기면 WEB_BUNDLE_FROM_BUCKET=0으로 프로덕션에서 그 동작을 강제할 수 있습니다.
프론트엔드는 API보다 훨씬 자주 바뀌고, 이제 릴리스 한 번에 7분이 아니라 1분쯤 걸립니다. 애쓰지 않았는데도 모아서 배포하는 버릇이 없어졌습니다.

