최근 리액트 공식문서를 한 번 훑어본 이후
프로젝트를 진행해보려고 한다.
SPA
에서 하나의 페이지만 사용하는 프로젝트의 등용문이 투두리스트였다면
여러 페이지를 라우팅 해서 사용하는 프로젝트의 등용문이 블로그인 것 같으니
간단한 메모 앱을 이용하려고 한다.
그 전, SPA
의 라우팅 기능에 대해 공부해보고자 한다.
SPA
에서의 라우팅전통적인 MPA (Multi Page Application)
에서는 a
태그를 이용해 서버에 업로드 해둔 페이지들을
클라이언트의 요청에 따라 완성해둔 페이지를 서버에서 요청 , 응답하여 페이지에 통째로 띄우곤 했다.
이 때 발생되는 새로운 페이지를 받아와 페이지를 이동하는 동안 새로운 페이지를 렌더링 하는 동안 화면이 깜박이는 현상 , Blink
현상 때문에 UX
가 떨어지곤 했다.
이에 대한 불편함을 해결하기 위해 나온 것이 SPA
로
클라이언트가 페이지에 접속하면 단순히 하나의 Single page
만을 제공하고
클라이언트의 이벤트에 따라 렌더링 되는 화면을 조작하여 UX
를 극대화 하는 SPA (Single Page Application)
이
리액트와 함께 같이 부상하였다.
하지만 라우팅 기능이 없던 SPA
에서는 치명적 문제가 몇 가지 존재했다.
URL
이 일치하지 않는 문제 예를 들어 클라이언트가 SPA
인 https://react.dev
에 접속해 화면을 보고 있다가
useState
에 대한 내용을 보기 위해 버튼을 클릭하고 이동했다고 해보자
이 때 SPA
의 특성상 URL
의 주소의 변화 없이 화면에 렌더링 되는 컴포넌트의 구성을 변경하여 클라이언트 페이지에 렌더링 하여 속도는 빨라졌으나 URL
은 여전히 싱글 페이지의 주소인 https://react.dev
를 가리키고 있다.
이는 클라이언트가 해당 페이지를 북마크 해둘 수도 , 다른 이에게 링크로 공유 할 수도 없다.
페이지의 주소를 나타내는 URL
은 여전히 초기 페이지이기 때문이다.
SEO
) 문제초기 SPA
개발에서는검색 엔진이 동적으로 생성되는 콘텐츠들을 효과적으로 인덱싱 하지 못하는 문제가 발생했다.
하지만 적절한 라우팅과 서버 사이드 렌더링 같은 기술을 사용함으로서 SPA
도 검색 엔진에 잘 노출 될 수 있게 한다.
SEO
를 높이기 위해서는 검색 엔진에서 특정 내용을 검색 시, 노출되는 페이지의URL
과
페이지의HTML
의 내용들이 잘 부합해야 한다.하나의
URL
로만 구성된 경우 검색 엔진에서 내용이 노출되어도 , 클릭 시 렌더링 되는 페이지는 다른URL
이기에 신규 클라이언트의 유입에 문제가 발생한다.
하나의 페이지로 구성된 App.js
을 메인 페이지로 하는 경우
대부분 모든 페이지에 존재하는 state
들은 App.js
이하에서 관리되게 된다.
하나의 영역에서 여러 이벤트 핸들러와 상태들을 관리하는 것은 예기치 못한 움직임을 가져오게 될 수 있다.
하지만 라우터를 이용하면 사용자의 상태를 브라우저 세션을 통해 유지하며 , 라우팅을 통해 상태를 유지하거나 복원 할 수 있어 사용자 경험이 개선 된다.
SPA
의 라우팅 예시window.history
브라우저의 window
객체는 브라우저의 세션 기록을 기록하는 자료구조인
window.history
객체를 제공한다.
windo.history
객체는 사용자의 방문 기록을 앞 뒤로 탐색하고 , 방문 기록 스택의 내용을 조작 할 수 있는
유용한 메소드와 속성을 노출한다.
예시를 통해 알아보자
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<nav>
<button id="page=1">Route to page1</button>
<button id="page=2">Route to page2</button>
<button id="page=3">Route to page3</button>
</nav>
<div id="root"></div>
</body>
<script src="script.js"></script>
</html>
// 초기에 라우팅 할 페이지 선택
window.addEventListener('DOMContentLoaded', () => {
// 페이지 초기화
if (!window.history.state || !window.history.state.path) {
const initialPath =
'http://127.0.0.1:5500/prototype/history-study/index.html'; // 초기 페이지 설정
const initialState = { page: null, path: initialPath };
window.history.replaceState(initialState, '', initialPath);
$root.innerHTML = '';
}
});
const $nav = document.querySelector('nav');
$nav.addEventListener('click', (event) => {
if (event.target.tagName !== 'BUTTON') return;
const path = `?${event.target.id}`; // 상대 경로 지정
const states = { page: path.slice(-1) };
window.history.pushState(states, '', path);
});
window.history
는 다양한 프로퍼티와 메소드가 담긴 객체이다.
window.history.method ..
이 history
의 pushState
는 URL
을 특정 주소로 변경 시키는 메소드이다.
pushState(state, unused, url)
state
는 이동하고자 하는 페이지의 url
과 관련된 정보를 담은 객체이다.
인수로 전달한 객체는 window.history.state
객체에 얕게 복사된 객체가 담긴다.
두 번째 인수는 역사적으로 사용되였으나 현재는 사용되지 않는 인수로 빈 문자열을 제공하면 된다.
(뭘 제공하든 크게 상관은 없음)
세 번째 인수는 변경할 url
주소이다. 해당 주소는 꼭 절대 경로일 필요가 없다.
절대 경로일 경우에는 메인 파일과 같은 출처여야 한다.
popstate
는 뒤로 가기 버튼을 누르거나, window.history.back()
을 실행 한 것과 같다.
변경 이전의 URL
주소를 window.history
객체 내에 저장해두고 , 해당 이벤트가 발생 시 변경 이전의 URL
주소로 변경한다.
여기서 중요한 부분은 history
의 메소드들은 url
의 주소만 변경 시킬 뿐 요청을 보내거나 액션을 취하는 것이 아니다.
URL
주소와 렌더링 화면 동기화 하기이에 SPA
에서 라우터에 따른 렌더링을 하고 싶다면
특정 자료구조에 렌더링 할 문서들을 담아두고 , root node
의 inner HTML
로 추가해주자
const $nav = document.querySelector('nav');
const $root = document.querySelector('#root');
const initialPath = 'http://127.0.0.1:5500/prototype/history-study/index.html';
const routes = {
'?page=1': '<p>page1 입니다요</p>',
'?page=2': '<p>page2 입니다요</p>',
// 예시를 위해 ?page=3 은 없는 페이지라고 가정하자
'http://127.0.0.1:5500/prototype/history-study/index.html': '',
};
window.addEventListener('DOMContentLoaded', () => {
// 페이지 초기화
if (!window.history.state) {
const initialState = { page: null, path: initialPath };
window.history.replaceState(initialState, '', initialPath);
$root.innerHTML = '';
}
});
$nav.addEventListener('click', (event) => {
if (event.target.tagName !== 'BUTTON') return;
const path = `?${event.target.id}`;
// history.state 객체에 라우팅 할 객체의 key 인 path 프로퍼티도 추가
const states = { page: path.slice(-1), path: path };
window.history.pushState(states, '', path);
$root.innerHTML = routes[path] ?? '<p>없는 페이지입니다 !</p>';
});
다음 예시를 보면 pushState
를 이용해 라우팅 될 때 렌더링 되는 페이지는 변화하는 모습을 볼 수 있으나
뒤로가는 경우에는, window.history.back()
혹은 window.history.popState
이벤트가 일어나는 경우, 렌더링 되는 화면이 변하지 않는 모습을 볼 수 있다.
이는 뒤로가기 버튼을 눌렀을 때 주소만 변경되고 root node
의 innerHTML
은 변경되지 않았기 때문이다.
뒤로 가는 popState
이벤트가 발생했을 때에도 주소와 함께 페이지의 렌더링 화면이 변경되도록 해보자
...
// 뒤로 갔을 때에도 렌더링이 변경되도록 이벤트 핸들러 등록
window.addEventListener('popstate', () => {
const path = window.history.state.path;
$root.innerHTML = routes[path] ?? '<p>없는 페이지입니다 !</p]>';
});
const $nav = document.querySelector('nav');
const $root = document.querySelector('#root');
const initialPath = 'http://127.0.0.1:5500/prototype/history-study/index.html';
const routes = {
'?page=1': '<p>page1 입니다요</p>',
'?page=2': '<p>page2 입니다요</p>',
// 예시를 위해 ?page=3 은 없는 페이지라고 가정하자
'http://127.0.0.1:5500/prototype/history-study/index.html': '',
};
window.addEventListener('DOMContentLoaded', () => {
// 페이지 초기화
if (!window.history.state) {
const initialState = { page: null, path: initialPath };
window.history.replaceState(initialState, '', initialPath);
$root.innerHTML = '';
}
});
$nav.addEventListener('click', (event) => {
if (event.target.tagName !== 'BUTTON') return;
const path = `?${event.target.id}`;
// history.state 객체에 라우팅 할 객체의 key 인 path 프로퍼티도 추가
const states = { page: path.slice(-1), path: path };
window.history.pushState(states, '', path);
$root.innerHTML = routes[path] ?? '<p>없는 페이지입니다 !</p>';
});
// 뒤로 갔을 때에도 렌더링이 변경되도록 이벤트 핸들러 등록
window.addEventListener('popstate', () => {
const path = window.history.state.path;
console.log(routes);
$root.innerHTML = routes[path] ?? '<p>없는 페이지입니다 !</p]>';
});
하지만 현재 코드에도 치명적인 문제가 존재한다.
사용자가 URL
에 다른 주소를 입력해도 해당 주소로 이동하지도 , 렌더링 화면이 변하지 않는다.
사실 사용자가 URL
에 어떤 주소를 입력한다는 행위가
MPA
에서는 해당 도메인 서버에 쿼리문이 추가된 새로운 주소의 페이지를 요청하는 것이였으나
SPA
에서는 해당 도메인 서버로 어떤 쿼리문을 추가하든 결국 메인 파일인 index.html
만 제공 한다.
그래서 어떤 쿼리문을 이용하든 결국 index.html
인 파일 화면이 뜨게 된다.
이를 해결하기 위해서 최초 클라이언트접속 시
사용자의 쿼리문을 파싱하고 해당 쿼리문으로 라우팅을 해줘야 한다.
..
window.addEventListener('DOMContentLoaded', () => {
// 초기 페이지 로딩 시, 사용자의 쿼리문이 있는지 확인하고
const path = window.location.search || initialPath;
const content = routes[path] ?? '<p>없는 페이지입니다 !</p>';
$root.innerHTML = content;
// 해당 쿼리문을 이용해 페이지를 렌더링 한다. (만약 쿼리문이 없다면 기초 주소로 렌더링 된다.)
});
..
SPA
는 결국 하나의 index.html
파일만 읽는건가 ?그렇다.
SPA
에서 라우팅을 위해 여러 페이지별 컴포넌트를 구성해두어도
서버 측에서 엔트리 파일로 요청하는 것은 개발자가 제공한 index.html
파일 하나이다.
만약 우리가 SPA
로 이뤄진 https://react.dev/reference/react/useState
에 처음 접속하면
브라우저는 사실 엔트리파일인 https://react.dev
이 렌더링 된 후
매우 빠르게 reference/react/useState
쿼리문에 맞는 페이지가 렌더링 된 것이다.
여기서 사용자의 최초 접속 할 때는 사용한 쿼리문을 파싱하여
index.html
에서 적절한 렌더링 화면을 렌더링 시키면 된다.
이는
SPA
페이지에서 해당 페이지에서 제공하는 링크로 갑자기 이동하게 되었을 때에도 마찬가지이다.라우팅이 아니라 쿼리문을 이용해 이동했을 때
나의 설명은 라우팅을 처음 공부해보는 입장에서 쓴 매우 얕고 추상적인 지식이다.
이와 관련된 내용은
How I Implemented my own SPA Routing System in Vanilla JS
MDN
문서를 확인해보면 좋을 것이다.