언어: 한국어 · English
앱 안에서 링크를 눌러 이동할 때는 되는데, 그 URL을 직접 열거나 새로고침하면 404가 난다면 먼저 서버가 그 경로를 실제 파일로 찾고 있는지 확인하세요. History API를 쓰는 SPA라면 브라우저 라우터가 시작되기 전에 서버가 같은 index.html을 돌려줘야 해요.
이번 바이브 코딩 클리닉의 질문은 하나예요. “배포된 앱에서 메뉴 이동은 되는데 /notes/42를 새로고침하면 왜 404가 나고, 어디를 가장 작게 고쳐야 할까?”
1. 증상: 홈은 열리고 링크 이동도 되는데 새로고침만 실패해요
홈 주소 /는 정상적으로 열립니다. 앱 안에서 메모 42 링크를 누르면 주소가 /notes/42로 바뀌고 화면도 나타나요. 그런데 그 상태에서 새로고침하거나, 새 탭에 같은 주소를 붙여 넣으면 호스팅 서비스의 404 페이지가 나옵니다.
두 동작은 같은 URL처럼 보여도 요청 경로가 달라요. 앱 안의 이동은 이미 내려받은 JavaScript가 처리할 수 있습니다. 반면 직접 접속과 새로고침은 브라우저가 서버에 GET /notes/42 문서를 새로 요청해요. 서버가 그 경로에 해당하는 파일이나 서버 route를 찾지 못하면, 클라이언트 라우터가 실행되기도 전에 404가 끝납니다.
2. 먼저 구분할 범위: client route인가, server route인가
모바일에서도 따라가는 진단 흐름
- 앱 안의 링크 이동도 실패 → frontend route 정의, base path, build 오류부터 확인
- 링크 이동은 성공하고 새로고침만 404 → hosting/server의 document fallback 확인
- JavaScript·CSS만 404 → asset path와 build output 확인
/api/...만 404 → backend route와 배포 대상 확인- 문서 요청은 200인데 빈 화면 → client router, console 오류, base path 확인
이번 해결법은 두 번째 경우만 다룹니다. 404라는 숫자만 보고 모든 요청을 index.html로 보내면, 빠진 asset이나 잘못된 API까지 정상처럼 숨길 수 있어요.
3. 최소 재현: 클릭은 되고 직접 접속은 실패하는 SPA
빈 폴더에 아래 두 파일을 만드세요. Node.js의 내장 모듈만 사용하며, 데이터와 경로는 모두 합성 예제입니다.
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SPA 새로고침 404 진단</title>
<style>
body { max-width: 42rem; margin: 3rem auto; padding: 0 1rem;
font-family: system-ui, sans-serif; }
nav { display: flex; gap: 1rem; flex-wrap: wrap; }
a { color: #1456b8; }
main { margin-top: 2rem; padding: 1rem;
border: 1px solid #ccd3dd; border-radius: .75rem; }
</style>
</head>
<body>
<nav aria-label="예제 경로">
<a data-link href="/">홈</a>
<a data-link href="/notes/42">메모 42</a>
</nav>
<main id="app" aria-live="polite"></main>
<script>
const routes = {
'/': '<h1>메모</h1><p>합성 메모를 선택하세요.</p>',
'/notes/42': '<h1>메모 42</h1><p>이 경로는 브라우저 앱 안에만 있습니다.</p>'
};
function render() {
document.querySelector('#app').innerHTML =
routes[location.pathname] ?? '<h1>앱 안에서 찾을 수 없음</h1>';
}
document.addEventListener('click', event => {
const link = event.target.closest('a[data-link]');
if (!link || event.button !== 0 || event.metaKey || event.ctrlKey ||
event.shiftKey || event.altKey) return;
const next = new URL(link.href);
if (next.origin !== location.origin) return;
event.preventDefault();
history.pushState({}, '', next.pathname);
render();
});
addEventListener('popstate', render);
render();
</script>
</body>
</html>
server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const html = await readFile(new URL('./index.html', import.meta.url));
const useSpaFallback = process.argv.includes('--spa-fallback');
createServer((request, response) => {
const pathname = (request.url ?? '/').split('?')[0];
const isGetOrHead = request.method === 'GET' || request.method === 'HEAD';
const wantsHtml = request.headers.accept?.includes('text/html');
const isClientRoute =
!pathname.startsWith('/api/') && !pathname.includes('.');
const shouldServeApp =
pathname === '/' ||
pathname === '/index.html' ||
(useSpaFallback && isGetOrHead && wantsHtml && isClientRoute);
if (shouldServeApp) {
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8'
});
response.end(request.method === 'HEAD' ? undefined : html);
return;
}
response.writeHead(404, {
'content-type': 'text/plain; charset=utf-8'
});
response.end('404 Not Found');
}).listen(4173, () => {
console.log(
`localhost:4173 (SPA fallback: ${useSpaFallback ? 'on' : 'off'})`
);
});
먼저 fallback을 끈 상태로 실행합니다.
node server.mjs
- 브라우저에서
localhost:4173을 엽니다. - 메모 42를 누릅니다. 화면이 바뀌고 주소는
/notes/42가 됩니다. - 새로고침합니다. 서버에는 그 파일이나 route가 없으므로
404 Not Found가 나옵니다.
4. 진단 순서: 첫 document 요청부터 보세요
① Network에서 요청 종류를 확인하기
DevTools의 Network를 열고 /notes/42에서 새로고침하세요. 실패한 항목의 Type이 document이고 Status가 404라면, JavaScript가 실행되기 전의 server/hosting 문제일 가능성이 큽니다. 실패 항목이 script나 stylesheet라면 이 글의 fallback보다 asset 경로가 먼저예요.
② soft navigation과 hard navigation을 비교하기
앱 안의 링크 클릭은 현재 문서를 유지한 채 history.pushState()로 주소와 화면을 바꿀 수 있어요. 새로고침·주소 붙여넣기·북마크 열기는 서버에 새 문서를 요청하는 hard navigation입니다. 한쪽만 실패한다는 차이가 진단 증거예요.
③ 배포 결과에 index.html이 있는지 확인하기
build가 성공했다는 메시지만 보지 말고, 실제 배포 output의 root에 앱 shell인 index.html이 있는지 확인하세요. output directory를 잘못 지정했다면 fallback 규칙을 추가해도 보낼 파일이 없습니다.
④ fallback 규칙의 순서를 확인하기
실제 정적 파일과 backend/API route를 먼저 처리하고, 그 어느 것에도 맞지 않는 HTML 문서 요청만 index.html로 보내야 해요. provider 설정에서는 보통 catch-all 규칙을 마지막에 둡니다.
5. 가장 작은 수정: document fallback만 켜기
최소 예제에서는 서버를 종료한 뒤 다음과 같이 다시 실행하세요.
node server.mjs --spa-fallback
이제 /notes/42를 직접 열어도 서버가 HTML 문서 요청에 index.html을 돌려줍니다. 브라우저가 스크립트를 실행한 뒤 location.pathname을 읽고 메모 화면을 그려요.
호스팅 서비스에서는 같은 원리를 그 서비스의 rewrite 문법으로 표현합니다. 현재 공식 문서에서 확인되는 대표 예시는 다음과 같아요.
Netlify _redirects
/* /index.html 200
Vercel vercel.json
{
"rewrites": [
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}
위 Vercel 예시는 공식 문서의 API 제외 형태입니다. 그대로 복사하기 전에 사용하는 host와 framework의 현재 공식 문서를 확인하세요. SSR·filesystem routing·server functions를 함께 쓰거나 없는 asset도 앱 shell로 바뀐다면 catch-all의 대상과 순서를 더 좁혀야 합니다.
6. 정상·실패·재시도를 확인하기
- 정상: 새 탭에서
/notes/42를 직접 열었을 때 document가 200·text/html이고, 메모 42 화면이 나타납니다. - 실패: document가 여전히 404라면 규칙 파일의 위치, 배포 output, 규칙 순서를 확인하세요.
- 잘못된 성공:
/missing.js나/api/notes/42까지 200 HTML을 받는다면 fallback 범위가 너무 넓습니다. - 재시도: 설정을 고친 뒤 기존 탭의 client 이동만 보지 말고, 새 탭 직접 접속과 새로고침을 다시 확인하세요.
최소 예제에서는 다음 명령으로 경계를 확인할 수 있어요.
curl -i -H 'Accept: text/html' localhost:4173/notes/42
curl -i localhost:4173/missing.js
curl -i -H 'Accept: text/html' localhost:4173/api/notes/42
첫 번째는 200 HTML이어야 하고, 뒤의 두 요청은 404를 유지해야 합니다. 유효한 client route만 복구하고 진짜 오류는 그대로 드러내는 것이 성공 기준이에요.
7. 되돌리기
로컬 예제는 서버를 종료하고 --spa-fallback 없이 다시 실행하면 원래 상태로 돌아갑니다. 배포 설정은 추가한 rewrite 한 줄이나 rewrites 항목만 제거한 뒤 이전 배포로 되돌리세요. 되돌린 뒤에는 /notes/42가 다시 404가 되는지 확인해야 rollback이 실제로 적용됐다고 볼 수 있습니다.
8. AI에게 줄 좋은 지시문
배포된 앱에서 홈은 열리고 앱 안의 /notes/42 링크도 작동하지만,
그 URL을 직접 열거나 새로고침하면 hosting 404가 나온다.
프레임워크를 바꾸거나 route를 다시 만들기 전에 다음을 확인해줘.
1. 실패한 Network 항목이 document, script, API 중 무엇인지 구분한다.
2. client-side navigation과 새 document 요청의 차이를 설명한다.
3. 실제 build output의 index.html 위치와 배포 output directory를 확인한다.
4. 사용 중인 host의 공식 문서에 맞는 가장 작은 SPA fallback만 제안한다.
5. 정적 파일과 /api 경로는 catch-all보다 먼저 처리하고,
missing.js와 잘못된 API가 200 HTML로 숨지 않게 한다.
6. 성공, 실패, 직접 접속, 새로고침, 진짜 404의 확인 절차와
정확한 rollback 방법을 마지막에 적는다.
이 지시문은 “404를 고쳐줘”가 아니라 실패한 요청의 종류, 수정 가능한 범위, 보존해야 할 404, 성공 증거를 함께 지정합니다. AI가 client route를 중복 추가하거나 모든 경로를 무조건 200으로 만드는 과잉 수정을 줄일 수 있어요.
9. 흔한 오진
- “라우터에
/notes/42를 다시 추가하면 된다”: 앱 안의 이동이 이미 된다면 client route는 존재할 가능성이 큽니다. 서버가 앱 shell을 보내기 전에 실패하는지 먼저 보세요. - “build가 성공했으니 배포 설정도 맞다”: build output과 host가 실제로 제공하는 directory는 다를 수 있습니다.
- “모든 404를
index.html로 바꾸면 끝이다”: 빠진 JavaScript와 API 오류까지 HTML 200으로 숨으면 디버깅이 더 어려워집니다. - “HTTP 200이면 페이지도 성공했다”: document가 200이어도 잘못된 HTML, client 오류, 없는 app route 때문에 빈 화면이나 앱 내부 404가 나올 수 있어요.
- “HashRouter로 바꾸는 것이 유일한 해결책이다”: hash route는 서버에 fragment가 전달되지 않는 우회 경로지만 URL 구조가 바뀝니다. host rewrite를 고칠 수 있을 때의 기본 답은 아니에요.
10. 이 해결법을 쓰면 안 되는 때
서버가 각 URL을 직접 렌더링하는 SSR 앱, 파일별 HTML을 제공하는 multi-page app, backend/API route, 정적 asset의 실제 404에는 SPA fallback을 기본 처방으로 쓰지 마세요. 검색 엔진이나 공유 미리보기에 경로별 완성 HTML이 필요하다면 pre-rendering이나 SSR이 더 맞을 수 있습니다. “새로고침 404”라는 증상만 같아도 누가 그 URL의 응답을 소유해야 하는지는 다를 수 있어요.
일요일 Fundamentals와 연결해 읽기
이번 Clinic은 「프론트엔드·백엔드·DB 차이: 화면 데이터는 어디에 저장될까? — 바이브 코딩 2강」의 경계를 URL 요청에 적용합니다. 화면 안의 route를 아는 frontend와, 새 document 요청에 응답해야 하는 server/hosting은 같은 일을 하지 않아요.