React를 이용해 개발한 프로젝트를 배포를 하려면 build를 거쳐야 한다.
netlify, vercel, haeroku와 같은 좋은 무료 호스팅 웹사이트에 repository와 해당 branch만 연결하면 push와 동시에 자동 배포를 해준다.
하지만 무료 버전은 배포 횟수도 제한되어있고 여기저기 호스팅 사이트에 흩어져서 배포하다보니 추후 관리가 힘들어졌다.
그래서 간단한 사이드 프로젝트는 GitHub에서 제공하는 GitHub pages로 배포하기로 했다.
GitHub pages도 마찬가지로 배포할 branch를 선택하면 push가 트리거될 때 자동 배포를 해준다.
문제는 branch에 빌드된 파일이 존재해야하기 때문에 매번 npm run build
명령어를 입력하고 gh-pages
branch에 빌드 결과를 push 해주어야 한다는 점이 무척 번거로웠다.
(gh-pages
라이브러리를 설치하고 predeploy command를 추가해서 하나의 deploy command 로도 배포가 가능한 방법도 있지만 역시나 매번 명령어를 입력해야 하는 번거로움이 있다.)
이번 기회에 이전 포스팅에서 다루었던 GitHub Actions를 이용해 CI/CD를 적용해보기로 했다.
프로젝트 루트 디렉토리에 .github/workflows
디렉토리를 생성하고 그 하위에 스크립트 파일을 작성한다. 나는 deploy.yaml
이라는 파일명으로 작성하기로 했다.
name: Deploy to GitHub Pages
on:
push:
branches:
- deploy
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install dependencies
run: npm run install
- name: Run Lint
run: npm run lint
- name: Run build
run: npm run build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ secrets.ACCESS_TOKEN }}
publish_dir: build
간단히 요약하자면 GitHub Pages에 배포하는 workflow이며, deploy branch에 push 이벤트가 트리거될 때마다 실행된다.
해당 repository에 checkout해서 코드에 접근하고 종속성 설치, 린트 검사, 빌드를 실행한다.
그다음 access token로 permission을 얻고 build 디렉토리의 파일들을 GitHub Pages에 배포한다.
actions-gh-pages 를 통해 GitHub Pages에 배포하고 있는데 이때 repository에 접근할 수 있는 권한을 부여해주어야 한다.
GitHub Settings > Developer Settings > Personal Access Tokens > tokens (classic) > Generate new token
위의 과정을 거쳐 접근 가능 권한을 부여해주는 토큰을 발급 받는다.
발급 받은 토큰은 다시는 볼 수 없으므로 어딘가에 꼭 저장해두자! (까먹으면 다시 재발급받아야하므로 귀찮다)
이제 프로젝트 repository로 다시 돌아가서 발급받은 access token을 적용시켜주면 된다.
Repository Settings > Secrets and variables > Actions > Repository screts > New repository secret
key는 workflow에 작성한 변수명, value는 발급받은 토큰을 넣는다.
Repository Settings > Pages > Build and deployment > Branch > gh-pages
설정에서 gh-pages를 배포 브랜치로 설정한다!
자 이제 설정이 끝났다!! 🎉
이제 deploy branch에 push 이벤트가 발생하면 workflow script가 실행되는데 이 과정은 Repository의 Actions 탭에서 확인할 수 있다.
스크립트가 성공적으로 적용되었다면 위의 사진과 같이 작동한다.
1. deploy branch가 push 될 때 위에서 작성했던 Deploy to GitHub Pages
workflow script가 실행된다.
2. 1번이 성공하면 빌드된 파일들이 gh-pages branch로 push 되고 Settings > Pages 에서 설정했던 pages-build-deployment
workflow가 실행될 것이다. 이것은 gh-pages가 설정한 branch가 트리거 될 때 자동적으로 실행되는 workflow다.
3. 이제 repository 오른쪽 사이드바에 생성된 Deployments에 들어가서 배포된 사이트를 확인하자!
index.html에 삽입된 link, script 태그를 지우지 않아서 생긴 오류다.
webpack이 번들링해서 자동으로 넣어주므로 삭제하자 😅
에러를 확인해보니 번들된 js와 css를 가져오지 못하고 있었다.
리소스의 경로를 살펴보니 https://mogooee.github.io/mogooee/challenges/static/js/main.048b81e2.js
인데
배포된 gh-pages의 경로는
https://mogooee.github.io/challenges
이므로 올바른 경로는
https://mogooee.github.io/challenges/static/js/main.048b81e2.js
이어야 한다.
바로 아이디가 경로 중간에 포함되어 있는 함정이 있었다.
webpack이 번들링한 파일을 index.html
에 넣는 과정에서
mogooee/challenges
라는 레포지토리 이름이 자동으로 들어갔다.
배포된 url의 path에는 아이디를 제외한 순수 레포지토리 이름 challenges
만 들어있어야 한다.
이 문제는 번들 파일의 ouput이 현재 레포지토리로 설정되어 있기 때문에 생기는 문제로 추측할된다.
webpack은 webpack.config.js
환경설정 파일에서 번들된 파일의 output path를 설정할 수 있었다.
output: {
path: path.join(__dirname, '..', 'build'),
filename: 'js/[name]-[chunkhash].js',
assetModuleFilename: 'assets/[name][ext]',
clean: true,
},
그러나 CRA로 프로젝트를 생성했기 때문에 webpack 설정을 위해 eject 하는 것보다 더 간단한 해결 방식이 필요했다..
stackoverflow에서 발견한 답변은 package.json
파일의 homepage
속성을 "."
로 설정하는 것이었다.
package.json
의 homepage
필드CRA 공식문서에 따르면 기본적으로 CRA는 서버 루트에서 빌드한다.
서버 루트란 https://www.example.com/some/path
라는 URL에서 https://www.example.com
에 해당하는 부분으로 웹 서버에서 호스팅 되는 모든 파일 및 디렉토리의 최상위 위치다.
일반적으로 루트 디렉토리에 HTML파일과 정적자원들이 위치하므로, 클라이언트가 루트 경로로 웹 페이지에 접근할 때 서버는 해당 웹 페이지의 HTML 파일과 관련된 정적 자원들을 제공한다.
하지만 서버 루트가 아닌 위치에서 HTML 파일, 정적 자원들이 위치하는 경우도 있다.
gh-pages로 배포하는 상황이 이러한 경우에 속한다.
만약 gh-pages가 서버 루트에서 빌드한다면
https://username.github.io
라는 URL에서 호스팅될 것이다.
그러나 gh-pages는 https://username.github.io/repository
에서 사이트를 호스팅하고 있다.
이것은 https://username.github.io/repository
경로에서 HTML 파일과 정적 자원들을 불러와야 한다는 것을 의미한다.
이렇게 서버 루트가 아닌 곳에서 자원을 로드해야할 때 CRA는 package.json
파일의 homepage
필드 값을 HTML 파일을 생성하는 root URL로 오버라이드한다.
homepage
필드를 통해 웹 애플리케이션이 호스팅될 URL을 지정할 수 있다.
일반적으로 빌드 디렉토리에는 index.html
과 정적 자원(js, css, img 파일 등)이 있다.
project/
├── build/
│ ├── index.html
│ ├── assets/
│ │ └── image.jpg
homepage
속성을 "."
으로 설정하면 index.html
파일이 있는 현재 디렉토리가 해당 프로젝트 루트 디렉토리가 된다. 정적 자원들 역시 HTML 파일과 같은 디렉토리에 존재하므로 루트 디렉토리에 존재하게 된다. (CRA에서는 일반적으로 public 폴더에 HTML 파일이 존재하지만 빌드될 때 최상위 디렉토리로 복사되므로, 루트 디렉토리에 있다고 간주된다.)
즉, 정적자원과 index.html
파일이 동일한 루트 디렉토리에 위치하게 되어 모든 정적 자원을 index.html
의 상대 경로로 설정하므로 자원 로드 문제를 해결할 수 있다.
"homepage": ".",
하지만 이 방식은 HTML5의 pushState history API 또는 client-side routing을 하지 않을 경우에만 사용해야 한다.
클라이언트측 라우팅(SPA)을 사용하지 않는다는 의미는 매번 요청마다 서버가 새로운 HTML 페이지를 생성하고 반환하는 서버측 렌더링(SSR)을 사용한다는 것이다.
서버 측에서 모든 라우팅과 페이지 로드를 처리하기 때문에 애플리케이션은 페이지 위치를 명시할 필요없이 각각의 경로에 대한 요청마다 HTML 파일과 자원을 받아서 사용하면 된다.
SPA에서는 현재 URL에 기반하여 정적 자원을 찾으려고 하므로 https://username.github.io/repository
경로에 대한 라우터가 있을 때 https://username.github.io/repository/assets/image.jpg
를 찾으려고 한다.
하지만 자원은 실제로 https://username.github.io/assets/image.jpg
경로에 존재하는 경우 오류가 발생할 수 있다.
homepage
속성을 "."으로 지정하는 것은 주로 SSG, SSR에서 사용된다.
SPA 경우 라우팅된 현재 url을 기준으로 정적 자원을 찾으려 하기 때문에 오류가 발생할 수 있다.
위와 같은 문제점으로 SPA에서는 정적 자원의 URL을 명시적으로 지정하는 것이 좋다.
정적 자원이 호스팅 되는 기본 URL을 절대 경로로 설정하여 자원을 로드하면 HTML 파일의 위치와 상관없이 항상 정적 자원을 올바른 위치에서 로드할 수 있다.
"homepage": "https://username.github.io/repository",
homepage 속성을 호스팅 되는 절대 경로인 url로 설정하면 SPA에서 라우팅에 따라 HTML 파일의 위치가 바뀌어도 항상 정적 자원을 올바른 위치에서 로드할 수 있다.
개발서버는 http://localhost:3000
에서 실행되므로 public/index.html
파일을 루트로 한다.
project-root/
├── public/
│ ├── assets/
│ │ └── image.jpg
│ └── index.html
└── src/
├── components/
│ └── Logo.js
└── pages/
└── Home.js
하지만 배포 환경에서는 앱이 빌드되어 build 디렉토리 하위로 index.html
이 이동한다.
project-root/
├── build/
│ ├── index.html
│ ├── static/
│ │ ├── js/
│ │ │ └── main.chunk.js
│ │ └── media/
│ │ └── logo.abc123.png
따라서 정적 자원은 index.html 파일의 위치를 기준으로한 상대 경로로 로드할 수 있다. (homepage: "."
)
하지만 SPA 라우팅은 경로마다 index.html
을 제공하는 SSR과 다르게 경로가 변경되더라도 여전히 build 디렉토리에 index.html
이 존재한다.
SPA에서 index.html
은 entry 목적이기 때문이다.
브라우저는 라우팅이 발생한 현재 URL(현재 디렉토리) https://username.github.io/repository/routing
를 기준으로 한 상대 경로로 자원을 가져오려 하지만 자원은 여전히 index.html
와 같은 디렉토리 https://username.github.io/repository
에 위치하므로 잘못된 경로로 인해 로드할 수 없는 문제가 발생할 수 있다.
따라서 절대경로를 사용하면 정적 자원의 경로가 항상 고정되어있어 더 안전하게 자원을 로드할 수 있다.
만약 프로젝트에서 react-router-dom
을 사용하고 있다면 위에서 설정한 homepage 속성으로 인해 router가 제대로 동작하지 않을 수 있다.
const Routers = (): React.ReactElement => {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<A />} />
<Route path="a" element={<A />} />
<Route path="b" element={<B />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
);
};
위와 같은 라우터 파일이 있을 때 BrowserRouter는 서버 루트 경로를 기준으로 하므로 https://username.github.io
를 찾는다.
배포된 경로는 https://username.github.io/repository
이므로 라우터가 동작하지 않는 것이다.
(그렇다고 https://username.github.io
로 접근하면 gh-pages의 기본 url로 404가 나온다.)
이를 해결하기 위해 아래와 같이 라우터에게 basename을 설정하여 루트 디렉토리 뒤에 path를 추가할 수 있다.
/repository
를 설정하면 라우터는 루트 디렉토리에 basename을 붙인 https://username.github.io/repository
경로를 라우터의 기본 url로 인식한다.
따라서 배포된 사이트의 https://username.github.io/repository
경로로 접근시 라우터도 정상적으로 동작하게 된다.
const Routers = (): React.ReactElement => {
return (
<BrowserRouter basename='/repository'>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<A />} />
<Route path="a" element={<A />} />
<Route path="b" element={<B />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
);
};
하지만 homepage 속성을 부여한 것과 마찬가지로 절대경로를 사용하면 안정성을 높일 수 있다. homepage 속성에 설정한 url을 process.env.PUBLIC_URL
로 코드에서 접근할 수 있으므로 다음과 같이 작성할 수 있다.
const Routers = (): React.ReactElement => {
return (
<BrowserRouter basename={process.env.PUBLIC_URL}>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<A />} />
<Route path="a" element={<A />} />
<Route path="b" element={<B />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
);
};
개발 환경에서는 문제 없었던 이미지 파일들이 배포 환경에서 엑박 처리되는 문제가 발생했다.
개발 환경 | 배포 환경 |
---|---|
![]() | ![]() |
코드를 살펴보면 X 아이콘은 단일로 가져오지만 success, info, warning, error 아이콘들은 동적으로 가져오고 있다.
template = () => {
return `<div class='notification' data-type=${this.type}>
<img src=${icon[this.type]}/>
<span>${this.message}</span>
<button class='cancel-btn'>
<img src=${icon.cancel} />
</button>
<progress value="${PROGRESS.MAX_VALUE}"
max="${PROGRESS.MAX_VALUE}"></progress>
</div>`;
};
이 이슈의 해결 방법은 다음 포스팅인 이미지 경로 동적으로 가져오기에서 다뤄보려 한다!