Github Page는 Github의 저장소 기능을 사용하여 정적 페이지를 호스팅할 수 있는 서비스입니다. 이를 활용하면 블로그와 같은 웹 페이지를 손쉽게 배포할 수 있습니다. 그런데 일반적으로 블로그는 여러 경로로 이루어져 있습니다. 처음 페이지에 진입하면 만날 수 있는 랜딩 페이지, 블로그 주인에 대한 소개 페이지, 여러 블로그의 목록 페이지 등을 떠올릴 수 있겠습니다. 이는 블로그가 아니더라도, 대부분의 웹 사이트들도 마찬가지입니다.
그런데 라우팅이 적용된 React SPA(Single Page Application)을 Github Page에 배포하려면 몇 가지 해결해야 할 문제들이 있습니다. 이 글에서는 이 문제가 무엇인지 알아보고, 원인을 알아본 뒤 각각의 해결 방안을 알아보겠습니다.
Single Page Application이란?
단일 페이지 어플리케이션이란, 겉으로 보기에는 여러 페이지들로 이루어진 웹 앱이지만, 실제 페이지 이동시 서버로부터 새 페이지를 전달받는 것이 아니라, 현재 클라이언트 측의 HTML과 JS 코드를 사용하여 현재 페이지의 내용을 변경하여 출력하는 방식의 어플리케이션입니다. 즉, 실제로 존재하는 페이지는 단 하나(
index.html
)이며, 각 논리적인 페이지마다 필요한 데이터는 API 등을 사용하여 불러오게 됩니다.따라서 페이지 간 전환이 빠르고 통신 비용이 적다는 장점이 있지만, 앱의 모든 데이터를 초기에 확보해두어야 하므로 초기 로딩 속도가 증가할 수 있다는 단점이 있습니다. 이러한 단점은 코드 스플리팅 등의 기법으로 어느 정도 완화할 수 있습니다.
이 글은 다음과 같은 분들께 도움을 드릴 수 있습니다:
<USERNAME>.github.io/
가 아닌 별도의 저장소를 사용하고자 할 때이 글은 Github Page와 React Router의 기본적인 사용법을 알고 계시는 것을 전제로 하고 있습니다.
create-react-app
을 사용하고 계신가요?만약 그렇다면, npm
에서 react-gh-pages
모듈을 다운받아 사용하시면 간단하게 문제가 해결됩니다. 이 글에서는 create-react-app
을 사용하지 않고 직접 프로젝트를 구성하신 분들을 위한 해결 방안을 안내합니다.
그럼 왜 굳이 react-gh-pages
를 사용하지 않냐고요? create-react-app
을 사용하지 않았다면 react-gh-pages
모듈을 사용할 수 없답니다.
아쉽게도, 이 글을 통하여 다루는 React 앱은 서버 사이드 렌더링(이하 SSR)을 통하여 배포할 수 없습니다. 왜냐하면 SSR을 수행하려면 React 앱을 배포하는 서버에서 이와 관련된 설정을 해주어야 하지만, 우린 그럴 수가 없습니다. 왜냐하면, 일반 사용자인 우리는 Github Page 서비스를 사용할 수 있을 뿐, Github Page 서비스를 제공하는 Github 서버의 내부 로직을 수정할 수는 없기 때문입니다.
따라서, 이 글에서는 클라이언트 사이드 렌더링으로 구동되는 React 앱을 다루는 것을 전제로 하고 읽어주시기 바랍니다. 어차피 여기서 목표로 하는 것은 간단한 React 앱의 배포이니, 괜찮습니다.
전통적인 방식의 웹 페이지의 경우 라우팅 요청이 들어왔을 때, 서버 상에서 경로를 파싱한 뒤 이에 대응하는 페이지를 반환하는 식으로 페이지 이동이 이루어집니다. SPA 형태인 React 앱에서도 라우팅 요청이 들어왔을 때 이를 서버가 처리해주는 것은 같습니다. 하지만, 라우팅 처리 로직이 서버에 있지 않고 React 앱 내에 존재하며, React 내부에서 처리되므로, 모든 요청을 균일하게 단 하나의 라우팅으로 받고, 응답으로 React 앱이 시작되는 index.html
을 반환한다는 점이 다릅니다. 그러면 이후 브라우저에서는 React 코드를 실행하고, 현재의 경로 URL을 확인하여 동적으로 페이지를 전환하게 됩니다.
// React 앱을 배포하는 아주 간단한 형식의 Node.js 서버 코드
const express = require('express');
const app = express();
// ...
app.get('/*', (req, res, next) => {
res.sendFile('index.html', {
root: path.join(__dirname, 'build')
})
});
// ...
app.listen(PORT, () => { /* ... */});
정리하면, React 앱에서 라우팅이 제대로 이루어지려면 모든 요청에 대하여 index.html
을 반환해주어야 합니다. 그런데 Github Page로 배포하면 그것이 불가능합니다. 앞서 서버사이드 렌더링을 사용할 수 없는 것과 같은 이유로 말이죠.
/
이외의 경로로 접근한다면 서버 입장에서는 식별할 수 없는 경로이므로, 404 Not Found 페이지를 반환해줍니다.
404.html
이런 경우를 위한 똑똑한 솔루션이 이미 존재합니다. 알 수 없는 경로로 존재했을 때 404.html
페이지를 반환한다는 점을 이용한 트릭입니다. 404.html
이 브라우저에서 실행될 때, /
로 리다이렉션을 시키고 동시에 현재 접근한 경로의 URI를 쿼리 스트링으로 전달하는 것입니다.
만약 `cadenzah.github.io/main`으로 접근했다면
→ `cadenzah.github.io?p=/main`으로 변환하는 식
/
로 도착한 뒤에는 즉시 쿼리 스트링을 파싱하여 본래의 경로로 다시 되돌린 뒤, 현재 경로로 대체합니다. 이후 이어서 React 코드가 실행되므로, 적절하게 경로가 처리될 수 있게 됩니다.
// index.html
<head>
<!-- 쿼리 스트링의 파싱이 제일 먼저 이루어진다 -->
...
(function(l) {
// 파싱 결과를 토대로 URI 내용을 대체한다
window.history.replaceState(null, null,
l.pathname.slice(0, -1) + (q.p || '') +
(q.q ? ('?' + q.q) : '') +
l.hash
);
}(window.location)
</head>
<body> ... </body>
<!-- 이후, 변환된 경로를 토대로 React 코드를 실행한다 -->
<script src="js/bundle.js"></script>
...
단순한 경로 이외에도 쿼리 파라미터(e.g. /search?keyword=ausg
), Fragment Identifier(e.g. /profile#list
)도 지원합니다. 자세한 사항은 위 링크의 저장소를 참조해주세요.
위 링크의 글을 번역해주신 분이 계셔서 해당 글을 링크합니다.
이를 토대로 Github Page에 사용하기 위한 프로젝트 구조를 구성해보면 아래와 같은 형식이 나올 수 있습니다:
// Github Page 배포시 `master` 브랜치를 통하여 배포하는 옵션을 선택하는 경우:
/ (최상위 디렉토리)
|-- docs/
|-- js/
|-- index.html
|-- 404.html
js/
는 번들링된 React 코드가 저장되도록 설정한 폴더로, 번들러 설정에 따라 번들 코드의 저장 위치는 다를 수 있습니다.
여기서 사용되는 index.html
과 404.html
은 앞서 제시한 해결 방법이 적용된 스크립트가 추가된 버전입니다. 이제 React 앱 내에서 React Router를 사용하여 경로를 구성한 뒤 배포해보면, 정상적으로 작동하는 것을 확인할 수 있습니다.
아니, 정상적으로 작동하던가요?
Github Page를 사용하여 페이지를 구축하는 가장 간단하고 기본적인 방법은 <자신의 Github 계정 이름>.github.io
라는 이름의 저장소를 생성하는 것입니다. 예를 들어, 제 ID를 사용한다면 아래와 같은 형식의 주소를 가질 것입니다.
// 들어가도_아무_것도_없어요
https://cadenzah.github.io
형식에서 보시다시피 깔끔하게 도메인 이름으로만 이루어진 주소입니다. 그런데, 만약 지금 사용중인 계정에 <자신의 Github 계정 이름>.github.io
꼴의 저장소를 이미 사용중이라면, 어떻게 해야 할까요? 그야, 새로운 저장소를 만들면 되겠죠. 여기서는 간단하게, example
라는 이름의 저장소를 만들었다고 해보겠습니다.
// 실제로는_만들지_않았어요
https://cadenzah.github.io/example
Github에서 어떤 계정 또는 Organization에 속한 저장소는 위와 같이 서브 URI 형태로 자신의 저장소 이름이 들어가는 형식의 이름을 갖게 됩니다. 그런데 문제는, 앞서 서브 URI가 없는 기본 저장소의 앱에서는 앞서 말씀드린 솔루션이 효과를 발휘하여 라우팅이 제대로 작동하지만, 방금 새로 만든 저장소에서는 그렇지 않다는 것입니다.
React Router는 화면 이동시 React 앱의 Base URL, 즉 /
가 시작되기 전까지의 주소를 기준으로 주소를 변경합니다. 따라서 Base URL은 외부에서 보이는 현재 앱의 주소입니다. 즉, 지금의 예시에서는 cadenzah.github.io
인 것이죠. cadenzah.github.io
라는 이름으로 생성한 저장소로 배포하였다면 문제없이 작동하겠지만, 현재 예시와 같이 별도의 저장소를 생성하여 배포하는 경우라면 분명 문제가 생길 것입니다. 최초로 앱에 진입했을 때까지는 문제가 없었더라도, <NavLink>
등을 클릭하여 페이지를 전환하면 바로 문제합니다.
그렇다면, 각 라우팅 경로를 접근할 때마다 저장소 이름을 앞에 붙여준다면 문제가 해결될 겁니다. 이를 위하여 React Router는 basename
속성을 제공합니다.
// index.jsx
<BrowserRouter basename="/example">
<Link to="/home" /> // 경로가 /example/home 으로 자동 변환
<Link to="/author" /> // 경로가 /example/author 으로 자동 변환
</BrowserRouter>
이제 어떤 저장소에서 React Router를 사용하더라도 경로 변경이 제대로 이루어질 겁니다.
일반적인 웹 서버와 마찬가지로 Github Page로 앱을 배포하면 소스 파일들이 정적 파일의 형태로 제공됩니다. 이 소스 파일들은 저장소 내에 실제 파일로 존재하게 됩니다.
// Github Page 배포시 `master` 브랜치를 통하여 배포하는 옵션을 선택하는 경우:
/ (최상위 디렉토리)
|-- docs/
|-- js/
|-- index.html
|-- 404.html
위 설정대로 앱을 배포하면, 지금의 예시에서는 cadenzah.github.io
라는 주소 아래 example
이라는 하위 디렉토리 내에 docs
디렉토리의 내용이 그대로 위치하게 됩니다.
cadenzah.github.io
|-- example/
|-- js/
|-- index.html
|-- 404.html
React의 경우, index.html
을 제외한 나머지 정적 파일들(e.g. CSS 파일, JS 번들 파일, 이미지 파일 등)은 index.html
이 브라우저 상에서 파싱되는 과정에서 차례차례 로드될 수 있도록, index.html
파일 내에 관련 마크업을 작성해주는 것이 일반적입니다. 그러면 브라우저는 현재의 Base URL 기준의 상대 경로를 통하여 정적 파일을 찾게 됩니다.
특별한 설정 없이 React 코드를 번들링한 뒤 앱을 배포해보면, index.html
상에서 정적 파일들을 찾을 수 없어 아예 React 앱이 실행되지 않는 문제가 발생합니다. 이는 앞서 React Router를 처음 설정할 때 만난 문제와 원인이 동일한데, 저장소 이름이 index.html
의 Base URL에 적용되지 않은 상태로 배포가 이루어졌기 때문입니다.
- 실제 에셋들의 위치: cadenzah.github.io/example/js/bundle.js (O)
- `index.html`이 찾은 경로: cadenzah.github.io/js/bundle.js (X)
index.html
동일하게 경로와 관련된 문제이지만, 발생 위치가 다릅니다. 앞서 React Router에서의 문제는 React 코드 상에서의 문제이지만, 지금은 index.html
, 즉 브라우저 수준에서 파일을 찾지 못하는 문제입니다. 따라서 index.html
을 수정하여 Base URL을 올바르게 설정해주면 문제가 해결됩니다.
<html>
<head>
<base href="https://cadenzah.github.io/example/">
</head>
...
<script src="js/bundle.js"></script>
...
<base>
태그를 사용하여 Base URL을 변경해주었습니다. 이제 index.html
내에서 상대 경로를 사용시 새로운 Base URL에 src
를 덧붙여서 정적 파일에 정확히 접근할 수 있게 됩니다.
예상하신 분들도 계시겠지만, 위 솔루션을 적용한 페이지는 SEO가 일반적인 형태로는 이루어지지 않습니다. 이 점은 위 솔루션을 만든 저자도 분명히 지적하고 있습니다. 리다이렉션된 마지막 페이지가 최종적으로 색인되는 특성상, SEO는 이루어지지만 최종 URL이 일반적인 형태를 갖지 못하게 됩니다.
만약 웹 크롤러가 `cadenzah.github.io/example/main`으로 접근했다면
→ `cadenzah.github.io?p=/example/main`을 색인
간단하게 Github Page에 앱 올려볼까~ 했는데, 생각보다 방법은 간단하지 않았던 상황이었습니다. 이 짧은 경험을 통하여 알 수 있었던 내용들을 간단하게 정리해보겠습니다.
index.html
둘 다 수정이 필요합니다.실제 적용된 간단한 예시는 여기에서 확인해볼 수 있습니다. 다음 편에서는 Github Page 배포용 소스들을 편리하게 생성하기 위한 Webpack과 프로젝트 설정 등을 예제 코드를 곁들여서 설명해보도록 하겠습니다. 궁금하신 점이나, 지적해주실 점이 있다면 댓글로 남겨주세요 😀
오~ BaseURL과 같은 설정을 간편하게 하는 방법을 알고있는게 가끔 큰 도움이 될 것 같아요. api 서버 짤 때도 /api=>/api/v1으로 base url을 옮기거나 할 때도 그렇구요.
하나 궁금한 건 404.html에서 리다이렉트 후 쿼리스트링을 이용하는 방식 외에 404.html을 index.html과 같은 내용으로 설정하면 올바르게 동작할 수 없나요?
비슷한 예시로 S3로 SPA 페이지 호스팅 할 때 에러페이지에 index.html을 설정해줬던 기억이 있어서요!