My Boundary As Much As I Experienced

전통적 링크 방식 → SPA 라우팅 발전 과정 본문

FrontEnd/Javascript(Vanilla)

전통적 링크 방식 → SPA 라우팅 발전 과정

Bumang 2024. 3. 4. 20:27

1. 전통적 방식의 routing (WEB 1.0)

1990년, 아날로그의 책처럼 일련의 순서대로 된 것이 아닌 가상의 공간에서 텍스트에 새로운 문서에 대한 링크가 존재하는 개념의 하이퍼텍스트가 탄생하였다. 그러나 이때의 web은 읽기 전용의 공간이며 뉴스나 간단한 홍보글, 카탈로그 등을 올리는 정도의 역할만 하였다.

정보 제공자와 사용자가 완전히 분리되어 있었고, 사용자가 정보를 생성하는 것은 불가능했다.

 

전통적 방식의 routing

link tag(<a href="/service.html">Service</a> 등)을 클릭하면 href 어트리뷰트 값인 리소스 경로가 URL의 path에 추가되어 주소창에 나타나고 해당 리소스를 서버에 요청한다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>SPA-Router - Link</title>
    <link rel="stylesheet" href="css/style.css" />
  </head>
  <body>
    <nav>
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/service.html">Service</a></li>
        <li><a href="/about.html">About</a></li>
      </ul>
    </nav>
    <section>
      <h1>Home</h1>
      <p>This is main page</p>
    </section>
  </body>
</html>

 

이때 서버는 html로 화면을 표시하는데 부족함이 없는 완전한 리소스를 클라이언트에 응답한다. 이를 서버 사이드 렌더링(SSR)이라 한다. 브라우저는 서버가 응답한 html을 응답받아 렌더링한다. 이때 응답받은 html로 전체 페이지를 다시 렌더링하게 되므로 새로고침이 발생한다.

 

Same Origin Policy를 준수하기 위해 2번 요청된다. 하여튼 최종 response를 받으면 Page Reload가 발생한다.

 

 

CGI(Common Gate Interface) 통신이 가능해지다, 1994

정적인 정보만 담고 있는 문서에 사용자가 일정한 정보를 입력해서 전달하면 그 정보를 바탕으로 서버에서 동적으로 페이지를 생성할 수 있게 된다. PHP를 시작으로 여러가지 서버 스크립트 언어가 만들어지면서 본격적인 홈페이지 서비스들이 만들어지게 된다.

이때부터 사용자는 정보를 서버로 보내고 데이터를 보관할 수 있게 되며, 홈페이지에 카운터를 달거나 방명록 등을 달 수 있게 되었다.

이후 방명록은 게시판으로 발전했고 독자적인 컨텐츠를 수용할 수 있는 커뮤니티로 발전하게 된다.

 

Javascript의 탄생, 1995

서버뿐만이 아니라 브라우저에서 실행이 가능한 스크립트 언어가 필요해졌고 자바스크립트가 만들어졌다. 이제 서버와 통신을 하기 전에 화면을 조작하거나 서버에 데이터를 전달하기전에 검증을 하는 등의 기능을 할 수 있게 되었다.

 

 

 

 

2. ajax 방식

전통적 링크 방식은 현재 페이지에서 수신된 html로 화면을 전환하는 과정에서 전체 페이지를 새롭게 렌더링하게 되므로 새로고침이 발생한다. 간단한 웹페이지라면 문제될 것이 없겠지만 복잡한 웹페이지의 경우, 요청마다 중복된 HTML과 CSS, JavaScript를 매번 다운로드해야하므로 속도 저하의 요인이 된다.

전통적 링크 방식의 단점을 보완하기 위해 등장한 것이 ajax(Asynchronous JavaScript and XML)이다. ajax는 자바스크립트를 이용해서 비동기적(asynchronous)으로 서버와 브라우저가 데이터를 교환할 수 있는 통신 방식을 의미한다.

 

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>SPA-Router - ajax</title>
    <link rel="stylesheet" href="css/style.css" />
    <script type="module" src="js/index.js"></script>
  </head>
  <body>
    <nav>
      <ul id="navigation">
        <li><a href="/">Home</a></li>
        <li><a href="/service">Service</a></li>
        <li><a href="/about">About</a></li>
      </ul>
    </nav>
    /* 실제 렌더될 div가 비어있다. */
    <div id="root">Loading...</div>
  </body>
</html>
// TODO: ajax 요청은 주소창의 url을 변경시키지 않으므로 history 관리가 되지 않는다.
$navigation.onclick = e => {
  if (!e.target.matches('#navigation > li > a')) return;
  e.preventDefault();
  const path = e.target.getAttribute('href');
  render(path);
};

// TODO: 주소창의 url이 변경되지 않기 때문에 새로고침 시 현재 렌더링된 페이지가 아닌 루트 페이지가 요청된다.
window.addEventListener('DOMContentLoaded', () => render('/'));

// 데이터를 json 방식으로 가져옴
const fetchData = async url => {
  const res = await fetch(url);
  const json = await res.json();
  return json;
};

preventDefault() 메소드로 새로고침을 막고, JSON 파일을 받아 활용한다. 새로고침을 발생시키지 않고 요청을 받을 수 있게 되었다.

 

ajax 요청은 주소창의 URL을 변경시키지 않는다. 이는 브라우저의 뒤로가기, 앞으로가기 등의 history 관리가 동작하지 않음을 의미한다. 따라서 history.back(), history.go(n) 등도 동작하지 않는다. 주소창의 URL이 변경되지 않기 때문에 새로고침을 해도 언제나 첫페이지가 다시 로딩된다. 동일한 하나의 URL로 동작하는 ajax 방식은 SEO 이슈에서도 자유로울 수 없다.

 

3 Hash 방식

ajax 방식은 불필요한 리소스 중복 요청을 방지할 수 있고 새로고침이 없는 사용자 경험을 구현할 수 있다는 장점이 있지만 history 관리가 되지 않는 단점이 있다. 이를 보완한 방법이 Hash 방식이다.

Hash 방식은 URI의 fragment identifier(#service)의 고유 기능인 앵커(anchor)를 사용한다. fragment identifier는 hash mark 또는 hash라고 부르기도 한다.

  ...
  </head>
  <body>
    <nav>
      <ul>
        <li><a href="#">Home</a></li> /* a링크에 '#'가 추가 */
        <li><a href="#service">Service</a></li>
        <li><a href="#about">About</a></li>
      </ul>
    </nav>
    <div id="root">Loading...</div>
  </body>
</html>

 

위 예제를 살펴보면 link tag(<a href="#service">Service</a> 등)의 href 어트리뷰트에 hash를 사용하고 있다. 즉, 내비게이션이 클릭되면 hash가 추가된 URI가 주소창에 표시된다. 단, URL이 동일한 상태에서 hash만 변경되면 브라우저는 서버에 어떠한 요청도 하지 않는다. 즉, URL의 hash는 변경되어도 서버에 새로운 요청을 보내지 않으며 따라서 페이지가 갱신되지 않는다. 

 

hash는 요청을 위한 것이 아니라 fragment identifier(#service)의 고유 기능인 앵커(anchor)로 웹페이지 내부에서 이동을 위한 것이기 때문이다. 또한 hash 방식은 서버에 새로운 요청을 보내지 않으며 따라서 페이지가 갱신되지 않지만 페이지마다 고유의 논리적 URL이 존재하므로 history 관리에 아무런 문제가 없다.

 

...
const render = async () => {
  try {
    // url의 hash를 취득
    const hash = window.location.hash.replace('#', '');
    const component = routes.find(route => route.path === hash)?.component || NotFound;
    $root.replaceChildren(await component());
  } catch (err) {
    console.error(err);
  }
};

// 네비게이션을 클릭하면 url의 hash가 변경되기 때문에 history 관리가 가능하다.
// 단, url의 hash만 변경되면 서버로 요청은 수행하지 않는다.
// url의 hash가 변경하면 발생하는 이벤트인 hashchange 이벤트를 사용하여 hash의 변경을 감지하여 필요한 ajax 요청을 수행한다.
// hash 방식의 단점은 url에 /#foo와 같은 해시뱅(HashBang)이 들어간다는 것이다.
window.addEventListener('hashchange', render);

 

hash 방식은 url의 hash가 변경하면 발생하는 이벤트인 hashchange 이벤트를 사용해 hash의 변경을 감지하고 url의 hash를 취득해 필요한 ajax 요청을 수행한다.

hash 방식은 과도기적 기술이다. HTML5의 History API인 pushState가 모든 브라우저에서 지원이 된다면 해시뱅은 사용하지 않아도 되지만 현재 pushState는 일부의 브라우저(IE 10 이상)에서만 지원이 가능하다.

 

또 다른 문제는 SEO 이슈이다. 웹 크롤러는 검색 엔진이 웹사이트의 콘텐츠를 수집하기 위해 HTTP와 URL 스펙(RFC-2396같은)을 따른다. 이러한 크롤러는 JavaScript를 실행시키지 않기 때문에 hash 방식으로 만들어진 사이트의 콘텐츠를 수집할 수 없다. 구글은 해시뱅을 일반적인 URL로 변경시켜 이 문제를 해결한 것으로 알려져 있지만 다른 검색 엔진은 hash 방식으로 만들어진 사이트의 콘텐츠를 수집할 수 없을 수도 있다.

 

 

 

4. pjax 방식

hash 방식의 가장 큰 단점은 SEO 이슈이다. 이를 보완한 방법이 HTML5의 History API인 pushState popstate 이벤트를 사용한 pjax(pushState + ajax) 방식이다. pushState와 popstate은 IE 10 이상에서 동작한다.

/* ... */
  </head>
  <body>
    <nav>
      <ul id="navigation">
        <li><a href="/">Home</a></li>
        <li><a href="/service">Service</a></li>
        <li><a href="/about">About</a></li>
      </ul>
    </nav>
    <div id="root">Loading...</div>
  </body>
</html>

 

pjax 방식은 기본적인 url방식이 쓰였다. pjax 방식은 내비게이션 클릭 이벤트를 캐치하고 preventDefault 메서드를 사용해 서버로의 요청을 방지한다. 이후, href 어트리뷰트에 path을 사용하여 ajax 요청을 하는 방식이다.

이때 ajax 요청은 브라우저 주소창의 URL을 변경시키지 않아 history 관리가 불가능하다. 이때 사용하는 것이 pushState 메서드이다. pushState 메서드는 주소창의 URL을 변경하고 URL을 history entry로 추가하지만 서버로 HTTP 요청을 하지는 않는다.

// TODO: path를 상태로 관리
const render = async path => {
  // $navigation의 a 요소를 클릭하면 path(a 요소의 href)가 전달된다.
  // 하지만 웹페이지가 처음 로딩되거나 앞으로/뒤로 가기 버튼을 클릭하면 path를 전달하지 않는다.
  // 이때 window.location.pathname를 키로 routes에서 컴포넌트를 결정해 뷰를 전환한다.
  const _path = path ?? window.location.pathname;

  try {
    const component = routes.find(route => route.path === _path)?.component || NotFound;
    $root.replaceChildren(await component());
  } catch (err) {
    console.error(err);
  }
};

$navigation.addEventListener('click', e => {
  if (!e.target.matches('#navigation > li > a')) return;

  /**
   * 네비게이션을 클릭하면 주소창의 url이 변경되므로 HTTP 요청이 서버로 전송된다.
   * preventDefault를 사용하여 이를 방지하고 history 관리를 위한 처리를 실행한다.
   */
  e.preventDefault();

  // 이동할 페이지의 path
  const path = e.target.getAttribute('href');

  // 현재 페이지와 이동할 페이지가 같으면 이동하지 않는다.
  if (window.location.pathname === path) return;

  // pushState는 주소창의 url을 변경하지만 HTTP 요청을 서버로 전송하지는 않는다.
  window.history.pushState(null, null, path);
  render(path);
});

/**
 * pjax 방식은 hash를 사용하지 않으므로 hashchange 이벤트를 사용할 수 없다.
 * popstate 이벤트는 pushState에 의해 발생하지 않고 앞으로/뒤로 가기 버튼을 클릭하거나
 * history.forward/back/go(n)에 의해 history entry가 변경되면 발생한다.
 * 앞으로/뒤로 가기 버튼을 클릭하면 window.location.pathname를 참조해 뷰를 전환한다.
 */
window.addEventListener('popstate', () => {
  console.log('[popstate]', window.location.pathname);
  render();
});

pjax 방식에서 사용하는 history.pushState 메서드는 주소창의 url을 변경하지만 HTTP 요청을 서버로 전송하지는 않는다. 따라서 페이지가 갱신되지 않는다. 하지만 페이지마다 고유의 URL이 존재하므로 history 관리에 아무런 문제가 없다. 또한 hash를 사용하지 않으므로 SEO에도 문제가 없다.