휴대폰으로 끝낸 Cloudflare 이전

오늘 아침 7시 51분, Vercel에서 온 메일 한 통을 스크린샷으로 찍어 AI 어시스턴트에게 보냈습니다. 무료 팀의 CDN 요청 한도 100만 건 중 75%를 썼다는 경고였습니다.

처음 한 질문은 뻔했습니다. 가만히 두면 어떻게 되냐는 것이었습니다. 답은 생각보다 나빴습니다. 무료 플랜에는 매달 초기화가 없습니다. 100만 건을 넘기면 프로젝트가 멈추고, Pro로 올리지 않는 한 보통 30일 동안 그대로입니다. 이 팀에서 돌고 있는 사이트가 llm-digest.com입니다. 2월부터 만들기 시작해서 만든 과정, 파이프라인을 쪼갠 이야기, 쪼갠 것이 망가진 이야기까지 전부 이 블로그에 적어 온 AI 뉴스 피드입니다.

75%까지는 가만히 있어도 괜찮았습니다. 다만 사용량은 줄어들 기미가 없었습니다. 그래서 7시 54분에 물었습니다. 이참에 옮겨 볼까, Cloudflare로?

75만 건 중에 독자는 거의 없었습니다

어시스턴트는 추측부터 하지 않고 저장소부터 읽었습니다. 돌아온 답은 이랬습니다. 실제 독자 트래픽은 지난여름에 쟀을 때 주 1,600 페이지뷰 정도였습니다. 75만 건의 대부분은 정적 페이지를 긁어 가는 봇과 크롤러였습니다.

이 차이가 중요합니다. Vercel 무료 플랜은 요청을 전부 같은 한도에서 깎습니다. 크롤러가 정적 HTML을 가져가나 독자가 가져가나 똑같이 한 건입니다. Cloudflare는 정적 자산을 아예 세지 않습니다. 세는 것은 Worker 호출뿐이고, 하루 10만 건에 매일 초기화됩니다. 제 API 엔드포인트 열한 개가 그 한도에 닿으려면 지금보다 예순 배는 바빠져야 합니다.

다른 선택지는 월 20달러짜리 Vercel Pro였습니다. 손은 안 갑니다. 대신 새는 곳은 그대로 두고 양동이만 키우는 셈입니다. 이전은 양동이를 키우는 대신 한도 걱정 자체를 없앱니다. 하자고 했습니다.

실제로 옮긴 것

사이트의 모양이 이전에 유리했습니다. 정적으로 렌더링한 HTML, /api 아래 작은 Node 서버리스 함수 열한 개, 두 시간마다 피드 데이터를 다시 만들어 main에 푸시하는 GitHub Action이 전부였습니다. 어시스턴트가 가져온 계획은 이랬습니다.

  • 정적 페이지는 손대지 않고 Worker의 정적 자산으로 그대로 옮깁니다.
  • /api 함수 열한 개는 Worker fetch 핸들러로 바꿉니다. 여기에 진짜 문제가 하나 있었습니다. 이 함수들은 디스크에서 JSON을 읽는데(latest.json은 100KB 남짓, judgments.json은 3.8MB 정도) Worker에는 디스크가 없습니다. 데이터는 Cloudflare의 오브젝트 스토리지인 R2로 옮깁니다.
  • 데이터를 발행하던 두 시간짜리 GitHub Action에 단계 두 개를 붙입니다. 바뀐 JSON을 R2에 동기화하고 Worker를 배포하는 단계입니다. 원래 있던 발행 파이프라인이 그대로 배포 파이프라인이 됩니다.
  • 자잘한 코드 변환도 있었습니다. node:crypto는 Web Crypto로, 환경 변수는 wrangler 시크릿으로 옮겼습니다. 무료 티어는 요청당 CPU가 10밀리초라서, 큰 JSON은 요청 때마다 파싱하지 않고 모듈 스코프에서 한 번만 파싱하게 바꿨습니다.

두 시간 동안 있었던 일

지금 봐도 믿기지 않아서 타임라인을 그대로 옮겨 둡니다.

  • 7시 51분 - 경고 메일을 전달하고 무시하면 어떻게 되는지 물었습니다.
  • 7시 58분 - 하자고 답했습니다.
  • 8시 21분 - 패치가 나왔습니다. 테스트 23개 전부 통과. 모든 페이지와 엔드포인트를 운영 사이트와 대조해 검증했습니다.
  • 8시 35분 - workers.dev 주소로 미리보기가 열렸습니다. 운영 사이트는 그대로입니다.
  • 8시 40분 - 실제 데이터가 R2에 들어갔고 미리보기가 완전한 복제본이 됐습니다.
  • 8시 59분 - 임시 주소로 이메일 구독이 끝까지 동작하는 것을 확인했습니다.
  • 9시 39분 - 브랜치가 올라갔고 PR이 열렸고 자동 배포가 연결됐습니다. 이제 main에 푸시하면 데이터가 R2에 동기화되고 Worker가 다시 배포됩니다.
  • 9시 47분 - PR을 머지했고 전환이 시작됐습니다. Vercel DNS 레코드 두 개를 지우고 커스텀 도메인 둘을 Worker에 연결한 뒤 운영 도메인을 페이지마다 확인했습니다.
  • 10시 01분 - 끝났습니다. llm-digest.com이 Cloudflare에서 나갑니다.

경고 메일을 받고 나서 이전이 끝나기까지 두 시간 십 분. 노트북은 한 번도 열지 않았고, 자격 증명을 주고받은 순간까지 전부 iMessage에서 일어났습니다. 이 대목이 따로 설명할 만합니다. 휴대폰만으로 충분했던 이유가 여기에 있기 때문입니다.

제가 한 일

제 몫은 짧았습니다. 할 가치가 있는 일인지 판단하고, API 토큰 네 개를 만들고, R2 활성화 버튼을 한 번 누르고, Resend 대시보드에서 키 하나를 가져오고, 준비가 됐을 때 “전환하자”라고 말하는 것. 이게 전부였습니다.

되돌리기 어려운 단계는 모두 제 대답을 기다렸습니다. PR 머지는 언제 해도 안전했고, 도메인 전환은 “전환하자”는 말이 나온 뒤에야 움직였습니다.

시크릿은 어떻게 움직였나

이런 작업에서 제일 조심해야 하는 일은 API 토큰을 채팅창에 붙여 넣는 것입니다. 시크릿은 한 번도 채팅을 지나지 않았습니다.

자격 증명이 필요할 때마다 어시스턴트가 보안 링크를 보내왔습니다. 저는 각 서비스 대시보드에서 토큰을 발급받아 그 링크에 넣었고, 토큰은 어시스턴트가 쓸 수는 있지만 누구에게도 다시 보여줄 수 없는 금고로 들어갔습니다. 순서대로 적으면 Cloudflare API 토큰, R2 접근 키 쌍, Vercel API 토큰, GitHub 토큰입니다. GitHub 토큰은 두 번 넣었습니다. 자동 배포 워크플로 파일에 필요한 workflow 권한이 처음 것에 없어서, 권한을 추가하고 다시 넣었습니다.

어시스턴트가 스스로 가져오지 못한 시크릿이 하나 있었는데 Resend API 키였습니다. 이메일 환경 변수 다섯 개 중 네 개는 Vercel API 토큰으로 옮겨졌지만, Vercel은 계정 권한이 있는 토큰에도 민감한 값은 돌려주지 않습니다. 그래서 그 하나만 Resend 대시보드에서 직접 가져와 다른 토큰과 똑같이 링크에 넣었고, 어시스턴트가 쓰기 전에 승인 버튼을 한 번 더 눌렀습니다.

채팅에는 시크릿만 빼고 모든 것이 오갔고, 그래서 채팅만으로 충분했습니다. 안전하게 하려면 노트북을 펴야 하는 순간이 한 번도 없었습니다.

속도보다 기억에 남는 두 순간

그날 아침, 속도보다 오래 남은 장면은 따로 있었습니다.

하나는 PR의 CI가 빨간불이었을 때입니다. 이전이 뭔가 망가뜨렸구나 싶었는데, 어시스턴트는 대답 전에 확인부터 했습니다. main이 이미 똑같은 오류로 실패 중이었습니다. 그날 아침 콘텐츠 발행이 새 스토리라인을 카탈로그에 넣고 렌더링된 페이지는 커밋하지 않았고, 제 편집 테스트는 카탈로그의 모든 항목에 페이지를 요구합니다. 원인은 PR이 아니라 이미 실패 중이던 main이었습니다. 검사를 탓하며 그냥 넘어가기 쉬웠을 텐데, 돌아온 말은 이전과 무관한 제 쪽 버그라는 설명과, 그 스토리라인이 운영 사이트에서 지금 아마 404라는 알림이었습니다.

다른 하나는 전부 초록불이 된 다음이었습니다. 어시스턴트가 자기 작업에서 문제를 찾아냈습니다. 저장소에 커밋된 wrangler 설정의 routes 블록이 주석 처리된 채라, 이후 자동 배포가 돌 때마다 커스텀 도메인이 조용히 떨어져 나갈 상태였습니다. 알림은 울리지 않았을 것이고, 사이트는 몇 주 뒤 어느 배포에서 이유도 모른 채 도메인에서 내려왔을 것입니다. 수정을 커밋하고, 배포를 기다리고, 운영 도메인 검증을 처음부터 다시 돌렸습니다. 마지막 변경이 끝난 뒤에 검증이 다시 돌았다는 것. 그 순서가 있었기에 앞의 두 시간을 믿을 수 있었습니다.

되돌아가야 한다면

Vercel은 아직 그대로 서 있습니다. 마지막 배포는 손대지 않았고, 돌아가는 방법은 Cloudflare에서 DNS 레코드 두 개를 다시 추가하는 것, 1분이면 됩니다. 제가 끈 것은 트리거입니다. 이제 main에 푸시해도 Vercel은 빌드하지 않으니 두 시스템이 따로 놀 일이 없습니다. 되돌아갈 길은 열어 두되, 어느 쪽도 서로를 건드리지 않습니다.

왜 두 시간이면 됐는가

제목만 보면 엉뚱한 결론이 나오기 쉬운 글이라, 이 대목은 정확히 쓰고 싶습니다.

타이핑이 빨라서가 아닙니다. 시스템이 이미 에이전트가 읽기 쉬운 모양이었기 때문입니다. 파이프라인은 읽을 수 있는 GitHub Action에 있었고, 데이터는 숨은 상태를 가진 데이터베이스가 아니라 평범한 JSON 파일이었고, API는 프레임워크의 마법 없는 작은 파일 열한 개였습니다. 저장소는 자기 규칙을 스스로 문서로 적어 두고 있었습니다. 파헤쳐야 할 것이 거의 없었으니, 시간은 대부분 코드를 해석하는 데가 아니라 실행하고 검증하는 데 쓰였습니다.

두 번째 이유가 위에서 적은 자격 증명 전달 방식입니다. 시크릿은 전부 대화가 아니라 보안 링크를 통해 금고로 들어갔기에, 접근 권한을 넘기는 일까지 다른 모든 것과 같은 대화 안에서, 같은 기기로 끝낼 수 있었습니다. 이게 없었다면 이전은 그래도 됐겠지만, 휴대폰에서는, 그리고 두 시간 안에는 안 됐습니다.

2월에 사이트를 에이전트 친화적으로 만드는 법을 쓸 때는 남의 에이전트가 제 글을 읽는 그림을 생각했습니다. 보상이 제 쪽에서 올 줄은 몰랐습니다. 에이전트가 사이트를 읽게 만드는 명료함이, 에이전트가 사이트를 운영하게도 만들어 줬습니다.

나머지는 배포는 싸고, 방향은 비싸다에 쓴 이야기입니다. 그날 아침 제 몫은 결정 몇 개와 보안 링크 몇 번이었습니다. 예전 같으면 주말을 통째로 썼을 일, 이식과 설정과 재시도와 검증이, 딴 일을 하다 틈틈이 읽는 메시지로 줄었습니다. 줄지 않은 것은 판단입니다. 옮길지 말지, 언제 전환할지, 무엇을 되돌아갈 길로 남길지는 여전히 제 몫이고, 그래야 한다고 생각합니다.