Vercel 배포가 Ready인데 404가 나오면 Next.js 라우트와 rewrite를 어떻게 확인하나요?
Vercel 배포가 Ready인데 특정 URL이 404라면 빌드 실패와 구분해 라우트를 확인하세요. 요청 경로에 대응하는 Next.js 페이지가 있는지 보고 rewrite 목적지·redirect·정적 자산 경로를 차례로 비교합니다.
적용 조건·범위: next.config.ts의 /legacy/tasks destination을 /task-list로 한 번 바꾸고 개발 서버를 다시 시작해 404를 재현합니다. 다시 /tasks로 복원하고 같은 URL에서 콘텐츠가 돌아오는지 확인합니다. 자가 확인: 주소까지 /tasks로 이동시키고 싶다면 rewrite 대신 redirect라는 다른 요구입니다. 로컬 typecheck·build와 위 세 경로 HTTP 응답은 확인했으며 실제 Vercel 배포는 이 검증에 포함되지 않습니다.
본문 근거: vercel.com/docs/errors/NOT_FOUND · nextjs.org/docs/app/api-reference/config/next-config-js/rewrites · nextjs.org/docs/app/building-your-application/routing
증상
Production deployment = Ready
/ = 정상
/tasks = 정상
/legacy/tasks = 404
또는 rewrite를 추가했는데 목적지 페이지가 존재하지 않아 예상과 다른 결과가 나올 수 있습니다.
환경
- Next.js App Router
- Vercel
- next.config.ts
목차
- 404를 세 종류로 나누기
- App Router와 URL 매핑
- rewrite와 redirect 차이
- 최소 재현
- 수정 전
- 수정 후
- 실습 프로젝트 전체 코드
- build와 route를 다시 확인합니다
- 정적 자산 404는 별도 문제입니다
- 도메인 연결 문제와 앱 404 구분
- 무조건 SPA fallback을 넣지 않습니다
- 학습 완료 체크리스트
- TaskFlow 프로젝트 적용
- 실습 경로·결과·복구
- 다음 학습
- 공식 문서
- 단독 실습과 결과 확인
404를 세 종류로 나누기
같은 404처럼 보여도 확인 지점이 다릅니다.
1. route 자체가 없음
2. rewrite 목적지가 없음
3. 정적 자산 경로가 잘못됨
도메인이 배포 프로젝트 자체를 잘못 가리키는 경우도 별도로 구분해야 합니다.
App Router와 URL 매핑
App Router에서는 폴더 구조가 route와 연결됩니다.
app/
├─ page.tsx
└─ tasks/
├─ page.tsx
└─ [id]/
└─ page.tsx
URL은 다음처럼 매핑됩니다.
app/page.tsx
→ /
app/tasks/page.tsx
→ /tasks
app/tasks/[id]/page.tsx
→ /tasks/123
요청한 URL에 대응하는 page.tsx가 없으면 먼저 route 설계를 확인합니다.
rewrite와 redirect 차이
rewrite는 브라우저 주소를 유지하면서 내부적으로 다른 목적지의 콘텐츠를 사용합니다.
redirect는 클라이언트에게 다른 URL로 이동하라고 응답합니다.
rewrite
/old
→ 내부적으로 /new 사용
브라우저 주소는 /old 유지 가능
redirect
/old
→ 브라우저가 /new로 이동
목적이 다른 기능이므로 이전 URL 유지가 필요한지, 주소 자체를 바꿀지 먼저 결정합니다.
최소 재현
현재 앱에는 /tasks가 존재한다고 가정합니다.
app/
└─ tasks/
└─ page.tsx
그런데 next.config.ts에 다음처럼 잘못된 rewrite가 있습니다.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/legacy/tasks",
destination: "/task-list",
},
];
},
};
export default nextConfig;
/task-list route가 없다면 rewrite가 의도한 결과를 만들 수 없습니다.
수정 전
source
/legacy/tasks
destination
/task-list
실제 route
/tasks
수정 후
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/legacy/tasks",
destination: "/tasks",
},
];
},
};
export default nextConfig;
이제 source와 실제 목적지가 연결됩니다.
build와 route를 다시 확인합니다
npm run build
배포 후 다음 URL을 확인합니다.
/tasks
/legacy/tasks
둘 다 예상한 화면을 보여야 합니다.
정적 자산 404는 별도 문제입니다
페이지는 열리는데 이미지나 JavaScript chunk만 404일 수 있습니다.
이 경우 확인할 대상은 page route와 다릅니다.
public 경로
Image src
basePath
assetPrefix
CDN 설정
배포된 파일
예를 들어 public/logo.svg 파일은 Next.js에서 보통 /logo.svg로 접근합니다.
실제 프로젝트 설정에 basePath나 별도 asset prefix가 있다면 그 기준을 적용합니다.
도메인 연결 문제와 앱 404 구분
다음처럼 확인합니다.
Vercel 기본 Production URL
→ 정상
커스텀 도메인
→ 404
이 경우 route 코드가 아니라 도메인이 올바른 프로젝트와 deployment를 가리키는지 먼저 확인할 필요가 있습니다.
반대로 두 도메인에서 같은 특정 route만 404라면 애플리케이션 라우팅 문제일 가능성을 우선 확인합니다.
무조건 SPA fallback을 넣지 않습니다
Vite SPA에서 모든 경로를 index.html로 보내는 rewrite는 흔한 설정입니다.
하지만 Next.js App Router는 서버 라우팅과 프레임워크 규칙을 사용합니다.
404를 없애겠다는 이유로 모든 요청을 하나의 정적 페이지로 보내면 정상적인 Next.js route 동작을 깨뜨릴 수 있습니다.
이 주제만 따로 연습하기
이 글의 독립 라우팅 실습 ZIP은 /tasks와 /legacy/tasks를 확인할 최소 Next.js 앱입니다. 이전 TaskFlow 전체 앱이나 Vercel 계정은 필요하지 않습니다. Node.js 24.x에서 ZIP README의 npm ci → npm run typecheck → npm run build를 실행하고 npm run dev로 엽니다.
| 입력 URL | 기대 결과 |
|---|---|
| /tasks | 작업 페이지 200 |
| /legacy/tasks | 주소는 유지하고 /tasks 콘텐츠 200 |
| /task-list | 존재하지 않는 목적지 404 |
next.config.ts의 /legacy/tasks destination을 /task-list로 한 번 바꾸고 개발 서버를 다시 시작해 404를 재현합니다. 다시 /tasks로 복원하고 같은 URL에서 콘텐츠가 돌아오는지 확인합니다. 자가 확인: 주소까지 /tasks로 이동시키고 싶다면 rewrite 대신 redirect라는 다른 요구입니다. 로컬 typecheck·build와 위 세 경로 HTTP 응답은 확인했으며 실제 Vercel 배포는 이 검증에 포함되지 않습니다.
학습 완료 체크리스트
- route 부재와 rewrite 오류를 구분할 수 있습니다.
- App Router 파일 구조를 URL과 연결할 수 있습니다.
- rewrite와 redirect 차이를 설명할 수 있습니다.
- 페이지 404와 정적 자산 404를 구분할 수 있습니다.
- DNS 연결 문제와 앱 route 문제를 분리할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 관리하는 예제 앱입니다. 이 선택 실습에서는 앱을 배포하고 서버·설정·운영 결과를 확인합니다.
이번 주제: Vercel 404 NOT_FOUND 오류 해결: Next.js 배포 후 라우팅과 rewrites 확인.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
이 글부터 시작하는 전체 프로젝트
시작 ZIP — deploy-step-07-vercel-build-debug-full-20260928을 새 폴더에 풀고, 이번 단계 완료 ZIP은 다른 폴더에 풀어 비교합니다. ZIP 안 프로젝트 폴더의 package.json 위치가 실행 루트입니다.
출발점은 Next.js 누적 TaskFlow와 같은 38단계 소스입니다. 프로젝트에는 홈·로그인·개인 작업 화면, 작업 추가·완료·삭제, Route Handler와 캐시 예제가 들어 있습니다. 설치 버전을 고정한 package-lock.json도 포함합니다.
이번 변경 이유: 기존 TaskFlow 라우트와 인증을 유지하면서 /legacy/tasks를 /tasks로 연결합니다.
next.config.tsproxy.ts
- 시작 ZIP을 풀고
npm ci로 의존성을 설치합니다. Node.js 24와 npm을 사용합니다. - 위 변경 파일을 아래 전체 내용 또는 완료 ZIP의 같은 경로와 대조해 적용합니다. 다른 앱 파일은 유지합니다.
npm run typecheck,npm run build를 실행합니다. 오류가 있으면 첫 오류의 파일·명령을 기록하고 해결한 뒤 진행합니다.npm run setup-auth로 일회용 로컬 학습 계정을 설정한 후npm run dev를 실행합니다. 생성된.env.local과 입력 값은 공유하지 않습니다.
예상 결과: 홈과 로그인 화면은 공개됩니다. 로그인 전 /tasks는 로그인 화면으로 이동하고 /api/tasks는 401을 반환합니다. 로그인 후 작업 목록에서 추가·완료·삭제를 확인합니다. /cache-lab의 별도 캐시 실습은 npm run cache-api가 필요합니다.
/legacy/tasks도 기존 인증을 유지합니다. 로그인하지 않은 상태의 로그인 이동은 404 오류가 아닙니다. next.config.ts의 rewrite와 proxy.ts의 보호 경로를 함께 확인하고, 로그인 후 두 주소에서 작업 화면을 대조합니다.
src/lib/env.ts와 src/lib/api.ts(9단계부터)는 환경 차이를 관찰하는 별도 진단 예제입니다. 기본 TaskFlow는 같은 origin의 인증된 /api/tasks를 사용하므로 이 예제로 기존 데이터 조회나 인증을 교체하지 않습니다. 공개 API origin 변수도 해당 진단 예제를 직접 사용할 때만 설정합니다.
저장·운영 범위: 작업은 단일 Node 프로세스 메모리에 저장되고 인증은 학습용 계정 예제입니다. 서버 재시작·서버리스 인스턴스 사이의 데이터 유지는 보장하지 않습니다. 로컬 실행 확인과 실제 운영 서비스 구축은 구분하며, 원격 배포·DNS·SSH·CI·롤백 결과는 직접 실행한 항목만 문서에 기록합니다.
전체 파일·코드 보기
이번 단계의 변경 파일 전체
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
async redirects() {
return [{ source: "/todo", destination: "/tasks", permanent: true }];
},
async rewrites() {
return [{ source: "/legacy/tasks", destination: "/tasks" }];
},
cacheComponents: false,
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com", pathname: "/**" },
],
},
};
export default config;
proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export const proxy = auth((request) => {
if (!request.auth?.user)
return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
});
export const config = { matcher: ["/tasks/:path*", "/settings/:path*", "/legacy/tasks"] };
나머지 앱 소스와 잠금 파일은 전체 완료 ZIP에 있습니다. 기존 코드 조각은 설명용이며 위 전체 변경 파일과 ZIP이 이 누적 실습의 기준입니다. 다음 글에는 이번 완료 ZIP을 시작본으로 가져갑니다.
이 회차는 아래 전체 TaskFlow 시작 ZIP을 기준으로 진행합니다. 이전 글의 코드를 모으지 않아도 되며, 완료 ZIP에는 이번 변경과 기존 앱 기능이 함께 들어 있습니다.
실습 프로젝트 전체 코드
아래는 8편의 rewrite 문제를 재현하는 최소 Next.js 앱과 누적 운영 자료 전체입니다. 기존 TaskFlow 전체 기능이나 실제 배포 결과가 아닙니다. 의존성 잠금 파일 package-lock.json은 ZIP에 포함하며 본문에서는 이 자동 생성 파일만 생략합니다.
.env.example
# 기존 앱에서 실제 사용하는 변수만 설정하세요. 값은 개발/운영 플랫폼에서 별도 관리합니다.
NEXT_PUBLIC_API_URL=
API_SECRET=
.github/workflows/ci.yml
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "24.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
.gitignore
node_modules/
.next/
.env.local
*.tsbuildinfo
.nvmrc
24
app/layout.tsx
import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return <html lang="ko"><body>{children}</body></html>;
}
app/page.tsx
import Link from "next/link";
export default function Home() {
return <main><h1>TaskFlow 배포 진단 실습</h1><Link href="/tasks">작업 목록</Link></main>;
}
app/tasks/page.tsx
export default function TasksPage() {
return (
<main>
<h1>Tasks</h1>
<p>TaskFlow 작업 목록입니다.</p>
</main>
);
}
docs/deployment-plan.md
# TaskFlow Deployment Plan
## Build
- package manager: npm
- install: npm ci
- build: npm run build
## Primary deployment
- platform: Vercel
- environment: Production
## Alternative deployment
- Firebase Hosting: static output일 때 검토
- Firebase App Hosting: server runtime이 필요할 때 검토
## Verification
- Production URL 응답
- 주요 화면 확인
- 환경변수 확인
- 주요 route 확인
- 현재 commit 확인
- rollback 가능 여부 확인
## Runtime decision
- static-only: no
- server runtime required: yes
- reasons:
- Route Handler
- server-side environment variables
## Primary
Vercel Production
## Firebase alternative
App Hosting 우선 검토
## Static variant
정적 결과물로 분리할 경우 Firebase Hosting 검토
## Production deployment
- deployment URL: 운영 문서에서 관리
- commit SHA: 배포마다 기록
- deployment status: 확인
- build log: 확인
- smoke test: 확인
## Domain
- canonical domain: 운영 문서에서 관리
- www policy: canonical로 redirect
- HTTPS: 확인
- DNS status: 확인
- auth callback / allowed origin: 확인
docs/server-access-runbook.md
# Server Access Runbook
## Connection
- host: 저장하지 않음
- user: 환경별 문서에서 별도 관리
- authentication: SSH key 우선
- private key: 저장소에 포함 금지
## Before upload
1. 대상 서버 확인
2. 현재 경로 확인
3. 배포 대상 경로 확인
4. 기존 파일 상태 확인
## After upload
1. 파일 존재 확인
2. owner / permission 확인
3. 서비스가 실제 경로를 읽는지 확인
4. HTTP 결과 확인
## Never commit
- private key
- password
- access token
- production secret
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/legacy/tasks",
destination: "/tasks",
},
];
},
};
export default nextConfig;
package.json
{
"name": "taskflow-deployment-lab",
"private": true,
"engines": {
"node": "24.x"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.10.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
}
README.md
# 배포·서버 08 — deploy-step-08-routing-debug
이 ZIP은 8편의 /legacy/tasks → /tasks rewrite를 재현하는 최소 Next.js 앱과 이전 회차 운영 문서를 포함합니다. 기존 TaskFlow 전체 기능, 로그인, 실제 API 서버나 Production 배포 상태를 제공하지 않습니다.
Node.js 24.x, npm을 사용합니다. `npm ci` → `npm run typecheck` → `npm run build`로 확인하고 `npm run dev` 후 `/tasks`, `/legacy/tasks`를 비교합니다. 전체 의존성 잠금 파일은 package-lock.json에 있습니다.
.env.example은 값이 빈 설정 안내입니다. 독자의 앱에서 사용하는 변수만 .env.local 또는 플랫폼의 해당 환경에 설정하세요. URL·계정·비밀키를 ZIP에 넣지 않습니다.
CI workflow는 이미 package.json/package-lock.json과 build 스크립트가 있는 기존 npm 프로젝트 루트에 적용합니다. actions/checkout@v7, actions/setup-node@v7, Node 24.x를 사용합니다. GitHub 원격 실행은 검증하지 않았습니다.
src/lib/env.ts는 해당 모듈을 import할 때 환경 변수를 검사합니다. 파일을 추가하기만 해서는 사용하지 않는 앱 전체의 환경 설정이 자동 검증되지 않습니다.
이 자료에는 실제 배포·DNS 변경·SSH 접속·메일/알림·롤백 결과가 없습니다. 운영 작업은 독자가 본인 환경과 권한을 확인한 후 수행해야 합니다. 문서의 확인 항목은 수행할 체크리스트이며 이미 통과했다는 기록이 아닙니다.
src/lib/env.ts
function required(name: string, value: string | undefined) {
if (!value) {
throw new Error(`Missing environment variable: ${name}`);
}
return value;
}
export const env = {
apiUrl: required(
"NEXT_PUBLIC_API_URL",
process.env.NEXT_PUBLIC_API_URL,
),
};
tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
이전 체크포인트
deploy-step-07-vercel-build-debug
수정 전
/legacy/tasks
→ /task-list
→ 실제 route 없음
수정 후
/legacy/tasks
→ /tasks
→ 실제 route 존재
검증
npm run build
Production 배포 후 다음 smoke test를 수행합니다.
GET /
GET /tasks
GET /legacy/tasks
실제 HTTP 상태와 화면 결과를 확인합니다.
완료 체크포인트
deploy-step-08-routing-debug
실습 경로·결과·복구
독자 질문
같은 404라도 라우트 부재와 rewrite 오류를 어떻게 구별할까요?
배포 전제
성공한 deployment URL, 실패 URL의 정확한 경로·응답 상태, App Router 파일 구조와 next.config.ts가 필요합니다. DNS 자체가 실패한 경우는 5회차의 도메인 점검부터 진행합니다.
실행 단계
- 배포 고유 URL의 홈과 실패 경로를 각각 열고 Network에서 문서 요청인지 _next 정적 자산 요청인지 기록합니다.
- app/tasks/page.tsx가 /tasks를 제공하는지 확인하고, rewrite가 있다면 source와 destination 양쪽 경로의 실제 존재 여부를 대조합니다. redirect는 브라우저 주소가 바뀌는 별도 동작입니다.
- 필요한 파일이나 rewrite 목적지만 수정해 재배포하고 배포 URL과 대표 도메인에서 홈·/tasks·정적 자산을 각각 다시 확인합니다.
성공·실패 확인
라우트가 없으면 해당 page를 추가해야 하고, rewrite 대상이 없으면 destination을 수정해야 합니다. 성공 기준은 의도한 URL에서 예상하는 200 또는 설계된 리다이렉트와 실제 화면입니다. 새 배포 없이 소스만 고쳐서는 운영 URL 결과가 바뀌지 않습니다.
복구와 검증 상태
수정으로 다른 URL이 깨지면 변경한 rewrite만 되돌리고 이전 정상 deployment의 경로를 비교합니다. 운영 도메인에 영향이 크면 검증된 이전 배포로 복귀한 뒤 smoke test를 반복합니다. 실제 Vercel 404 재현·복구는 미확인입니다.
다음 학습
다음 글에서는 코드와 route가 정상인데 로컬, Preview, Production에서 결과가 달라지는 경우를 환경변수, 프록시, 캐시, 응답 헤더 관점에서 확인합니다.
다음 글: 배포 환경 차이 해결: 프록시·캐시·환경변수·헤더 점검
다음 회차 확인 과제: 09회차에서는 정상 라우트가 환경마다 다르게 동작할 때 요청 URL·환경변수 범위·응답 헤더·배포 버전을 비교합니다.
공식 문서
공식 문서 확인 기준: 2026-09-21
체크포인트 다운로드
글 소개·학습 준비·전체 목차

배포·서버 · 8 / 11 · 오류 해결 · 404와 rewrites
Vercel 배포가 성공했는데 특정 URL에서 404 NOT_FOUND가 발생한다면 build 실패와는 다른 문제입니다.
가장 먼저 확인할 것은 요청한 URL에 대응하는 Next.js route가 실제로 존재하는지입니다. 그다음 rewrite, redirect, base path, 정적 자산 경로를 확인합니다.
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 11개 · 필수 7개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.