$ cd /blog
오류 해결

Railway로 옮겼더니 네트워크 요금이 붙었습니다 — 사라진 CDN과 page cache 과금

Vercel에서 Railway로 옮긴 뒤 네트워크 요금이 메모리만큼 나왔습니다. 원인은 코드가 아니라 '이전과 함께 사라진 CDN'이었고, 메모리 요금에는 파일 캐시까지 포함돼 있었습니다. 진단 방법과 Cloudflare 캐시 규칙으로 잡은 과정을 정리했습니다.


안녕하세요. Jay입니다!

지난번에 Vercel에서 Railway로 사이트를 옮긴 이야기메모리 500MB의 범인을 찾은 이야기를 썼는데요, 이전을 마치고 한 주쯤 지나 요금 화면을 열어봤습니다. 그런데 예상 못 한 항목이 눈에 들어왔습니다. 네트워크 요금이 메모리 요금과 거의 같은 금액으로 쌓이고 있었던 겁니다.

메모리는 그렇다 쳐도 네트워크는 왜 나올까요. 방문자가 갑자기 늘어난 것도 아닌데 말이죠. 파보니 원인은 코드가 아니라 이전과 함께 조용히 사라진 것에 있었습니다.

📊 먼저 요금이 어디서 나오는지 확인하기

Railway는 컨테이너 리소스 기준으로 과금합니다. 요금 화면에 CPU·메모리·네트워크·볼륨이 따로 찍히는데, 제 경우는 이랬습니다.

항목 청구액 환산하면
CPU $0.01 거의 안 씀
메모리 $0.82 평균 약 0.9GB
네트워크 $0.84 약 17GB 전송
볼륨 $0.00 미사용

네트워크 요금(egress)은 GB당 $0.05입니다. 거꾸로 나누면 17GB가 서버 밖으로 나갔다는 뜻이죠. 개인 사이트치고는 꽤 큰 숫자입니다.

🔍 진단 1 — 이전과 함께 CDN이 사라졌다

Vercel을 쓸 때는 페이지가 CDN에 캐시돼서, 같은 페이지를 백 명이 봐도 서버는 한 번만 일했습니다. 저는 그 전제로 캐시 헤더도 넉넉하게 잡아뒀고요.

문제는 Railway가 CDN을 주지 않는다는 점이었습니다. 컨테이너를 실행해주는 곳이지 캐시 계층이 딸려오는 곳이 아니거든요. 헤더는 그대로 남아 있는데 그 헤더를 읽어줄 CDN이 사라진 상태였습니다.

앞에 Cloudflare를 두고 있었으니 괜찮을 거라 막연히 생각했는데, 실제로 확인해보니 아니었습니다. 확인 방법은 간단합니다. 응답 헤더의 cf-cache-status만 보면 됩니다.

curl -sI https://내도메인.com/ | grep -i cf-cache-status
# cf-cache-status: DYNAMIC   ← 캐시 안 됨. 매 요청이 서버까지 옴

DYNAMIC은 "캐시 대상이 아님"이라는 뜻입니다. 페이지도, 이미지 변환 결과도 전부 이 상태였습니다. Cloudflare는 기본적으로 확장자가 붙은 정적 파일만 캐시하고 HTML은 규칙을 따로 만들지 않으면 그냥 통과시킵니다.

여기에 검색 봇이 더해집니다. 제 사이트는 페이지가 300개가 넘는데, 크롤러가 밤새 전 페이지를 훑으면 HTML·자바스크립트·이미지가 통째로 서버에서 나갑니다. 17GB의 정체가 이거였습니다.

🧠 진단 2 — 메모리 요금에는 파일 캐시까지 들어간다

메모리 쪽에서도 이상한 점이 있었습니다. 서버에서 직접 잰 프로세스 메모리(RSS)는 150~260MB인데 요금은 평균 0.9GB 기준으로 붙고 있었거든요. 세 배 넘게 차이가 납니다.

찾아보니 Railway 공식 답변이 있었습니다. 요금에 쓰이는 메모리 지표에는 buff/cache, 즉 리눅스 커널이 잡아두는 파일 캐시가 포함된다는 것이었습니다.

파일 캐시는 디스크에서 읽은 내용을 메모리에 들고 있는 영역이라, 메모리가 부족해지면 커널이 알아서 회수합니다. 서버가 죽을 위험은 없어서 저는 "무해하다"고 판단하고 넘어갔었는데, 요금 관점에서는 전혀 무해하지 않았던 겁니다. 서버 안정성만 보고 판단한 것이 실수였습니다.

🛠 해결 — Cloudflare 캐시 규칙 세 가지

정리하면 할 일은 하나였습니다. 사라진 CDN 계층을 Cloudflare로 다시 세우는 것. 세 가지를 적용했습니다.

1. 정적 자산 캐시 규칙 — 이미지·폰트·OG 이미지처럼 빌드 후 바뀌지 않는 것들은 엣지 캐시 30일로 잡았습니다. Cloudflare 대시보드의 Caching → Cache Rules에서 만들 수 있습니다.

2. HTML 캐시 규칙/api/와 내부 경로를 제외한 나머지 경로를 캐시 대상으로 지정하고, 만료 시간은 서버가 보내는 헤더를 따르게 했습니다.

3. 배포 후 자동 퍼지 — 이게 빠지면 큰일 납니다. HTML을 오래 캐시해두면 새로 배포해도 방문자에게는 옛날 페이지가 계속 보입니다. Vercel은 배포할 때마다 CDN을 알아서 비워주지만 Railway에는 그런 게 없어서, GitHub Actions에 퍼지 단계를 붙였습니다.

- name: Purge Cloudflare cache
  run: |
    curl -sS -X POST \
      -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
      -H "Content-Type: application/json" \
      --data '{"purge_everything":true}' \
      "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache"

적용 후 검증은 같은 주소를 두 번 요청하면 됩니다.

# 1회차: MISS (캐시에 없어서 서버까지 감)
# 2회차: HIT  (엣지에서 바로 응답 — 서버는 일하지 않음)
curl -sI https://내도메인.com/ | grep -i cf-cache-status

두 번째 요청에서 HIT가 뜨면 성공입니다. 참고로 규칙이 퍼지는 데 15~30초쯤 걸립니다. 저장하자마자 확인하면 아직 안 걸린 것처럼 보이니 조금 기다렸다 확인하세요.

🎁 예상 못 한 수확 하나

사실 이 사이트는 Vercel 시절에 HTML 캐싱을 여러 번 시도했다가 전부 실패한 이력이 있습니다. Next.js가 응답에 자동으로 붙이는 Vary 헤더 때문에 Cloudflare가 캐싱을 거부했거든요. 그래서 이번에도 HTML 규칙은 "안 되면 지우자"는 마음으로 넣었습니다.

그런데 됐습니다. 같은 Vary 헤더가 그대로 붙어 있는데도 MISS → HIT가 정상적으로 찍혔습니다. 왜 Vercel에서는 막혔고 Railway에서는 통과했는지, 원인은 아직 정확히 규명하지 못했습니다. 원점 서버가 바뀌면서 응답의 다른 조건이 달라졌을 것으로 추측만 하고 있습니다. 확실한 건 한 번 실패했던 설정도 환경이 바뀌면 다시 시험해볼 가치가 있다는 것 정도네요.

💡 Jay의 한 줄

플랫폼을 옮길 때 우리는 보통 "무엇을 옮길까"만 챙깁니다. 코드, 환경변수, 도메인, 데이터베이스처럼요. 그런데 이번에 제가 놓친 건 "옮기면서 무엇이 사라지는가" 였습니다. Vercel의 CDN과 자동 캐시 비우기는 요금표에 항목으로 적혀 있지 않아서, 없어진 줄도 몰랐던 거죠.

요금은 코드 품질이 아니라 구조에서 나옵니다. 이전을 계획 중이시라면 옮기는 목록 옆에 "딸려 있던 기능 목록"도 같이 적어보시길 권합니다.

결론

  • Railway 네트워크 요금은 egress GB당 과금 — 캐시가 없으면 크롤러 트래픽이 그대로 요금이 됩니다
  • cf-cache-statusDYNAMIC이면 캐시가 전혀 안 되고 있다는 신호입니다
  • Railway 메모리 요금에는 파일 캐시(buff/cache)까지 포함되므로 프로세스 메모리만 보고 안심하면 안 됩니다
  • Cloudflare 캐시 규칙으로 CDN 계층을 세우되, 배포 후 자동 퍼지는 반드시 함께 넣으세요
  • 서비스별 리소스 상한을 걸면 파일 캐시로 인한 메모리 요금도 상한선을 둘 수 있습니다

다음에도 유익한 포스팅으로 찾아오겠습니다. 감사합니다!

📚 참고 / 출처

#Railway#Cloudflare#Next.js#CDN#비용 최적화

$ ls related/

더 많은 IT·AI 활용법이 궁금하다면?

매주 새로운 실전 가이드가 업데이트됩니다.

전체 글 보기