바닐라JS로 SPA 만들기 - 라우터

Develop
2022-11-21

바닐라 자바스크립트로 SPA 만들기

2주라는 짧은 시간동안 나름대로 다 갖춘 커머스 서비스를 만들다 보니, 썩 마음에 드는 코드는 아닌 것 같다. 그래도 나처럼 바닐라 자바스크립트로 SPA 만들기를 처음 시도해보는 사람들을 위해 기록을 남기고자 한다.

개인적으로 이 프로젝트를 통해 리액트가 어떻게 동작하는지 어렴풋이 엿볼 수 있어서 굉장히 좋은 경험이었다.

그리고 약간 벼락치기로 node express mongoDB를 공부해볼 수 있는 아주아주 좋은 경험...?

막히는 부분이 많이 나왔는데, 부분부분 구글링과 영어로 된 유튜브 영상을 보며 해결해나갔으며, 홍시의 블로그가 엄청난 도움이 되었다!🙏


페이지가 하나뿐인 SPA (with. nodejs)

만약 유저가 이상조닷컴의 메인페이지에서, 마이페이지로 이동한다면 어떻게 될까?

개발자는 각 페이지에 해당하는 html을 작성해두고, 서버에서 /mypageget요청이 오면 res.sendFile(__dirname + '/mymage.html');로 마이페이지에 해당하는 html을 보내줄 것이다. 마찬가지로 로그인은 res.sendFile(__dirname + '/login.html'); 장바구니는 res.sendFile(__dirname + '/cart.html');

이런 방식이 MPA다. 페이지를 여러개 만들어 두고, 페이지 요청이 오면 해당하는 html을 보여주면 끝!

반면에 SPA를 구현하려면 어떻게 해야할까? 먼저 index.html을 하나 만든다. 우리는 싱글 페이지, 그러니까 페이지가 하나만 존재하기 때문에 다른 html을 만드는 일은 없을 것이다. 그럼 이제 어떤 경로로 접근하든 index.html만 주구장창 보여주면 된다.

그리고, index.html을 포함하여 프론트엔드에서 관리할 view를 전부 ./frontend 경로에 두기 위해서 정적 폴더 경로를 지정해주면 SPA를 만들기 위한 기초 작업은 끝난다.

// backend/app.js
 
app.use("/", express.static(path.resolve(__dirname, "../frontend")));
 
app.get("/*", (req, res) => {
  res.sendFile(path.resolve(__dirname, "../frontend", "index.html"));
});

router 구현하기

자, 유저가 uri에 뭘 입력해도 우리는 무지성으로 index.html만 계속 보여주는 말 그대로 페이지가 하나뿐인 앱을 만들었다.

근데 우리는 uri가 변경되면 해당하는 컨텐츠를 담아서 페이지를 다시 그려줘야 한다.

스케치북에 그림을 그려보자. 다 그리고 나서 다른 그림을 그리고 싶다면 어떻게 해야할까? 페이지를 넘겨서 다른 종이에다 열심히 그릴 것이다.

반면에 화이트보드의 경우는 어떨까? 새 화이트보드를 가져와서 그리는게 아니라, 기존에 그려져있던 그림을 쓱싹 지우고 다른 그림을 그릴 것이다. 즉, 화이트보드 판을 계속 재활용하는 구조다.

SPA에서 router를 구현하는 원리가 바로 이것이다. 유저가 이상조닷컴/mypage로 이동한다면, 우리는 index.html이라는 화이트보드에 그려져있던 기존 그림을 지우고 마이페이지에 해당하는 그림을 그려주면 된다.

1. index.html

자, 먼저 index.html을 조금 손보자.

// frontend/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>Document</title>
  </head>
  <body>
    <div id="app">
      <script type="module" src="/App.js"></script>
    </div>
  </body>
</html>

갓 생성한 따끈따끈한 index.html에다 App.js를 연결해준다.(App.js는 이제 만들거임) 이 App.js가 라우터 역할을 해줄 것이다. /mypage를 입력하면 마이페이지를 보여주고, /cart를 입력하면 장바구니를 보여주고... 이런 동작을 어떻게 구현할 수 있을까?

2. App.js

해당 경로의 route.view를 호출하는 App 함수 작성

// frontend/App.js
 
const routes = [
  { path: "/", view: ()=>{console.log("메인화면입니다.")} },
  { path: "/mypage", view: ()=>{console.log("마이페이지입니다.")} }
];
 
const App = async () => {
  
  const pageMatches = routes.map(route => {
    return {
      route: route,
      isMatch: window.location.pathname === route.path,
    };
  });
 
  let match = pageMatches.find(pageMatch => pageMatch.isMatch);
  console.log(match.route.view());
}
 
App();

routes는 페이지를 담고있는 배열이다. 각 페이지는 객체이며, pathview라는 프로퍼티를 갖는다. path경로, view보여줄 페이지 컴포넌트다. 다만 페이지에 해당하는 컴포넌트가 없으니 콘솔에다 찍어보는 것으로 대체하였다.

자, 유저가 이상조닷컴/mypage를 주소창에 입력했다고 생각하자. 우리는 주소창의 url-path 정보를 window.locationpathname 프로퍼티에서 얻을 수 있다. 이제 우리가 할 일은, 미리 작성해둔 routes 배열을 돌면서 window.location.pathname, 그러니까 유저가 이동하고자 하는 페이지의 경로가 routes 배열에 있는지 확인해주는 것이다.

 
// 유저는 주소창에 /mypage를 입력했다.
 
const pageMatches = routes.map(route => {
  return {
    route: route,
    isMatch: window.location.pathname === route.path,
    // window.location.pathname는 /mypage다.
  };
});
 
// 위 경우, pageMatches는 아래와 같다.
pageMatches = [
  {
    route: { path: "/", view: ()=>{console.log("메인화면입니다.")} },
    isMatch: false,
  },
  {
    route: { path: "/mypage", view: ()=>{console.log("마이페이지입니다.")} }
    isMatch: true,
    // window.location.pathname이 route.path와 동일하면 isMatch가 true다.
  },
];

이렇게 현재 유저가 방문한 경로를 확인한 pageMatches배열을 얻었다. 이제 우리가 할 일은, isMatchtrueroute.view를 호출하는 것이다.

let match = pageMatches.find(pageMatch => pageMatch.isMatch);
// pageMatches에서 isMatch가 true인 요소를 찾아서 match에 할당한다.
 
match.route.view(); // 마이페이지입니다.

이렇게 작성해주면 우리는 url path에 따라 원하는 동작을 실행하는 간단한 라우터를 완성했다. (잊지말고 App함수를 호출해야 한다.)

여기서 console.log가 아니라 특정 페이지 컴포넌트를 보여주면 진짜 라우터가 될 것이다.

history.pushState

이제부터는 각종 컴포넌트가 있다고 가정하고 작성해보겠다. 컴포넌트를 만들고 나서 다시 돌아와도 괜찮다.

자, 메인페이지에 마이페이지로 가기가 있다고 생각해보자. 코드는 아래와 같다. <a href="/mypage" >마이페이지로 가기</a>

클릭하면 어떻게 될까? 아래와 같다.

  1. <a>의 기본 기능인 새로고침
  2. <a>의 기본 기능인 href 속성에 작성된 경로로 이동
  3. 위에서 작성한 마이페이지의 route.view 메서드 호출

어쨌든 잘 되네! 완성!!...?

실제로 클릭해보면, 뭔가 이상하다. 우리는 SPA를 만들고 싶은데, <a>를 클릭하여 페이지를 이동하면 새로고침이 된다. 하지만 SPA는 페이지 이동시 새로고침이 되지 않는 특성을 가진다.

즉, 뭔가가 잘못됐다. 잘못된 부분을 history.pushState를 사용하여 바로잡아 주자.

먼저, 자꾸 새로고침 되는 것을 막아보자.

// frontend/App.js
 
...
BASE_URL = "http://localhost:5001";
 
document.addEventListener("DOMContentLoaded", () => { // 1️⃣
  
  document.body.addEventListener("click", e => { // 2️⃣
    const target = e.target.closest("a");
    if (!(target instanceof HTMLAnchorElement)) return;
    
    e.preventDefault();
    navigate(target.href);
  });
  
  App();
});

DOMContentLoaded 이벤트, 즉 html 문서가 불러와지면 1️⃣번 콜백함수를 실행할 것이다. 1️⃣번 콜백함수에는 body에서 click 이벤트가 발생하면 2️⃣번 콜백함수를 실행할 것(이벤트 위임)이라는 코드가 작성되어 있다.

2️⃣번 콜백함수를 살펴보자.

클릭이 발생한 e.target에서 가장 가까운 <a>를 찾아 target에 할당한다. closest은 해당하는 태그가 없다면 null을 반환한다. 그리고 target<a>가 맞는지 확인한다. 아니라면 함수를 종료한다.

왜 이런 과정을 거치는 것일까?

유저는 페이지 내에서 여기저기 아무 요소나 클릭할 수 있다. 만약 아무 의미 없는 <div> 하나를 메인페이지에 그려놨다고 생각해보자. <div>는 페이지 이동을 위해 만든 것이 아니다. 정말 말 그대로 아무 의미도 없는 예시일 뿐이다.

그럼에도 "body에서 click 이벤트가 발생하면 2️⃣번 콜백함수를 실행" 이라는 코드를 작성했기 때문에 어떤 요소를 클릭해도 일단 2️⃣번 콜백함수는 실행된다. 이런 의미없는 클릭을 제외하기 위한 코드라고 생각하면 될 것 같다.

또한, 이런 경우가 있을 수 있다.

<a href="/mypage">
  <div>
    <img />
    <span />
  </div>
</a>

이렇게 구성된 컴포넌트에서 img 영역을 클릭한다면 e.target에는 <img />가 할당된다. 근데 이번 이미지는 아까처럼 아무 의미없는 요소가 아니라 클릭되면 페이지를 이동시켜야 하는 요소다. 그래서 e.target.closest("a");를 통해 e.target에서 가장 가까운 <a>를 찾아 target에 할당한다.

이제 우리는 유저가 화면 어딘가를 클릭했을때, 클릭한 곳이 페이지 이동을 위한 것이라면 해당하는 <a>를 찾아서 target에 할당하고, 페이지 이동을 위한 것이 아니라면 무시할 수 있다.

이제 진짜진짜 새로고침을 막을 차례다.

// frontend/App.js
 
...
document.body.addEventListener("click", e => { // 2️⃣
  const target = e.target.closest("a");
  if (!(target instanceof HTMLAnchorElement)) return;
  
  e.preventDefault();
  navigate(target.href);
});
 

새로고침은 <a>의 기본 기능이다. 기본 기능은 e.preventDefault()로 간단하게 막을 수 있다. 작성해주자.

단, <a>의 기본 기능에는 href 속성으로 페이지를 이동하는 기능도 포함되어 있다. 따라서 e.preventDefault()는 페이지 이동까지 막는다.

그래서 작성해준 코드가 navigate(target.href);다.

navigate()가 뭐냐고? 이제부터 작성해야한다.

const navigate = url => {
  window.history.pushState(null, null, url);
  App();
};

아주 간단하다. history API를 사용하는 함수인데, 받아온 인자 값을 pushState에 전달한다. 그리고 App 함수를 호출한다.

자세히 살펴보자. pushState란 뭘까? 인자를 3개 받는다. 순서대로 다음과 같다.

  1. state : 새로운 세션 기록 항목에 연결할 상태 객체. (popstate 이벤트 발생 시 쓸 수 있음)
  2. unused : 생략
  3. url : 변경할 주소

크게 신경쓰지 말자. 1은 페이지 이동 시 데이터를 전달할 수 있다는 내용인데, 무시하자. 2 역시 원래는 title이었는데 mdn을 확인해보니 unused로 바뀌었다. 무시하자. 1, 2는 null로, 3은 navigate 함수에 전달된 인자 값을 그대로 넣어주자. 그럼 pushState가 유저를 url에 해당하는 경로로 이동시켜 줄 것이다. 단, 주소는 변경되는데 렌더링은 다시 안되기 때문에 App 함수를 호출해줄 것이다.

이제 새로고침 없이 페이지를 이동하는 SPA 라우터가 완성되었다!!

popstate event

마지막으로 수정이 필요한 부분이 있다. 위 내용까지 다 작성했다면, 뒤로가기와 앞으로가기가 작동하지 않을 것이다. 자세히 말하자면 주소는 바뀌는데 화면은 바뀌지 않는 상황일 것이다.

뒤로가기와 앞으로가기는 popstate 이벤트를 발생시키기 때문에, 간단하게 popstate 이벤트 발생 시 App 함수를 호출해주면 된다.

window.addEventListener("popstate", App);

결과물

이렇게 바닐라 자바스크립트로 SPA 만들기의 첫 관문인 라우터 구현이 끝났다. 사실 이후로도 동적라우팅을 구현하는 서브퀘스트가 있었는데, 나중에 따로 기록해보도록 하겠다! 정말 react-router에게 감사해지는 프로젝트였다...

다음 편은 컴포넌트를 만드는 방법으로 돌아오겠다.