페이지 이동의 과정을 가장 단순하게 설명한다면 다음과 같습니다.
- 사용자가 새로운 url을 요청한다. (새로운 페이지로 이동하는 버튼을 클릭하거나, 뒤로가기 버튼을 누르는 등)
- 요청한 url에 맞는 리소스(html, css, js)를
- 화면에 렌더링한다.
간단하죠?
MPA(Multiple Page Application), SPA(Single Page Application) 모두 페이지 이동의 골자는 동일합니다. 다만 리소스를 요청하는 방식에 따라 MPA와 SPA로 구분이 됩니다.
- 사용자가 새로운 url을 요청
- 해당 url에 대한 리소스를 서버에 요청
- 서버에서 받은 응답으로 화면을 렌더링
즉, 페이지를 이동할 때마다 서버에 해당 페이지 전체에 대한 리소스를 요청합니다. 이 과정에서 새로고침이 발생하게 됩니다.
또한 한 페이지 전체를 요청하기 때문에, 여러 페이지들에서 중복되는 요소가 있을 경우(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의 리소스 요청 방식은 다음과 같습니다.
- 첫 페이지 요청 시, 해당 어플리케이션에 필요한 모든 리소스를 서버에 요청
- 사용자가 새로운 url을 요청
- 해당 url에 대한 리소스를 클라이언트가 동적으로 생성(1번에서 받은 리소스를 활용해서)
- 생성한 리소스로 화면을 렌더링
“url을 요청하면 url에 맞는 리소스를 보내준다” 라는 서버의 역할을 클라이언트가 일정 부분 대체했다고 볼 수 있겠네요! 새로고침 없이 한 페이지 내에서 리소스를 바꿔 끼우기만 하는 방식이라 Single Page Application이라고 부릅니다.
어플리케이션에서의 라우팅을 한 문장으로 정의한다면, 다음과 같이 정의할 수 있을 것 같습니다.
사용자가 요청한 URL 또는 이벤트를 해석해, 페이지를 전환하는 일련의 행위
사용자의 행위에 따라 사용자를 알맞은 경로로 보내주는 것! 정도로 이해할 수 있겠네요. "route"의 사전적 정의와도 일치합니다. 그렇다면 router 라는 것은 route를 담당하는 도구를 의미하겠네요.
SPA와 라우팅에 대해 이해하셨다면, 이제 구현을 해봅시다! 구현의 핵심 아이디어는 다음과 같습니다.
url(path)과 컴포넌트를 1:1 매핑하는 배열을 생성합니다. routes
라고 칭하겠습니다. 아래 이미지와 같은 방식입니다. 이때 컴포넌트는 위에서 말했던 리소스(html, css, js)의 집합체라고 할 수 있겠죠.
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>
주소창의 url을 변경하되, 서버로 직접 http 요청을 보내서는 안됩니다. (그건 SPA가 아니니까요!) 어떻게 서버 요청 없이 url만 변경할 수 있을까요?
History API를 사용하면 됩니다! History 객체는 Web API의 일종으로, Window
객체를 통해 접근할 수 있습니다. history 객체의 pushState 메서드는 주소창의 url을 변경하고, 해당 url을 브라우저의 세션 기록에 추가하지만, 서버로 http 요청을 하지는 않습니다.
따라서 앵커 태그 대신 history.pushState()
를 사용해 url을 변경하고, 해당 이벤트를 감지해 변경된 url에 맞는 컴포넌트를 routes
배열에서 찾아 렌더링 해보겠습니다.
우선 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();
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 컴포넌트를 렌더링 합니다.
// 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 객체가 아닌 개별 컴포넌트에 이벤트리스너를 부착하는 것이 좀 더 바람직하지 않을까 싶네요:)
이 글이 도움이 되셨길 바랍니다!
이해하기 쉽게 작성하셨네요. 도움이 됐습니다.