Vercel에 리액트 앱을 손쉽게 배포해봤던 나로서는, GitHub Pages로도 금방 될 거라고 생각했다.
근데 웬걸, 생각보다 훨씬 많은 삽질이 기다리고 있었다.
사실 GitHub Pages랑 Vercel은 구조부터가 다르다.
Vercel은 SSR이든 CSR이든 알아서 잘 처리해주는 플랫폼이고, GitHub Pages는 그냥 정적인 파일만 서빙해주는 서비스다.
SPA(Single Page Application)
은 기본적으로 하나의 HTML(index.html
)을 기반으로, 나머지 페이지는 JavaScript가 알아서 만들어주는 구조다.
그래서 /profile
같은 경로에 접근해도 실제로는 profile.html
이 따로 있는 게 아니라, index.html 하나가 모든 걸 담당한다.
그런데 GitHub Pages는 말 그대로 정적 파일만 서빙하는 서버다.
/profile
로 들어오면 진짜로 profile.html
파일이 있는지 찾고, 없으면 404
페이지를 보여준다.
그래서 생기는 문제가 바로 이거다:
❗️ 메인 페이지는 잘 뜨는데, 새로고침하거나 직접 경로를 입력하면 404가 뜨는 현상
이걸 해결하려면 방법은 크게 두 가지가 있다.
React-router
에서 createBrowserRouter
대신 createHashRouter
를 쓰면 된다.
그러면 URL이 /profile
이 아니라 /#/profile
이런 형태가 되는데, 이게 핵심이다.
브라우저는 #
뒤에 있는 정보는 서버에 요청하지 않고, 클라이언트가 직접 해석하게 되어 있다.
즉, GitHub Pages는 그냥 /index.html
을 주고, 브라우저는 /#/profile
을 보고 내부에서 알아서 페이지를 전환하는 것이다.
그래서 서버 입장에서는 항상 동일한 index.html
만 주면 되고, 우리가 경험한 에러가 발생하지 않는다.
다만, URL이 다소 직관적이지 않을 수 있다는 단점이 있지만, 정적 서버 환경에서는 안정적으로 동작한다.
하지만 나는 단순히 hashRouter
로 해결할 수 없었다.
이번 프로젝트에서는 직접 구현한 History API
기반의 커스텀 라우터를 사용했고, 요구사항에서도 이 방식이 제시되었기 때문이다.
그래서 선택한 방법이 바로 GitHub Pages의 404.html 우회 방식이다.
GitHub Pages는 존재하지 않는 경로로 접근하면 자동으로 404.html
을 반환한다.
이를 활용해 현재 경로를 쿼리 파라미터로 변환해 index.html
로 리다이렉트하고, 클라이언트에서 이를 다시 해석해 원래 요청한 경로를 렌더링하는 방식이다.
예를 들어 사용자가 /profile
로 접근하면:
404.html
을 반환한다.404.html
에 작성된 스크립트가 현재 경로(/profile
)를 ?p=/profile
로 변환하여 index.html
로 이동시킨다.index.html
에서는 window.history.replaceState()
를 통해 주소를 다시 /profile
로 복원한다.이 방식은 rafgraph/spa-github-pages라는 오픈소스를 참고하여 구현했다.
우선 404.html
파일을 만들고, <script>
에 다음과 같은 코드를 작성해준다.
<!-- 404.html -->
<script>
var segmentCount = 1;
var l = window.location;
l.replace(
l.protocol + "//" + l.hostname +
(l.port ? ":" + l.port : "") +
l.pathname.split("/").slice(0, 1 + segmentCount).join("/") +
"/?p=/" + l.pathname.slice(1).split("/").slice(segmentCount).join("/").replace(/&/g, "~and~") +
(l.search ? "&q=" + l.search.slice(1).replace(/&/g, "~and~") : "") +
l.hash
);
</script>
그리고 index.html
쪽에는 이걸 복원하는 코드가 들어간다.
<script>
(function (l) {
if (l.search) {
var q = {};
l.search.slice(1).split("&").forEach(function (v) {
var a = v.split("=");
q[a[0]] = a.slice(1).join("=").replace(/~and~/g, "&");
});
if (q.p !== undefined) {
window.history.replaceState(null, null, q.p + (q.q ? "?" + q.q : "") + l.hash);
}
}
})(window.location);
</script>
이렇게 구성된 두 스크립트는 GitHub Pages에서 직접 경로 접근 시 발생하는 404 문제를 우회하고, 사용자가 입력한 경로를 SPA 라우터가 정확히 인식하도록 도와준다.
나는 빌드 도구로 Vite를 사용했는데, 여기서도 몇 가지 설정을 추가해줘야 했다.
먼저, 404.html도 빌드 결과물에 포함되도록 아래와 같이 진입점을 명시했다.
// vite.config.js
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
base: "/my-spa/", // GitHub Pages 배포 경로에 맞춰 설정
build: {
outDir: "dist",
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
notFound: resolve(__dirname, "404.html"),
},
},
},
});
또한 base
옵션도 꼭 설정해줘야 한다.
이걸 안 하면 빌드된 JS 파일들을 잘못된 경로에서 불러오게 되는데, 그러면 페이지가 아예 흰 화면으로 나와버린다. (몇 시간동안 삽질을 했는지…)
이 두 가지 설정만 해주면 GitHub Pages에서도 404.html
리다이렉트 방식이 정상 작동하고,
SPA 앱도 깔끔하게 돌아간다.
GitHub Pages는 정적 파일만 서빙하기 때문에 브라우저 라우팅을 사용하는 SPA 구조와 충돌이 발생한다.
하지만 그렇다고 모든 배포 환경에서 이런 문제가 생기는 것은 아니다.
우리가 자주 사용하는 nginx나 Vercel처럼, SPA를 다르게 처리해주는 환경들도 있다.
이들은 GitHub Pages와는 다른 방식으로 요청을 처리하고, 그 덕분에 별다른 우회 없이도 SPA가 자연스럽게 동작한다.
이제 이 두 환경이 SPA 요청을 어떻게 처리하는지 살펴보자.
nginx도 GitHub Pages처럼 정적 파일을 서빙하는 서버다.
기본 설정 상태에서는 /profile
과 같은 경로로 접근했을 때, 실제 profile.html
파일이 존재하지 않으면 404를 반환한다.
즉, 기본 동작은 GitHub Pages와 동일하다.
하지만 nginx는 설정을 커스터마이징할 수 있기 때문에, 아래처럼 try_files
를 통해 SPA 라우팅에 맞게 대응할 수 있다.
location / {
try_files $uri $uri/ /index.html;
}
이 설정은 다음과 같이 동작한다:
즉, 어떤 경로로 들어와도 항상 index.html을 반환하게 되고, SPA 라우터는 이를 받아 클라이언트 사이드에서 경로를 분석하고 페이지를 렌더링한다.
GitHub Pages처럼 별도의 우회 로직 없이도, 클라이언트 라우팅이 자연스럽게 작동할 수 있도록 서버에서 미리 처리해주는 셈이다.
nginx가 설정을 통해 모든 경로 요청을 index.html
로 전달해주는 방식이라면,
Vercel은 이 과정을 사용자 설정 없이 자동으로 처리해준다는 점에서 차이가 있다.
Vercel은 SPA를 위한 정적 파일 배포 환경에 매우 친화적이다.
사용자가 /profile
같은 경로로 접근했을 때, 해당 경로에 파일이 없어도 서버가 알아서 index.html
을 반환하도록 내부적으로 rewrite
규칙이 적용되어 있다.
// Vercel 내부에서 적용되는 rewrite 설정 (사용자 설정 불필요)
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
이 덕분에 개발자는 별도로 404.html을 만들어 우회하거나, hash router를 적용하지 않아도 된다.
라우팅 경로가 존재하지 않더라도 항상 index.html이 응답되고, 클라이언트에서 JavaScript가 이를 받아 원하는 페이지로 라우팅을 수행한다.
또한 Vercel은 프로젝트에 사용된 프레임워크(React, Vue, Svelte 등)를 자동으로 감지하고, 이에 맞는 최적화된 빌드 및 배포 설정을 적용해준다.
덕분에 설정이 복잡하지 않고, 별다른 커스터마이징 없이도 대부분의 SPA 프로젝트가 잘 작동한다.
📚 참고 문서:
사실 처음엔 그저 간단한 정적 사이트 배포쯤으로 생각하고 시작했지만, 막상 직접 부딪히고 보니 생각보다 훨씬 많은 것을 배울 수 있는 경험이었다.
SPA와 정적 파일 서버의 관계, 브라우저 라우팅이 어떻게 작동하는지, 그리고 플랫폼마다 이를 어떻게 다르게 처리하는지까지.
리액트 라우터가 알아서 다 해주던 걸 직접 구현하고, 직접 디버깅하며 느낀 건 "브라우저는 정말 아무것도 모르고, 서버는 시키는 일만 한다"는 단순하지만 중요한 원리였다.
물론 Vercel처럼 모든 걸 자동으로 해주는 멋진 도구도 있지만, 가끔은 이렇게 돌아가는 구조를 뜯어보고, 그 사이를 내가 직접 메꿔야 할 때가 있다.
그리고 그때 우리는 진짜로 성장하게 된다.
이번 작업은 그런 의미에서, 꽤나 값진 '삽질'이었다.