Vanilla Javascript로 SPA 구현하기(프로그래머스 쇼핑몰 SPA)

이은지·2022년 11월 25일
22
post-thumbnail

✅ SPA(Single Page Application) 이해하기

페이지 이동의 과정을 가장 단순하게 설명한다면 다음과 같습니다.

  1. 사용자가 새로운 url을 요청한다. (새로운 페이지로 이동하는 버튼을 클릭하거나, 뒤로가기 버튼을 누르는 등)
  2. 요청한 url에 맞는 리소스(html, css, js)를
  3. 화면에 렌더링한다.

간단하죠?

MPA(Multiple Page Application), SPA(Single Page Application) 모두 페이지 이동의 골자는 동일합니다. 다만 리소스를 요청하는 방식에 따라 MPA와 SPA로 구분이 됩니다.

🧚‍♀️ MPA의 리소스 요청 방식

  1. 사용자가 새로운 url을 요청
  2. 해당 url에 대한 리소스를 서버에 요청
  3. 서버에서 받은 응답으로 화면을 렌더링

즉, 페이지를 이동할 때마다 서버에 해당 페이지 전체에 대한 리소스를 요청합니다. 이 과정에서 새로고침이 발생하게 됩니다.

또한 한 페이지 전체를 요청하기 때문에, 여러 페이지들에서 중복되는 요소가 있을 경우(ex 내비게이션 바) 중복된 데이터를 여러 번 요청하게 됩니다.

앵커(a) 태그를 이용한 페이지 이동 방식을 떠올리면 이해가 되실겁니다.

<nav>
    <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/service.html">Service</a></li> 
        <li><a href="/about.html">About</a></li>
        // 클릭 시 서버에 '/about.html'에 해당하는 리소스를 요청
    </ul>
</nav>

참고로 해당 방식은 서버 단에서 완성해준 html을 그대로 렌더링한다는 점에서 SSR(Server Side Rendering)이라고도 칭합니다. 반면 SPA의 리소스 요청 방식은 다음과 같습니다.

🧚 SPA의 리소스 요청 방식

  1. 첫 페이지 요청 시, 해당 어플리케이션에 필요한 모든 리소스를 서버에 요청
  2. 사용자가 새로운 url을 요청
  3. 해당 url에 대한 리소스를 클라이언트가 동적으로 생성(1번에서 받은 리소스를 활용해서)
  4. 생성한 리소스로 화면을 렌더링

“url을 요청하면 url에 맞는 리소스를 보내준다” 라는 서버의 역할을 클라이언트가 일정 부분 대체했다고 볼 수 있겠네요! 새로고침 없이 한 페이지 내에서 리소스를 바꿔 끼우기만 하는 방식이라 Single Page Application이라고 부릅니다.

💁🏻‍♀️ 라우팅이란?

어플리케이션에서의 라우팅을 한 문장으로 정의한다면, 다음과 같이 정의할 수 있을 것 같습니다.

사용자가 요청한 URL 또는 이벤트를 해석해, 페이지를 전환하는 일련의 행위

사용자의 행위에 따라 사용자를 알맞은 경로로 보내주는 것! 정도로 이해할 수 있겠네요. "route"의 사전적 정의와도 일치합니다. 그렇다면 router 라는 것은 route를 담당하는 도구를 의미하겠네요.

💡 구현 핵심 아이디어

SPA와 라우팅에 대해 이해하셨다면, 이제 구현을 해봅시다! 구현의 핵심 아이디어는 다음과 같습니다.

routes 배열

url(path)과 컴포넌트를 1:1 매핑하는 배열을 생성합니다. routes 라고 칭하겠습니다. 아래 이미지와 같은 방식입니다. 이때 컴포넌트는 위에서 말했던 리소스(html, css, js)의 집합체라고 할 수 있겠죠.

url에 따라 컴포넌트 교체하기

url에 따라 main.App 요소의 innerHTML을 교체 합니다. 이때 1번에서 선언한 routes 배열을 참조합니다.

만약 ‘/about’ 이라는 url이 요청됐다면, routes 배열에서 ‘/about’에 해당하는 컴포넌트를 찾아 main.App 의 하위에 렌더링합니다.

한 페이지 내에서 리소스만 갈아끼운다 라는 말이 이제 이해가 되시나요?

// index.html
<html>
  <body>
    <main class="App"></main> // 이 요소의 innerHTML을 변경할겁니다.
    <script type="module" src="./src/main.js"></script>
  </body>
</html>

History API로 url 변경 요청하기

주소창의 url을 변경하되, 서버로 직접 http 요청을 보내서는 안됩니다. (그건 SPA가 아니니까요!) 어떻게 서버 요청 없이 url만 변경할 수 있을까요?

History API를 사용하면 됩니다! History 객체는 Web API의 일종으로, Window 객체를 통해 접근할 수 있습니다. history 객체의 pushState 메서드는 주소창의 url을 변경하고, 해당 url을 브라우저의 세션 기록에 추가하지만, 서버로 http 요청을 하지는 않습니다.

따라서 앵커 태그 대신 history.pushState() 를 사용해 url을 변경하고, 해당 이벤트를 감지해 변경된 url에 맞는 컴포넌트를 routes 배열에서 찾아 렌더링 해보겠습니다.

👩🏻‍💻 실제 구현

index.html, main.js, 컴포넌트 작성

우선 index.html 파일을 작성해줍니다! head 태그는 편의상 생략했습니다.

<!--index.html -->

<html lang="en">
  <body>
    <main class="App"></main>
    <script type="module" src="./src/main.js"></script>
  </body>
</html>
// main.js

const $app = document.querySelector(".App");

Home, About 페이지에 해당하는 컴포넌트들도 만들어줄게요. 각 파일에서 내보낸(export) 컴포넌트 인스턴스들을 추후에 routes 배열에 등록 해줄거예요. template 메서드는 해당 컴포넌트의 마크업을 리턴합니다. 

// Home.js

class Home {
  template() {
    return `
    <div>Home Page</div>
    <button class="moveToAboutPageBtn">Go to About Page</button>
    `;
  }
}
export default new Home();

// About.js

class About {
  template() {
    return `
    <div>About Page</div>
    <button class="moveToHomePageBtn">Go to Home Page</button>
    `;
  }
}
export default new About();

routes 배열 생성

path와 컴포넌트의 매핑 정보를 담고 있는 routes 배열을 만들어 봅시다. 저 같은 경우 path명으로 바로 접근하고 싶어서 배열 대신 객체를 사용했습니다~

// main.js

import About from "./components/About.js";
import Home from "./components/Home.js";

const $app = document.querySelector(".App");

const routes = {
  "/": Home,
  "/about": About,
};

$app.innerHTML = routes["/"].template();

앱의 첫 구동 시에는 "/" 에 해당하는 Home 컴포넌트를 렌더링 합니다.

history.pushState를 이용한 url변경 구현

// main.js
export const changeUrl = (requestedUrl) => {
  // history.pushState를 사용해 url을 변경한다.
  history.pushState(null, null, requestedUrl);

  // routes 배열에서 url에 맞는 컴포넌트를 찾아 main.App에 렌더링 한다.
  $app.innerHTML = routes[requestedUrl].template();
};

Home, About 페이지의 버튼이 클릭됐을 때 이 changeUrl 함수를 호출하면 됩니다. 이벤트리스너를 사용해 클릭 이벤트를 등록해주겠습니다.

// main.js

window.addEventListener("click", (e) => {
  if (e.target.classList.contains("moveToAboutPageBtn")) {
    // Home 페이지의 버튼이 클릭된 경우
    changeUrl("/about");
  } else if (e.target.classList.contains("moveToHomePageBtn")) {
    // About 페이지의 버튼이 클릭된 경우
    changeUrl("/");
  }
});

url과 화면이 잘 변경되고 있죠?

뒤로가기 구현

뒤로가기의 경우 브라우저 자체의 뒤로가기 버튼을 통해 이뤄지기 때문에, 따로 처리를 해줘야 합니다. popstate 이벤트를 통해 뒤로가기의 발생을 감지할 수 있습니다. 

// main.js

window.addEventListener("popstate", () => {
  changeUrl(window.location.pathname);
});

뒤로가기 이벤트가 발생하면, window.location.pathname 을 사용해 변경된 url을 받아옵니다. 해당 url로 changeUrl 함수를 호출합니다. 

✨ 전체 코드

// main.js

import About from "./components/About.js";
import Home from "./components/Home.js";

const $app = document.querySelector(".App");

const routes = {
  "/": Home,
  "/about": About,
};

$app.innerHTML = routes["/"].template();

export const changeUrl = (requestedUrl) => {
  history.pushState(null, null, requestedUrl);
  $app.innerHTML = routes[requestedUrl].template();
};

window.addEventListener("click", (e) => {
  if (e.target.classList.contains("moveToAboutPageBtn")) {
    // Home 페이지의 버튼이 클릭된 경우
    changeUrl("/about");
  } else if (e.target.classList.contains("moveToHomePageBtn")) {
    // About 페이지의 버튼이 클릭된 경우
    changeUrl("/");
  }
});

window.addEventListener("popstate", () => {
  changeUrl(window.location.pathname);
});

이렇게 간단한 방식으로 SPA를 구현해봤습니다!

현재는 편의상 main.js 내에 모든 코드를 작성 했지만, 추후에는 라우팅 기능에 해당하는 코드들을 별도의 파일로 분리하는 것이 바람직해 보입니다.

또 window 객체가 아닌 개별 컴포넌트에 이벤트리스너를 부착하는 것이 좀 더 바람직하지 않을까 싶네요:)

이 글이 도움이 되셨길 바랍니다!

3개의 댓글

comment-user-thumbnail
2023년 9월 18일

이해하기 쉽게 작성하셨네요. 도움이 됐습니다.

1개의 답글
comment-user-thumbnail
2024년 9월 28일

좋은 튜토리얼 감사합니다. 리액트를 배우지 않은 상태이고 바닐라JS에 대해서만 조금 아는 정도인데, 이러한 작동 방식을 이해하고 리액트를 접근한다면 좀 더 리액트를 빨리 이해할 수 있지 않을까 하는 기대를 해봅니다.

그런데 이거... 프랙티컬한 상황에서도 쓸만 할까요? 물론 리액트보다야 코딩 효율성은 낮겠지만, 사용자 측면에서는 외부라이브러리를 불러오지 않아도 돼서 지연시간은 덜하지 않을까 하는 생각이 있어서요!

답글 달기