Vue 프로젝트를 하다보면 URL에 '#' 모양이 포함된 것을 볼 수 있다. '#'에 의미는 뭘까? '#'이 붙어 있는 것과 없는 것의 차이는 무엇일까?
1. 들어가기 전
우선 URL에 해쉬(#)가 붙어 있는 것이 Hash Mode입니다. 그리고 URL에 해쉬(#)가 없는 것이 우리가 늘 보았던 깔끔한 주소인데 이것은 History Mode입니다.
왜 해쉬(#)가 붙는 URL이 있는 걸까? 호기심을 풀어보겠습니다.
먼저, SPA(Single Page Application)라는 것을 알아야 합니다.
SPA란, 웹 브라우저에서 액세스할 수 있는 하나의 인덱스(보통 index.html) 파일에서 동적으로 화면을 변경해 표시하는 기술입니다.
SPA 작동 방식은,
- 브라우저는 최초에 한 번 서버에 요청하여 하나의 페이지 전체를 Load합니다.
- 이후, 페이지가 이동할 때 클라이언트에서 처리되는 라우팅을 통해서 URL에 해당하는 내용을 기존 페이지에 렌더링 하는 것 입니다. 이것이 Client-Side Routing입니다.
↔ SPA 보편화 되기 전 전통적인 라우팅은 서버에서 처리하는 Server-Side Routing 방식이었습니다.
여기서 Client-Side Routing과 매우 밀접한 관련이 있습니다.
! 힌트: SPA는 한 개(Single)의 페이지(Page)로 구성된 어플리케이션(Appliation)이다.
만약, 사용자가 URL을 브라우저에 직접 액세스한다면 어떻게 될까?
(직접 액세스한다는 말은 새로고침 하거나 주소창에 URL을 입력하고 엔터를 친 경우를 말합니다.)
- 주소창에 'http://localhost:8080/goods/1234' 입력 후 엔터를 친다.
- 서버는 요청된 주소의 맞는 물리적 경로를 찾아가는데... 물리적 경로가 없다?
- "404 페이지를 찾을 수 없습니다." 에러 페이지가 나타난다.
SPA는 클라이언트에서 설정한 라우팅을 통해 페이지가 이동되는 것처럼 보이게 해줍니다. 그런데 이 라우팅을 통해 접속하지 않고 주소를 직접 때리고 접속하면 서버에서는 해당 주소 경로의 페이지 파일을 찾을 수 없기 때문에 문제가 발생합니다.
결론은 경로 찾기 문제 때문입니다.
그래서 URL에 해쉬(#)가 포함되는 Hash Mode가 생긴 것입니다.
2. Hash Mode
Hash Mode의 특징은,
- 주소에 해쉬(#)가 붙기 때문에 보기 불편한 마음이 듭니다(?)
- 해쉬(#) 뒤에 주소가 변경되면 브라우저는 서버에 어떠한 요청도 하지 않습니다. 따라서 페이지 갱신(새로고침)되지 않아요.
- 대신 해쉬(#) 뒤에 주소 값은 history stack에 추가되므로 history 관리는 가능한 상태입니다.
Hash Mode의 동작 방식은,
- 주소창에 'http://localhost:8080/#/goods/1234' 입력 후 엔터를 친다.
- 해쉬(#) 이전의 도메인 주소로 서버에 요청이 간다.( http://localhost:8080)
- 도메인 주소에 해당하는 곳에 있는 인덱스(보통 index.html) 파일의 내용을 Content-Type이 text/html 형태로 클라이언트한테 전달합니다.
- 클라이언트는 서버로 부터 전달 받은 인덱스(보통 index.html) 파일 내용을 로드합니다.
- 해쉬(#) 이후에 path는 라우터에게 전달되고 path에 해당하는 내용을 기존 페이지에 렌더링하게 됩니다.
3. History Mode
"주소에 해쉬(#) 있는 것이 보기 싫어요.", "일반적인 주소가 아니라서 마음이 불편합니다." 등 석연치 않은 분들은 Hash Mode 대신 History Mode를 사용하세요.
Vue에서 제공하는 Vue Router의 공식 내용을 확인해보겠습니다.
"history.pushState API를 활용하여 페이지를 다시 로드하지 않고도 URL을 탐색할 수 있다 "고 나옵니다.
그러나 문제는 URL을 직접 접속하면 404 오류가 발생합니다.
3.1 History mode 문제
SPA(Single Page Application)앱이므로 적절한 서버 구성이 없이 사용자가 URL을 브라우저에 직접 액세스하면 404 오류가 발생합니다.
Vue는 외부에서 보여지는 페이지는 많지만 실제로는 index.html 하나입니다. index.html 내에서 Vue Component들의 조합으로 페이지 이동이 일어난 것처럼 보이는 것입니다.
이 부분을 해결하려면 서버에서 Rewrite 설정을 해줘야 합니다.
3.2 History mode 문제 해결 방법
URL이 정적 자산과 일치하지 않으면 동일한 페이지의 index.html을 대체 경로로 서버에 추가해야 합니다.
개발 환경과 운영 환경 각각 문제 해결 방법을 아래와 같이 알아봅니다.
3.2.1 개발 환경에서의 설정
Vue router 수정
createWebHistory() 함수를 hitory 옵션에 설정한다.
1
2
3
4
5
6
|
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(), // history mode
routes: routeList,
});
|
cs |
Webpack 수정
1. devServer 옵션에서 historyApiFallback: true,로 설정한다.
2. output 옵션에서 publicPath: '/',와 같이 경로를 꼭 지정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
module.exports = {
mode: 'development',
devtool: 'inline-source-map',
devServer: {
historyApiFallback: true,
},
output: {
filename: '[name].[contenthash].js',
publicPath: '/',
clean: true,
},
module: {
rules: [
// .... loader...
],
},
};
|
cs |
! 주의 사항: publicPath 값을 auto로 지정하지 않습니다.
3.2.2 운영 환경에서의 Server 설정
nginx
server & location contenxt에 'try_files $uri $uri/ /index.html;' 코드를 작성합니다.
location / {
root html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
|
cs |
try_files는 모든 request에 대해 일치하는 path를 확인 후 만약 해당 path가 root에 존재한다면 해당 path로 rewrite 한다.
첫 번째 $uri는 URI는 있는 그대로 검사한다.
두 번째 $uri/는 URI를 디렉터리로 검사를 한다.
세 번째 /index.html은 대체 옵션으로 간주한다. 위의 최소 2개 옵션을 nginx 내부에서 찾고 없다면 index.html을 찾아서 제공해준다.
만약 세 번째 조건도 실패하면 nginx는 404 오류 페이지를 제공한다.
Express
미들웨어 사용하는 경우
npm을 통해 connect-history-api-fallback 미들웨어 설치
npm install --save connect-history-api-fallback |
다음과 같이 미들웨어 추가
const express = require('express');
const app = express();
const history = require('connect-history-api-fallback');
// 미들웨어 추가
app.use(history());
|
cs |
미들웨어 사용하지 않고 URL 탐색
const express = require('express');
const app = express();
const indexHtmlPath = path.join(__dirname, '..', 'dist/index.html');
// history 모드에서 페이지를 다시 로드하지 않고도 URL 탐색하는 방법
app.all('*', (req, res) => {
try {
res.sendFile(indexHtmlPath);
} catch (err) {
res.json({ code: 'ERROR', message: '' });
}
});
app.listen(5000, () => {
console.log('server on');
});
|
cs |
4. 마치며....
이 history mode를 알아보게 된 계기는 외부에서 해당 사이트로 링크 걸 때 redirect 이슈가 있다고 hash mode에서 history mode 변경에 대한 문의가 오게 되었다. 세부 내용은 어떤 서비스에서 fragment가 허용되지 않는 이슈였던 것 같다. 뭐 나중에서야 hash mode의 hash(#) fragment 이슈는 아니었고 해당 서비스 단에서 해결 되었지만, 그 사이 우리는 history mode에 대해 검토를 진행 중이었던 상황이다.
SPA 웹 사이트에서 검색엔진 최적화(SEO)에 문제가 발생하는데, 사이트 검색에 노출하지 않는 사이트의 경우 hash mode던, history mode던 상관 없을 것이다. 지금은 Hash mode와 History mode의 배경과 개념만 이해하면 될 듯 하다.
5. 기타 참고
5.1 URL 프래그먼트 = hash mark(#)
기본 리소스에 종속된 리소스를 참조하는 문자열로서 기본 리소스는 URI로 식별되고 조각 식별자(fragment identifier)는 하위 리소스를 가리킨다고 합니다. 일반적으로 해당 문서의 일부를 식별하는 데 사용된다고 합니다.
예시
- - ID로 식별되는 요소에 페이지 위치로 스크롤 한다.
- 다음 a 태그의 id 식별자가 "서버-설정-예제"로 작성되었다.
- <h2 id="서버-설정-예제"><a href="#서버-설정-예제" class="header-anchor">#</a> 서버 설정 예제</h2>
- 다음 URL로 접근하면 ID식별자가 #서버-설정-예제인 위치로 이동한다.
- 다음 a 태그의 id 식별자가 "서버-설정-예제"로 작성되었다.
5.2 브라우저에서 History API 제공
History API는 전역 객체를 통해 브라우저 히스토리를 조작할 수 있습니다.
다음과 같이 히스토리 스택을 조잘할 수 있는 메서드와 이벤트를 확인합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
history.back(); // 브라우저 도구 모음의 Back 버튼을 클릭한 것과 동일하게 뒤로 이동할 수 있다.
history.forward(); // 사용자가 Forward 버튼을 클릭한 것처럼 앞으로 이동할 수 있다.
history.go(-1); // 페이지를 하나 뒤로 이동한다.
history.go(1); // 페이지를 하나 앞으로 이동한다.
history.go(0); // 현재 페이지를 새로 고친다.
hisotory.go(); // 현재 페이지를 새로 고친다.
const numberOfEntries = history.length; // 속성 값을 확인하여 기록 스택의 페이지 수를 확인할 수 있다.
// 기록을 추가(stateObj, title, URL)
history.pushState({ page: 1 }, "title 1", "?page=1");
history.pushState({ page: 2 }, "title 2", "?page=2");
// 기록을 교체
history.replaceState({ page: 3 }, "title 3", "?page=3");
// 기록 이동
history.back(); // alerts "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back(); // alerts "location: http://example.com/example.html, state: null"
history.go(2); // alerts "location: http://example.com/example.html?page=3, state: {"page":3}"
|
cs |
다음과 같이 History API 함수 및 속성을 확인할 수 있습니다.
5.3 History mode를 제공하는 Front-end 프레임워크 및 라이브러리들
Angular
LocationStrategy Provider를 지정하여 결정할 수 있다.
Provider | 설명 | |
PathLocationStrategy | HTML5 pushState 스타일을 사용합니다. 이 값이 기본값입니다. | localhost:3002/crisis-center |
HashLocationStrategy | 해시 URL 스타일을 사용합니다. | localhost:3002/src/#/crisis-center |
import { NgModule } from '@angular/core';
import { HashLocationStrategy, LocationStrategy, PathLocationStrategy } from '@angular/common';
import {AppComponent} from './app.component';
@NgModule({
declarations: [
// components...
],
imports: [
// modules...
],
providers: [{ provide: LocationStrategy, useClass: HashLocationStrategy }],
bootstrap: [AppComponent]
})
Vue 3
Provider | 설명 | |
createWebHashHistory | 해시 URL 스타일을 사용합니다. 이 값이 기본값입니다. | example.com/#/user/22 |
createWebHistory | 히스토리 모드 | example.com/user/22 |
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(), // history mode
routes: routeList,
});
※ 참고
Vue Router: https://v3.router.vuejs.org/kr/
Vue Router : https://router.vuejs.org/guide/essentials/history-mode.html
URI fragment: https://en.wikipedia.org/wiki/URI_fragment
Hash Mode vs History Mode 차이점은 무엇인가: https://happy-coding-day.tistory.com/entry/Vue-vue-router에서-Hash-Mode-Vs-History-Mode-차이점은-무엇인가
jooyoung.log: https://velog.io/@yangddu/mode-history
Configure Html5 History Mode in VueJS and Apache: https://blog.devgenius.io/configure-html5-history-mode-in-vuejs-and-apache-52daa0c76a75
mdn web docs History API: https://developer.mozilla.org/en-US/docs/Web/API/History_API
Angular router: https://angular.io/guide/router