$ cd /blog

Vercel 무료 플랜 한도에 걸려 Railway로 옮겼습니다 — Next.js 이전 실전기

Vercel 무료 플랜 사용량 한도가 조여올 때 Next.js 사이트를 Railway로 옮기는 전 과정을 실제 이전 경험으로 정리했습니다. standalone 빌드·Dockerfile·cron 대체·환경변수 이사·DNS 전환 체크리스트까지.


안녕하세요. Jay입니다!

Vercel은 Next.js를 올리기에 정말 편한 곳이지만, 무료(Hobby) 플랜에는 사용량 한도가 있습니다. 트래픽이 늘거나 검색 봇이 열심히 돌기 시작하면 대시보드의 Usage 그래프가 슬금슬금 올라가는데, 이게 은근히 신경 쓰입니다. 저도 도구 60여 개 + 블로그 50여 편을 올려둔 Next.js 사이트를 운영하다가 결국 Railway로 이전했고, 오늘 도메인 전환까지 마쳤습니다. 이전하면서 밟은 순서와 실제로 걸려 넘어진 함정들을 정리해봤습니다.

🚨 왜 옮겼나 — 한도는 트래픽이 아니라 '크롤'로 찼습니다

제 계정에서 문제가 된 항목은 방문자 수가 아니라 ISR Reads(정적 페이지 캐시 읽기 횟수)였습니다. 30일 누적이 한도의 70~80%까지 올라왔는데, 원인을 보니 사람이 아니라 검색 엔진 크롤러였어요. 마침 URL 구조를 바로잡는 작업을 하면서 구글이 사이트 전체를 다시 크롤하는 중이었고, 하루 사용량이 몇 배로 뛰었습니다.

여기서 판단이 갈립니다. 크롤을 막으면 사용량은 줄지만, 검색 유입 회복이라는 원래 목표가 무너집니다. 써야 하는 비용이었기 때문에 아낄 수 없었고, 그래서 "사용량을 아끼는 방향" 대신 "사용량 계산 방식이 다른 곳으로 옮기는 방향"을 골랐습니다. Railway는 페이지 읽기 횟수가 아니라 컨테이너 리소스(CPU·메모리·실행 시간) 기준으로 과금하니 크롤이 늘어도 성격이 완전히 달라지니까요.

🧱 1단계: Next.js를 컨테이너로 돌릴 준비

Vercel은 Next.js를 알아서 최적화해 올려주지만, Railway는 내가 만든 컨테이너를 실행합니다. 그래서 먼저 빌드 산출물을 컨테이너용으로 바꿔야 합니다.

// next.config.js
module.exports = {
  output: 'standalone'  // 실행에 필요한 파일만 모아 .next/standalone 생성
};

이 한 줄이 핵심입니다. standalonenode_modules 전체 대신 실제로 필요한 의존성만 추려 독립 실행 폴더를 만들어줘서 이미지 용량이 크게 줄어듭니다. 저는 과거에 이 옵션을 지웠던 이력이 있어서 되살리는 것부터 시작했어요.

Dockerfile에서 놓치기 쉬운 부분은 런타임에 읽는 파일을 복사하는 것입니다. 저는 블로그 글을 마크다운 파일로 읽고 있었는데, standalone 산출물에는 이 폴더가 포함되지 않습니다.

COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/posts ./posts   # ← 런타임에 읽는 콘텐츠 폴더, 빼먹으면 404
EXPOSE 8080
CMD ["node", "server.js"]

여기에 .dockerignorenode_modules·.next·.git을 제외하면 빌드가 눈에 띄게 빨라집니다.

🩺 2단계: 헬스체크 엔드포인트 만들기

Railway는 배포한 컨테이너가 정상인지 확인할 주소를 물어봅니다. 없으면 "떴는지 아닌지"를 포트만 보고 판단하게 되니, 간단한 엔드포인트 하나를 만들어두는 게 좋습니다.

// app/api/health/route.ts
export async function GET() {
  return Response.json({ ok: true });
}
// railway.json
{ "deploy": { "healthcheckPath": "/api/health" } }

이러면 새 배포가 헬스체크를 통과하지 못할 때 기존 버전이 유지되므로, 잘못된 배포로 사이트가 죽는 사고를 막아줍니다.

⏰ 3단계: Vercel Cron은 GitHub Actions로 대체

vercel.jsoncronsVercel 전용 기능입니다. 옮기면 그대로 사라지므로 대체가 필요한데, 저는 별도 서비스를 붙이지 않고 GitHub Actions 스케줄을 썼습니다. 코드 저장소에 남으니 관리 지점이 늘지 않아 좋았어요.

# .github/workflows/cron-example.yml
on:
  schedule:
    - cron: '0 15 * * *'   # UTC 기준! KST는 +9시간
jobs:
  ping:
    runs-on: ubuntu-latest
    steps:
      - run: |
          curl -fsS -X POST "https://내도메인.com/api/cron/작업" \
            -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}"

두 가지만 조심하세요. ① cron은 UTC 기준이라 한국 시간으로 새벽 0시면 0 15 * * *입니다. ② 이 엔드포인트는 인증으로 막아야 합니다. 아무나 호출할 수 있는 상태로 두면 크론 작업을 외부에서 마음대로 돌릴 수 있으니, 시크릿 헤더를 검사해 없으면 401을 주도록 했습니다.

🔑 4단계: 환경변수 이사 — Sensitive 변수는 내려받을 수 없습니다

가장 예상 밖의 복병이었습니다. Vercel에서 Sensitive로 등록한 변수는 값을 다시 볼 수도, vercel env pull로 받을 수도 없습니다. 받아보면 값 대신 자리 표시자만 옵니다. 보안상 맞는 동작이지만, 이사할 땐 그 값을 어디서든 다시 구해와야 합니다.

  • 다른 곳에 같은 값이 있으면 거기서 회수 — 저는 앱에서 서버로 서명을 보내는 구조라 앱 코드에 같은 시크릿이 있어 그걸 썼습니다.
  • 원본이 어디에도 없으면 새로 발급 — 저는 크론용 시크릿을 새로 만들어 GitHub Secrets와 Railway 양쪽에 동시 등록했습니다. 호출하는 쪽과 검사하는 쪽이 짝이라 한쪽만 바꾸면 바로 401이 납니다.
  • 정리한 값은 .env.railway 같은 파일로 모아두되 반드시 gitignore에 넣으세요.

✅ 5단계: 도메인 전환 전 스모크 테스트

여기서 서두르면 사고가 납니다. 저는 Railway가 임시로 주는 주소(*.up.railway.app)로 먼저 전부 확인하고, 그다음에 도메인을 옮겼습니다. 제가 쓴 체크리스트는 이렇습니다.

  1. 주요 페이지가 200으로 뜨는지 (특히 런타임에 파일을 읽는 페이지)
  2. 리다이렉트 규칙이 그대로인지 (제 경우 /ko → 루트 308 유지 확인)
  3. sitemap.xml·robots.txt가 정상 생성되는지
  4. DB 연결 — 실제 데이터가 나오는지
  5. 인증이 걸린 API — 잘못된 서명으로 요청해 403이 나고 데이터가 기록되지 않는지(통과되면 그게 더 큰 문제)
  6. 크론 엔드포인트가 시크릿 없이 호출하면 401인지

특히 5번처럼 "막혀야 하는 요청이 제대로 막히는지"를 꼭 넣으세요. 성공 케이스만 확인하면 보안 설정이 이사 중에 빠진 걸 놓칩니다.

🔀 6단계: DNS 전환과 롤백 경로

전환 자체는 순서만 지키면 담백합니다.

  1. Railway에 커스텀 도메인 추가 (컨테이너 포트를 물어보면 8080 등 실제 포트 지정)
  2. Railway가 인증서 발급을 끝냈는지 확인 — 이걸 안 기다리면 전환 직후 SSL 오류가 납니다
  3. DNS(저는 Cloudflare) 레코드를 Railway가 준 대상으로 교체
  4. 전환 확인은 응답 헤더로 — 저는 x-railway-edge 헤더가 보이는지로 판단했습니다
curl -sI https://내도메인.com | grep -i railway

그리고 기존 Vercel 프로젝트는 며칠 남겨두세요. 문제가 생기면 DNS만 되돌리면 되는 롤백 경로가 됩니다. 저는 2~3일 관찰 후 정리하기로 했습니다.

💡 Jay가 실제로 걸린 함정 3가지

① 브라우저는 되는데 curl·스크립트 요청만 403 API 테스트가 계속 403(오류 코드 1010)으로 막혀서 이전 실패인가 싶었는데, 원인은 Cloudflare의 Browser Integrity Check였습니다. 브라우저가 아닌 User-Agent의 요청을 차단하는 설정이라 Vercel 시절부터 있던 동작이었고, 실제 앱 요청은 정상이었어요. 테스트 도구가 막힌 것과 서비스가 망가진 것은 다릅니다. API를 스크립트로 점검할 땐 User-Agent를 지정하세요.

② 배포하면 자동으로 캐시가 비워지지 않습니다 Vercel은 배포 시 자체 CDN 캐시를 정리해주지만, 컨테이너 호스팅은 그런 게 없습니다. 앞단에 CDN을 뒀다면 배포 후 캐시 퍼지를 챙겨야 합니다. (이 함정은 Vercel + Cloudflare 캐시 글에서 자세히 다뤘습니다.)

③ 첫날 DB 타임아웃 1건 전환 직후 서버리스 DB 조회 하나가 연결 타임아웃으로 실패했습니다. 이후 요청은 모두 정상이라 일시적인 네트워크 블립으로 판단하고, 재발 빈도를 관찰 대상으로 남겨뒀습니다. 이전 직후엔 이런 단발 오류가 나오기 마련이라, 바로 코드를 고치기보다 로그를 며칠 모아보고 판단하는 편이 낫습니다.

결론

  • Vercel 무료 플랜의 한도는 방문자보다 크롤·캐시 읽기로 먼저 찰 수 있습니다
  • 컨테이너 이전의 핵심 3종은 output: 'standalone' + 런타임 파일 복사 + 헬스체크
  • Vercel 전용 기능(cron·자동 캐시 퍼지)은 대체 수단을 미리 준비해야 합니다
  • Sensitive 환경변수는 내려받을 수 없다는 점을 이사 계획에 넣어두세요
  • 도메인 전환 전 "막혀야 할 요청이 막히는지"까지 포함한 스모크 테스트, 전환 후 며칠간 롤백 경로 보존

같은 이유로 이전을 고민하고 계셨다면 순서만 잡아두면 생각보다 담백한 작업입니다. 다음에도 유익한 포스팅으로 찾아오겠습니다. 감사합니다!

#Vercel#Railway#Next.js#Docker#배포

$ ls related/

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

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

전체 글 보기