자바스크립트 SPA 구현

Jemin·2023년 4월 19일
0

지식 공유

목록 보기
1/53
post-thumbnail
post-custom-banner

어제 저녁부터 이틀간 자바스크립트만을 사용한 SPA에 대해 직접 구현해보고 개념등을 학습했다.
사실 실무에서는 React와 같은 프레임워크를 사용해서 작업하지만 과제 구현 테스트에서 자바스크립트만을 사용한 화면 구현을 자주 하기 때문에
기본적인 라우팅 개념부터 잡고가려고 직접 구현해보았다.

자바스크립트 SPA란?

일단 SPA에 대한 기초적이고 기본적인 개념을 이해하기 위해 구글링을 했는데 마침 좋은 문서가 있어서 해당 페이지를 정독하고 모르는 부분들도 따로 찾아보았다.
SPA & Routing
요약해서 정리하자면 SPA는 기본적으로 웹 애플리케이션에 필요한 모든 정적 리소스를 최초 접근시 단 한번만 다운로드하고 이후 새로운 페이지 요청 시, 페이지 갱신에 필요한 데이터만을 JSON으로 전달받아 페이지를 갱신하여 전체적인 트래픽 감소와 전체 페이지를 다시 렌더링하지 않고 변경되는 부분만을 갱신하므로 새로고침이 필요하지 않아서 사용자 경험을 제공할 수 있다.

SPA의 핵심 가치는 사용자 경험(UX)향상에 있다고 한다.

SPA에도 장단점이 존재하는데

초기 구동 속도
SPA는 웹 애플리케이션에 필요한 모든 정적 리소스를 최초 접근시 단 한번만 다운로드하기 떄문에 초기 구동 속도가 상대적으로 느리다.

SEO(검색엔진 최적화) 이슈
SPA는 일반적으로 SSR방식이 아닌 자바스크립트 기반 비동기 모델의 CSR방식으로 동작한다. CSR은 일반적으로 데이터 패치 요청을 통해 서버로부터 데이터를 응답받아 뷰를 동적으로 생성하는데 이때 브라우저 주소창의 URL이 변경되지 않는다. 이는 사용자 방문 history를 관리할 수 없음을 의미하고 SEO 이슈의 발생원인이기도 하다.
하지만 SPA는 정보 제공을 위한 웹페이지보다는 애플리케이션에 적합한 기술이므로 SEO 이슈는 심각한 문제로 취급할 수 없다고 생각할 수 있지만 블로그와 같이 애플리케이션의 경우 SEO는 무시할 수 없다.

위와 같은 장단점 때문에 애플리케이션의 상황을 고려하여 적절한 방법을 선택할 필요가 있다.

Routing

라우팅이란 출발지에서 목적지까지의 경로를 결정하는 기능이다. 애플리케이션의 라우팅은 사용자가 태스크를 수행하기 위해 어떤 화면에서 다른 화면으로 전환하는 내비게이션을 관리하기 위한 기능을 의미한다.
간단하게 사용자가 요청한 URL 또는 이벤트(클릭같은)를 해석하고 새로운 페이지로 전환하기 위해 필요한 데이터를 서버에 요청하고 페이지를 전환하기 위한 일련의 행위를 말한다.

브라우저가 화면을 전환하는 경우는 다음과 같다.
1. 브라우저의 주소창에 URL을 입력하면 해당 페이지로 이동한다.
2. 웹페이지의 링크(a tag)를 클릭하면 해당 페이지로 이동한다.
3. 브라우저의 뒤로가기 또는 앞으로가기 버튼을 클릭하면 사용자 기록(history)의 뒤 또는 앞으로 이동한다. history관리를 위해서는 각 페이지는 브라우저의 주소창에서 구별할 수 있는 유일한 URL을 소유해야 한다.

이 라우팅 기능을 위에 링크해둔 글에서는 4가지 방식으로 구분한다.
1. 전통적 링크 방식 (a tag)
2. ajax 방식
3. hash 방식
4. pjax 방식

SPA 구현하기

나는 여기에 없는 history API를 사용하여 라우팅 기능을 구현해보기로 했다.
그동안 코딩테스트만 풀고 CS공부만 했었어가지고 자바스크립트 구현에 대한 지식이 많이 없어서 블로그를 참고해서 구현하였다.
Vanilla JavaScript로 SPA 구현하기

history API란?

일단 구현하기 앞서 내가 무엇을 구현할지는 이해했고
이제 내가 사용할 도구에 대해서 알아볼 필요가 있다고 생각했다.
검색하면 제일 위에 나오는 MDN부터 정독했다.
history API[MDN]

스택 자료구조를 사용하여 사용자 기록(브라우저 세션 기록)을 저장하고 해당 스택을 조작하여 앞뒤로 이동할 수 있게 도와주는 API라고 이해했다.

구현할 때는 PopStateEvent와 PushStateEvent를 사용하는데 MDN만 보고는 이해가 잘 가지않아 다른 문서를 더 찾아보기로 했다.

History.pushState()[MDN]
같은 MDN 문서에 해당 이벤트에 대한 자세한 설명이 나와있었다.

해당 메서드는 브라우저의 세션 기록 스택에 상태를 추가하는 메서드라고 한다.

history.pushState() 에는 3가지 매개변수를 넘겨줄 수 있는데
1. state = 브라우저 이동 시 넘겨줄 데이터, popState에서 받아서 원하는 처리 가능 (없으면 null)
2. title = 변경할 브라우저 제목 (없으면 null)
3. url = 변경할 브라우저 URL

popstate[MDN]
PopStateEvent도 MDN 문서를 통해 찾아보았다.
popstate는 브라우저에서 뒤로가기 버튼을 클릭했을 경우 발생하는 이벤트이다.
스택 자료구조를 사용하기 때문에 pop을 사용해서 맨 위의 데이터를 가져오는 메서드라고 생각된다.
popstate을 통해 반환되는 값으로 pushState()에서 매개변수로 넘겨준 데이터들을 가져오는 것 같다.

history에 대한 기초적인 개념은 이해한 것 같으니 이제 디렉토리 구조부터 알아보자.

0. 디렉토리 구조


디렉토리는 페이지 구동을 위한 서버와 프론트엔드를 나눴고
프론트엔드 폴더안에 static 폴더를 생성하여 정적인 파일들을 관리하였다.

1. index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Single Page App</title>
  </head>
  <body>
    <nav class="nav">
      <a href="/" class="nav_item" data-link>Dashboard</a>
      <a href="/posts" class="nav_item" data-link>Posts</a>
      <a href="/settings" class="nav_item" data-link>Settings</a>
    </nav>

    <div id="app"></div>

    <script type="module" src="/static/js/index.js"></script>
  </body>
</html>

script 삽입 시 type을 module로 설정한 이유는, es6의 import/export 문법을 사용하기 위해서라고 한다. 자세한 내용은 잘 모르니 나중에 시간을 내서 자세하게 찾아보기로 했다.

이렇게 보니 React와 비슷한 구조로 보인다.

2. server.js

서버 구동을 위해 express를 설치하여 사용했다.
패키지 관리를 위해 우선 package.json 파일을 생성해주었다.

npm init

그리고 express를 설치

npm install express

자동으로 package-lock.json과 node-modules가 생성된다.

서버는 자세하게 알지 못해서 일단 블로그에 나온 코드를 따라서 작성했다.
그래도 이해한 부분들은 주석으로 작성해두었다.

// express 모듈 불러오기
const express = require("express");
const path = require("path");

// express 사용
const app = express();

// Server.js의 실행경로 + "/static"을 localhost:port/static으로 마운트
// middleware 설정
// '/static'으로 시작되는 경로로 접속 시 frontend/static이 기본 고정 경로가 된다.
app.use(
  "/static",
  express.static(path.resolve(__dirname, "frontend", "static"))
);

// get요청이 오면 frontend/index.html 파일을 읽고 내용을 클라이언트로 전송한다.
// Single Page이기 때문에 모든 경로에서 index.html을 불러온다.
app.get("/*", (req, res) => {
  res.sendFile(path.resolve("frontend", "index.html"));
});

// 포트번호 5000에서 서버를 실행한다.
app.listen(process.env.PORT || 5000, () => console.log("Server running ...."));

그냥 node server.js를 사용하면 매번 코드가 변경될 때마다 서버를 껐다가 켜야하는 불편함이 있어서 nodemon을 설치했다.

npm install nodemon --save-dev

실행

nodemon server.js

그런데 위의 명령어로 실행하면 에러가 발생한다.
나는 bash 터미널을 사용하는데 bash에서 nodemon이라는 명령을 찾을 수 없다는 것이었다.
간단하게 구글링해보니 앞에 npx를 붙여줘야 한다고 한다.

npx nodemon server.js

서버를 실행하고 브라우저 주소창에 http://localhost:5000/를 입력하면 접속된다.

3. index.js

이제 자바스크립트 파일을 연결해야하는데 미들웨어를 설정하지 않으면 에러가 발생한다고 한다.
앞서 말했듯이 나는 서버에 대해서 잘 모르기 때문에 미들웨어에 관해서도 나중에 시간을내서 알아봐야겠다..(공부해야할 것들이 너무 많다..😭)
나는 이미 미들웨어까지 위 코드에서 설정을 했기 때문에 바로 넘어가겠다.

index.js에서는 라우팅 기능을 구현했다.

import Dashboard from "../views/Dashboard.js";
import Posts from "../views/Posts.js";
import Settings from "../views/Settings.js";
import NotFound from "../views/NotFound.js";

// 페이지 전환 함수
const navigateTo = (url) => {
  history.pushState(null, null, url);
  router();
};

// async를 사용하는 이유는, 어떤 페이지에서는 렌더링 전에 서버 단 요청을 먼저 받아야하는 경우가 있기 때문
const router = async () => {
  // 각 route의 경로와 현재 페이지 확인용 콘솔
  const routes = [
    { path: "/", view: Dashboard },
    { path: "/posts", view: Posts },
    { path: "/settings", view: Settings },
    { path: "/404", view: NotFound },
  ];

  // 현재 route와 현재 페이지 경로가 일치하는지 테스트
  const routeMatches = routes.map((route) => {
    return {
      route: route,
      isMatch: location.pathname === route.path,
    };
  });

  // 유효한 경로인지 확인
  let match = routeMatches.find((routeMatche) => routeMatche.isMatch);

  // 유효하지 않다면 404 페이지로 이동
  if (!match) {
    match = {
      route: routes[routes.length - 1],
      isMatch: true,
    };
  }

  // 활성화된 view 가져오기
  const view = new match.route.view();

  // #app element에 활성화된 view의 HTML 삽입
  document.querySelector("#app").innerHTML = await view.getHtml();
};

// 뒤로가기나 새로고침했을 때 router도 그 페이지에 맞게 동작
window.addEventListener("popstate", router);

// DOMContentLoaded => 초기 HTML 문서를 완전히 불러오고 분석했을 떄 발생
document.addEventListener("DOMContentLoaded", () => {
  // 클릭 이벤트 발생 시 해당 target이 "data-link" attribute가 있다면 페이지 이동 함수 실행
  document.body.addEventListener("click", (e) => {
    if (e.target.matches("[data-link]")) {
      e.preventDefault();
      navigateTo(e.target.href);
    }
  });
  router();
});

history API를 사용하여 사용자가 페이지를 전환하기위해 클릭한다면 pushState 메서드를 통해
해당 a teg의 URL 주소를 스택에 저장한다.
history.pushState를 사용하여 새로고침 없이 URL도 바꿔주고 루트 엘리먼트의 콘텐츠도 바꿔준다.
그리고 사용자가 뒤로가기를 눌렀을 때를 PopStateEvent로 감지하여 router함수를 실행한다.

4. 콘텐츠 페이지 생성

일단 AbstractView.js라는 파일로 모든 페이지의 초기 세팅을 해준다.

AbstractView.js

// 페이지 초기 세팅
export default class {
  constructor() {}

  // 페이지 타이틀
  setTitle(title) {
    document.title = title;
  }

  // 뿌려질 HTML
  async getHtml() {
    return "";
  }
}

class를 사용하여 모듈화했다.
이후 생성되는 페이지들에 import하여 extends로 자식 클래스로 만든다.

Dashboard.js

import AbstractView from "./AbstractView.js";

export default class extends AbstractView {
  constructor() {
    super();
    this.setTitle("Dashboard");
  }

  async getHtml() {
    return `
        <h1>Welcome!</h1>
        <p>This is Dashborad page.</p>
        <a href="/posts" data-link>
            View recent posts
        </a>
    `;
  }
}

Posts.js

import AbstractView from "./AbstractView.js";

export default class extends AbstractView {
  constructor() {
    super();
    this.setTitle("Posts");
  }

  async getHtml() {
    return `
            <h1>Posts</h1>
            <p>You're viewing the posts!</p>
        `;
  }
}

Settings.js

import AbstractView from "./AbstractView.js";

export default class extends AbstractView {
  constructor() {
    super();
    this.setTitle("Settings");
  }

  async getHtml() {
    return `
        <h1>Posts</h1>
        <p>You're viewing the Settings!</p>
    `;
  }
}

NotFound.js

import AbstractView from "./AbstractView.js";

export default class extends AbstractView {
  constructor() {
    super();
    this.setTitle("Posts");
  }

  async getHtml() {
    return `
        <p>404 Not Found!</p>
    `;
  }
}

에러 발생

Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.

위 코드들은 이미 에러를 처리한 상황이라 문제가 없지만
구현 진행 중에 import 관련 에러가 발생했었다.
해당 에러에 대한 자세한 내용은 밑의 링크에 작성해두었다.
에러 해결 과정

마무리

이렇게 SPA 라우팅 기능을 구현하는데 성공했다.🎉
처음에는 막무가내로 따라하는 식으로 구현하면서 중간중간 이해가 가지 않거나 모르는 부분들은
구현을 잠깐 멈추고 찾아보는 식으로 진행했다.
그동안 코딩테스트랑 CS공부만 주구장창해서 그런지 오랜만에 구현하는게 재밌기도 했고
프레임워크를 사용하지 않고 오직 자바스크립트로만 구현해서 그런지 어려운 부분이 많았다.
서버에 대해 공부가 더 필요하고 앞으로 구현 과제에 대한 학습도 꾸준히 해야겠다.

해당 프로젝트 깃허브 링크

추가적인 학습이 필요한 부분📚

  • script type = module 사용 이유
  • middle ware에 대한 개념
profile
경험은 일어난 무엇이 아니라, 그 일어난 일로 무엇을 하느냐이다.
post-custom-banner

0개의 댓글