JavaScript - SPA & Router

이소라·2022년 11월 11일
0

JavaScript

목록 보기
10/22

1. SPA

  • Single-Page Application(SPA)
    • 웹 브라우저에서 새 페이지 전체를 불러오지 않고, 웹 서버로부터 받은 새 정보를 사용하여 현재 웹 페이지를 동적으로 다시 작성하면서 사용자들과 상호 작용하는 웹 어플리케이션 또는 웹 사이트
  • 장점
    • 배포가 간단함
    • 새 페이지 요청시 필요한 데이터만 전달받아 페이지를 갱신하므로 전체적인 트래픽을 감소시킬 수 있음
    • 새로고침이 발생하지 않아 네이티브 앱과 유사한 사용자 경험을 제공할 수 있음
  • 단점
    • 초기 구동 속도가 느림
      • SPA는 웹 어플리케이션에 필요한 모든 정적 리소스를 최초 접근시 단 한번 다운로드함
    • SEO 문제
      • SPA는 일반적으로 JavaScript 기반 비동기 모델의 클라이언트 사이드 렌더링(CSR) 방식으로 동작함
      • CSR은 일반적으로 데이터 fetch 요청을 통해 서버로부터 데이터를 응답받아 뷰를 동적으로 생성하는데 이때 브라우저 주소창의 URL이 변경되지 않음
      • 이는 사용자 방문 history를 관리할 수 없음을 의미하며 SEO 이슈의 발생 원인이됨
      • Angular나 React 등의 SPA 프레임워크는 서버 사이드 렌더링을 지원하는 기능이 이미 존재함
      • 크롬 등의 현대 브라우저는 SPA의 SEO 문제를 해결 하고 있음



2. Routing

  • Routing

    • 출발지에서 목적지까지의 경로를 결정하는 기능
  • Routing in App

    • 사용자가 일을 수행하기 위해 한 화면(view)에서 다른 화면으로 전환하는 내비게이션을 관리하기 위한 기능
    • 사용자가 요청한 URL 또는 이벤트를 해석하고 새로운 페이지로 전환하기 위해 필요한 데이터를 서버에 요청하고 페이지를 전환하는 일련의 행위
  • 브라우저가 화면을 전환하는 경우

    • 브라우저의 주소창에 URL을 입력하면 해당 페이지로 이동함
    • 웹페이지의 링크(a tag)를 클릭하면 해당 페이지로 이동함
    • 브라우저의 뒤로 가기 또는 앞으로 가기 버튼을 클릭하면 사용자 방문 기록(history)의 뒤 또는 앞으로 이동함
      • history 관리를 위해서는 각 페이지는 브라우저의 주소창에서 구별할 수 있는 유일한 URL을 소유해야 함



3. SPA & Routing

  • Routing 방식 발전 과정
    1. 전통적인 링크 방식
    2. ajax 방식
    3. hash 방식
    4. pjax 방식

3.1 정통적인 링크 방식

<!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>
  • 전통적인 링크 방식
    • link tag로 동작하는 기본적인 웹페이지의 동작 방식
      • link tag를 클릭하면 href attribute 값인 리소스 경로가 URL의 path에 추가되어 주소창에 나타남
      • 브라우저가 해당 리소스를 서버에 요청하면, 서버가 html 리소스를 응답함 : 서버 사이드 렌더링(SSR)
      • 브라우저는 서버가 응답한 html로 전체 페이지를 리렌더링하여 새로고침이 발생함

  • 전통적인 링크 방식의 장단점
    • 장점
      • JavaScript 도움 없이 응답받은 html만으로 렌더링 가능함
      • 각 페이지마다 고유의 URL이 존재하므로 history 관리 및 SEO 대응에 아무런 문제 없음
    • 단점
      • 요청마다 중복된 리소스를 응답받아야 함
      • 전체 페이지를 리렌더링하는 과정에서 새로고침이 발생하여 사용성이 좋지 않음

3.2 ajax 방식

  • ajax(Asynchronous JavaScript and XML)
    • JavaScript를 이용하여 비동기적으로 서버와 브라우저가 데이터를 통신할 수 있는 방식
    • 페이지 일부만 생신하여 리렌더링 효과를 봄

<!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 id="root">Loading...</div>
  </body>
</html>
  • ajax 방식
    • a tag의 href attribute에 path를 사용하고 있음
    • 내비게이션이 클릭되면 path가 추가된 URL이 서버로 요청되지만, preventDefault 메서드를 사용하여 서버로의 요청을 방지함
    • href attribute에 path를 사용하여 리소스를 요청하고, 요청된 리소스(html, json 등)가 등답되면 클라이언트에서 특정 요소에 응답받은 데이터를 기반으로 html을 생성해 추가함
// index.js
import { Home, Service, About, NotFound } from './components.js';

const $root = document.getElementById('root');
const $navigation = document.getElementById('navigation');

const routes = [
  { path: '/', component: Home },
  { path: '/service', component: Service },
  { path: '/about', component: About },
];

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

$navigation.onclick = e => {
  if (!e.target.matches('#navigation > li > a')) return;
  e.preventDefault();
  const path = e.target.getAttribute('href');
  render(path);
};

window.addEventListener('DOMContentLoaded', () => render('/'));
// components.js
const createElement = domString => {
  const $temp = document.createElement('template');
  $temp.innerHTML = domString;
  return $temp.content;
};

const fetchData = async url => {
  const res = await fetch(url);
  const json = await res.json();
  return json;
};

export const Home = async () => {
  const { title, content } = await fetchData('/api/home');
  return createElement(`<h1>${title}</h1><p>${content}</p>`);
};

export const Service = async () => {
  const { title, content } = await fetchData('/api/service');
  return createElement(`<h1>${title}</h1><p>${content}</p>`);
};

export const About = async () => {
  const { title, content } = await fetchData('/api/about');
  return createElement(`<h1>${title}</h1><p>${content}</p>`);
};

export const NotFound = () => createElement('<h1>404 NotFound</p>');
  • ajax의 장단점
    • 장점
      • 불필요한 리소스의 중복 요청을 방지 가능함
      • 전체 페이지를 리렌더링하지 않고 필요한 일부만 갱신하면 되므로 빠른 성능과 향상된 사용자 경험을 제공함
    • 단점
      • 주소창의 URL이 변경되지 않으므로, history 관리가 동작하지 않음
        • 주소창의 URL이 변경되지 않으므로, 새로고침을 해도 언제나 첫페이지가 다시 로딩됨
        • 동일한 하나의 URL로 동작하므로 SEO 이슈가 발생함

3.3 Hash 방식

  • Hash 방식
    • URL의 fragment identifier(#)의 고유 기능인 앵커(anchor)를 사용함
      • fragment identifier : hash mark 또는 hash라고 부르기도 함
<!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 - Hash</title>
    <link rel="stylesheet" href="css/style.css" />
    <script type="module" src="js/index.js"></script>
  </head>
  <body>
    <nav>
      <ul>
        <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>
  • Hash 방식
    • a link의 href attribute에 hash를 사용하고 있음
    • 내비게이션을 클릭하면 추가된 URI가 주소창에 표시됨
      • URI가 동일한 상태에서 hash만 변경되면, 브라우저는 서버에 새로운 요청을 보내지 않고 페이지가 갱신되지 않음
      • hash는 fragment identifier의 고유 기능인 앵커로 웹페이지 내부에서 이동을 위한 것임
    • 서버에 새로운 요청을 보내지 않으며 페이지가 갱신되지 않지만 페이지마다 고유의 논리적 URL이 존재하므로 history 관리에 아무런 문제 없음
    • url의 hash가 변경하면 발생하는 hashchange 이벤트를 사용하여 hash의 변경을 감지하고 url의 hash를 취득하여 필요한 ajax 요청을 수행함
// index.js
// components.js는 위와 동일함
import { Home, Service, About, NotFound } from './components.js';

const $root = document.getElementById('root');

const routes = [
  { path: '', component: Home },
  { path: 'service', component: Service },
  { path: 'about', component: About },
];

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);
  }
};

window.addEventListener('hashchange', render);

window.addEventListener('DOMContentLoaded', render);
  • hash 방식의 단점
    • url에 불필요한 #이 들어감
      • 일반적으로 hash 방식을 사용할 때 해쉬뱅 #!을 사용하기도 함
    • SEO 이슈 발생
      • 웹 크롤러가 JavaScript를 실행시키지 않기 떄문에 hash 방식으로 만들어진 사이트의 콘텐츠를 수집할 수 없음

3.4 pjax 방식

  • pjax 방식
    • HTML5의 history API인 pushStatepopstate 이벤트를 사용한 pjax(pushState + ajax) 방식
      • pushState와 popstate는 IE 10 이상에서 동작함
<!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 - pjax</title>
    <link rel="stylesheet" href="css/style.css" />
    <script defer 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 id="root">Loading...</div>
  </body>
</html>
  • pjax 방식
    • a tag의 href attribute에 path를 사용하고 있음
    • 내비게이션이 클릭되면 path가 추가된 URL이 서버로 요청되지만 preventDefault 메서드를 사용하여 서버로의 요청을 방지함
    • 이후에 href attribute에 path을 사용하여 ajax 요청을 함
    • pushState 메서드를 사용하여 history 관리함
      • pushState 메서드는 주소창의 URL을 변경하여 URL을 history entry로 추가하지만 서버로 HTTP 요청을 하지 않음
        • SEO에 문제 없음
    • 새로고침시 주소창의 url이 변경되기 때문에 요청이 서버로 전달됨
      • 서버 렌더링 방식과 ajax 방식이 혼재되어 있는 방식으로 서버의 지원이 필요함
// index.js
// components.js는 위와 동일함
import { Home, Service, About, NotFound } from './components.js';

const $root = document.getElementById('root');
const $navigation = document.getElementById('navigation');

const routes = [
  { path: '/', component: Home },
  { path: '/service', component: Service },
  { path: '/about', component: About },
];

const render = async path => {
  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;

  e.preventDefault();

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

  if (window.location.pathname === path) return;

  window.history.pushState(null, null, path);
  render(path);
});

window.addEventListener('popstate', () => {
  console.log('[popstate]', window.location.pathname);
  render();
});

/**
 * 웹페이지가 처음 로딩되면 window.location.pathname를 확인해 페이지를 이동시킨다.
 * 새로고침을 클릭하면 현 페이지(예를 들어 localhost:5004/service)가 서버에 요청된다.
 * 이에 응답하는 처리는 서버에서 구현해야 한다.
 */

window.addEventListener('DOMContentLoaded', () => {
  render();
});
// server
const express = require('express');
const path = require('path');

const app = express();
const port = 5004;

app.use(express.static(path.join(__dirname, 'public')));

app.get('/api/:page', (req, res) => {
  const { page } = req.params;
  res.sendFile(path.join(__dirname, `/data/${page}.json`));
});

// 브라우저 새로고침을 위한 처리 (다른 route가 존재하는 경우 맨 아래에 위치해야 한다)
// 브라우저 새로고침 시 서버는 index.html을 전달하고 클라이언트는 window.location.pathname를 참조해 다시 라우팅한다.
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'public/index.html'));
});

app.listen(port, () => {
  console.log(`Server listening on http:/localhost:${port}`);
});

Conclusion

구분History 관리SEO 대응사용자 경험서버 레더링구현 난이도IE 대응
전통적 링크 방식OOXO간단
ajax 방식XXOX보통7 이상
hash 방식OXOX보통8 이상
pjax 방식OOO복잡10 이상



참고

0개의 댓글