배포 후 링크는 되는데 새로고침하면 404일 때 — 바이브 코딩 클리닉 2

언어: 한국어 · 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인가

모바일에서도 따라가는 진단 흐름

  1. 앱 안의 링크 이동도 실패 → frontend route 정의, base path, build 오류부터 확인
  2. 링크 이동은 성공하고 새로고침만 404 → hosting/server의 document fallback 확인
  3. JavaScript·CSS만 404 → asset path와 build output 확인
  4. /api/...만 404 → backend route와 배포 대상 확인
  5. 문서 요청은 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
  1. 브라우저에서 localhost:4173을 엽니다.
  2. 메모 42를 누릅니다. 화면이 바뀌고 주소는 /notes/42가 됩니다.
  3. 새로고침합니다. 서버에는 그 파일이나 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. 흔한 오진

  1. “라우터에 /notes/42를 다시 추가하면 된다”: 앱 안의 이동이 이미 된다면 client route는 존재할 가능성이 큽니다. 서버가 앱 shell을 보내기 전에 실패하는지 먼저 보세요.
  2. “build가 성공했으니 배포 설정도 맞다”: build output과 host가 실제로 제공하는 directory는 다를 수 있습니다.
  3. “모든 404를 index.html로 바꾸면 끝이다”: 빠진 JavaScript와 API 오류까지 HTML 200으로 숨으면 디버깅이 더 어려워집니다.
  4. “HTTP 200이면 페이지도 성공했다”: document가 200이어도 잘못된 HTML, client 오류, 없는 app route 때문에 빈 화면이나 앱 내부 404가 나올 수 있어요.
  5. “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은 같은 일을 하지 않아요.

공식 문서


다른 글 보기 · 주제 탐색 · 작성자와 편집 기준 · 문의·정정 요청

RUDA DIRECTOR에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기