React Router - 라우터 톺아보기 (리액트가 아닌 VanilaJS 에서의 SPA 라우터)

ChoiYongHyeun·2024년 3월 7일
1

리액트

목록 보기
16/31
post-thumbnail

공부 이유

최근 리액트 공식문서를 한 번 훑어본 이후

프로젝트를 진행해보려고 한다.

SPA 에서 하나의 페이지만 사용하는 프로젝트의 등용문이 투두리스트였다면

여러 페이지를 라우팅 해서 사용하는 프로젝트의 등용문이 블로그인 것 같으니

간단한 메모 앱을 이용하려고 한다.

그 전, SPA 의 라우팅 기능에 대해 공부해보고자 한다.


SPA 에서의 라우팅

라우팅의 필요성

전통적인 MPA (Multi Page Application) 에서는 a 태그를 이용해 서버에 업로드 해둔 페이지들을

클라이언트의 요청에 따라 완성해둔 페이지를 서버에서 요청 , 응답하여 페이지에 통째로 띄우곤 했다.

이 때 발생되는 새로운 페이지를 받아와 페이지를 이동하는 동안 새로운 페이지를 렌더링 하는 동안 화면이 깜박이는 현상 , Blink 현상 때문에 UX 가 떨어지곤 했다.

이에 대한 불편함을 해결하기 위해 나온 것이 SPA

클라이언트가 페이지에 접속하면 단순히 하나의 Single page 만을 제공하고

클라이언트의 이벤트에 따라 렌더링 되는 화면을 조작하여 UX 를 극대화 하는 SPA (Single Page Application)

리액트와 함께 같이 부상하였다.

하지만 라우팅 기능이 없던 SPA 에서는 치명적 문제가 몇 가지 존재했다.

1. 클라이언트가 보는 화면과 URL 이 일치하지 않는 문제

예를 들어 클라이언트가 SPAhttps://react.dev 에 접속해 화면을 보고 있다가

useState 에 대한 내용을 보기 위해 버튼을 클릭하고 이동했다고 해보자

이 때 SPA 의 특성상 URL 의 주소의 변화 없이 화면에 렌더링 되는 컴포넌트의 구성을 변경하여 클라이언트 페이지에 렌더링 하여 속도는 빨라졌으나 URL 은 여전히 싱글 페이지의 주소인 https://react.dev 를 가리키고 있다.

이는 클라이언트가 해당 페이지를 북마크 해둘 수도 , 다른 이에게 링크로 공유 할 수도 없다.

페이지의 주소를 나타내는 URL 은 여전히 초기 페이지이기 때문이다.

2. 검색 엔진 최적화 (SEO) 문제

초기 SPA 개발에서는검색 엔진이 동적으로 생성되는 콘텐츠들을 효과적으로 인덱싱 하지 못하는 문제가 발생했다.

하지만 적절한 라우팅과 서버 사이드 렌더링 같은 기술을 사용함으로서 SPA 도 검색 엔진에 잘 노출 될 수 있게 한다.

SEO 를 높이기 위해서는 검색 엔진에서 특정 내용을 검색 시, 노출되는 페이지의 URL
페이지의 HTML 의 내용들이 잘 부합해야 한다.

하나의 URL로만 구성된 경우 검색 엔진에서 내용이 노출되어도 , 클릭 시 렌더링 되는 페이지는 다른 URL 이기에 신규 클라이언트의 유입에 문제가 발생한다.

3. 페이지별 상태 관리의 어려움

하나의 페이지로 구성된 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 ..

historypushStateURL 을 특정 주소로 변경 시키는 메소드이다.

pushState(state, unused, url)

state 는 이동하고자 하는 페이지의 url 과 관련된 정보를 담은 객체이다.

인수로 전달한 객체는 window.history.state 객체에 얕게 복사된 객체가 담긴다.

두 번째 인수는 역사적으로 사용되였으나 현재는 사용되지 않는 인수로 빈 문자열을 제공하면 된다.
(뭘 제공하든 크게 상관은 없음)

세 번째 인수는 변경할 url 주소이다. 해당 주소는 꼭 절대 경로일 필요가 없다.

절대 경로일 경우에는 메인 파일과 같은 출처여야 한다.

popstate 는 뒤로 가기 버튼을 누르거나, window.history.back() 을 실행 한 것과 같다.

변경 이전의 URL 주소를 window.history 객체 내에 저장해두고 , 해당 이벤트가 발생 시 변경 이전의 URL 주소로 변경한다.

여기서 중요한 부분은 history 의 메소드들은 url 의 주소만 변경 시킬 뿐 요청을 보내거나 액션을 취하는 것이 아니다.

URL 주소와 렌더링 화면 동기화 하기

이에 SPA 에서 라우터에 따른 렌더링을 하고 싶다면

특정 자료구조에 렌더링 할 문서들을 담아두고 , root nodeinner 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 nodeinnerHTML 은 변경되지 않았기 때문이다.

뒤로 가는 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 문서를 확인해보면 좋을 것이다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글