MPA로 블로그 구현하기
1. MPA 블로그 구현하기
이번 절에서는 앞 절에서 만들었던 SPA를 MPA로 변경하여 구현해보겠습니다. MPA(Multi Page Application)는 여러 페이지로 구성된 애플리케이션입니다. 각 페이지는 서버로부터 새로운 HTML을 받아와 렌더링하는 방식입니다.
백엔드 코드는 그대로 두고 프론트엔드만 바꿉니다. 같은 API로 전혀 다른 형태의 화면을 만들 수 있다는 것도 이번 절에서 확인할 수 있습니다.
1.1 MPA 구조
아래처럼 총 4개의 페이지로 구성된 MPA를 구현해보겠습니다.
| 파일 | 역할 |
|---|---|
blog_list.html | 블로그 글 목록 페이지 |
blog_detail.html | 블로그 글 상세 페이지 |
blog_create.html | 블로그 글 생성 페이지 |
blog_edit.html | 블로그 글 수정 페이지 |
앞 절에서 만든 index.html과 app.js는 지우거나 그대로 두셔도 됩니다.
1.2 공통 설정 파일 만들기
페이지가 4개로 늘어나면 API 주소를 4번 적게 됩니다. 나중에 주소가 바뀌면 4곳을 모두 고쳐야 하므로, 공통으로 쓸 파일을 하나 만들어두겠습니다.
common.js 파일을 만들고 아래 내용을 작성합니다.
// API 서버 주소입니다.
// 05-5절에서 FastAPI가 화면까지 서빙하게 되면 이 값을 ""로 바꿉니다.
const API = "http://127.0.0.1:8000";
각 페이지에서 이 파일을 먼저 불러온 다음 자기 코드를 실행하도록 하겠습니다.
페이지 사이 링크는 상대 경로로 씁니다
링크를 http://127.0.0.1:5500/blog_detail.html처럼 전체 주소로 적으면 아래와 같은 문제가 생깁니다.
- Live Server의 포트가 5500이 아닐 수도 있습니다.
- 05-5절에서 FastAPI가 화면을 서빙하면 주소가 8000으로 바뀌어 모든 링크를 고쳐야 합니다.
- 나중에 실제 도메인에 올리면 또 전부 고쳐야 합니다.
<a href="blog_detail.html?id=3">처럼 상대 경로로 적으면 지금 페이지가 있는 곳을 기준으로 알아서 찾아갑니다. 어느 서버에서 열리든 그대로 동작합니다. 이 책에서는 상대 경로를 사용합니다.
1.3 MPA 구현
1.3.1 블로그 목록 페이지
블로그 목록 페이지에서는 블로그 글 목록을 조회하고, 각 글을 클릭하면 상세 페이지로 이동할 수 있습니다. 파일명은 blog_list.html로 생성합니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>블로그 목록</title>
</head>
<body>
<h1>블로그 목록</h1>
<a href="blog_create.html">새 글 쓰기</a>
<main></main>
<script src="common.js"></script>
<script>
const main = document.querySelector("main");
async function loadBlogs() {
const response = await fetch(`${API}/blogs`);
if (!response.ok) {
main.innerHTML = "<p>목록을 불러오지 못했습니다.</p>";
return;
}
const data = await response.json();
if (data.length === 0) {
main.innerHTML = "<p>아직 글이 없습니다.</p>";
return;
}
data.forEach((blog) => {
const article = document.createElement("article");
article.innerHTML = `
<h2><a href="blog_detail.html?id=${blog.id}">${blog.title}</a></h2>
<p>${blog.author} | ${blog.created_at}</p>
<hr>
`;
main.appendChild(article);
});
}
loadBlogs();
</script>
</body>
</html>
목록에서는 제목과 작성 정보만 보여주고, 본문은 상세 페이지에서 보여줍니다. 목록에 본문을 전부 표시하면 글이 길 때 화면이 지저분해집니다.
1.3.2 블로그 상세 페이지
블로그 상세 페이지에서는 블로그 글의 상세 정보를 조회하고, 수정 및 삭제를 할 수 있습니다. 수정과 삭제 기능은 추후 로그인 한 사용자만 수정과 삭제가 가능해야 합니다. 파일명은 blog_detail.html로 생성합니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>블로그 상세</title>
</head>
<body>
<h1>블로그 상세</h1>
<main></main>
<a href="blog_list.html">목록으로 가기</a>
<button class="edit">수정하기</button>
<button class="delete">삭제하기</button>
<script src="common.js"></script>
<script>
const main = document.querySelector("main");
// 주소에서 id를 꺼냅니다.
// blog_detail.html?id=3 이면 id는 "3"입니다.
const urlParams = new URLSearchParams(location.search);
const id = urlParams.get("id");
async function loadBlog() {
const response = await fetch(`${API}/blogs/${id}`);
if (!response.ok) {
main.innerHTML = "<p>해당 글을 찾을 수 없습니다.</p>";
return;
}
const blog = await response.json();
main.innerHTML = `
<h2>${blog.title}</h2>
<p>${blog.author} | 작성 ${blog.created_at} | 수정 ${blog.updated_at}</p>
<p>${blog.content}</p>
`;
}
document.querySelector(".edit").addEventListener("click", () => {
location.href = `blog_edit.html?id=${id}`;
});
document.querySelector(".delete").addEventListener("click", async () => {
if (!confirm("정말 삭제하시겠습니까?")) {
return;
}
const response = await fetch(`${API}/blogs/${id}`, { method: "DELETE" });
if (!response.ok) {
alert("삭제에 실패했습니다.");
return;
}
location.href = "blog_list.html";
});
loadBlog();
</script>
</body>
</html>
URLSearchParams가 이 페이지의 핵심입니다. SPA에서는 입력칸에 ID를 직접 넣었지만, MPA에서는 주소에 ID가 담깁니다. 덕분에 blog_detail.html?id=3 주소를 그대로 복사해서 공유할 수 있습니다. 앞 절에서 SPA의 단점으로 언급했던 부분이 해결되었습니다.
삭제 전에 confirm으로 한 번 확인하는 것도 추가했습니다. 되돌릴 수 없는 동작 앞에는 확인 절차를 두는 것이 좋습니다.
1.3.3 블로그 생성 페이지
블로그 생성 페이지에서는 새로운 블로그 글을 생성할 수 있습니다. 파일명은 blog_create.html로 생성합니다. 다만 여기서 고려해야 할 것이 게시물을 생성한 다음에 어디로 가야할지입니다. 목록으로 갈 수도 있고, 방금 쓴 글의 상세 페이지로 갈 수도 있습니다. 이번에는 게시물을 생성한 다음에 상세 페이지로 이동하도록 구현하겠습니다. 여기에 따라 프론트엔드 구현이 매우 달라집니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>블로그 생성</title>
</head>
<body>
<h1>새 글 쓰기</h1>
<form>
<input type="text" name="title" placeholder="제목" required><br>
<textarea name="content" placeholder="내용" required></textarea><br>
<button type="submit">작성</button>
</form>
<a href="blog_list.html">목록으로 가기</a>
<script src="common.js"></script>
<script>
const form = document.querySelector("form");
form.addEventListener("submit", async (event) => {
event.preventDefault(); // 폼의 기본 동작(페이지 새로고침)을 막습니다
const title = event.target.title.value;
const content = event.target.content.value;
const response = await fetch(`${API}/blogs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
alert("작성에 실패했습니다.");
return;
}
const blog = await response.json();
location.href = `blog_detail.html?id=${blog.id}`;
});
</script>
</body>
</html>
event.preventDefault()가 중요합니다. HTML 폼은 원래 제출하면 페이지 전체를 서버로 보내고 새로 받아옵니다. 우리는 JavaScript로 직접 요청을 보낼 것이므로 그 기본 동작을 막아야 합니다.
<form> 태그에 action을 적지 않은 것도 같은 이유입니다. JavaScript가 요청을 보내므로 action은 쓰이지 않으며, 적어두면 오히려 오해를 부릅니다.
1.3.4 블로그 수정 페이지
블로그 수정 페이지에서는 기존 블로그 글을 수정할 수 있습니다. 파일명은 blog_edit.html로 생성합니다.
수정 페이지에는 생성 페이지에 없는 단계가 하나 더 있습니다. 기존 내용을 먼저 불러와서 입력칸을 채워야 합니다. 빈 화면에서 다시 쓰게 하면 사용자가 불편합니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>블로그 수정</title>
</head>
<body>
<h1>글 수정</h1>
<form>
<input type="text" name="title" placeholder="제목" required><br>
<textarea name="content" placeholder="내용" required></textarea><br>
<button type="submit">수정</button>
</form>
<script src="common.js"></script>
<script>
const form = document.querySelector("form");
const urlParams = new URLSearchParams(location.search);
const id = urlParams.get("id");
// 기존 데이터를 가져와 폼에 채웁니다
async function loadBlog() {
const response = await fetch(`${API}/blogs/${id}`);
if (!response.ok) {
alert("해당 글을 찾을 수 없습니다.");
location.href = "blog_list.html";
return;
}
const blog = await response.json();
form.title.value = blog.title;
form.content.value = blog.content;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch(`${API}/blogs/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: event.target.title.value,
content: event.target.content.value,
}),
});
if (!response.ok) {
alert("수정에 실패했습니다.");
return;
}
location.href = `blog_detail.html?id=${id}`;
});
loadBlog();
</script>
</body>
</html>
수정이 끝나면 주소에서 꺼내둔 id로 상세 페이지에 이동합니다. 이미 알고 있는 값을 응답에서 다시 꺼낼 필요가 없기 때문입니다.
2. 실행과 확인
FastAPI 서버가 켜져 있는 상태에서, blog_list.html을 Live Server로 열어보세요.
아래 흐름이 모두 동작하는지 확인합니다.
- 목록에서 글 제목을 클릭하면 상세 페이지로 이동합니다.
- 상세 페이지 주소에
?id=3처럼 ID가 붙어 있습니다. - 그 주소를 복사해서 새 탭에 붙여넣으면 같은 글이 나옵니다.
- 브라우저 뒤로 가기 버튼을 누르면 목록으로 돌아갑니다.
- 새 글 쓰기, 작성, 방금 쓴 글의 상세 페이지로 이동하는 순서로 진행됩니다.
- 수정하기를 누르면 기존 내용이 채워져 있고, 수정하면 상세 페이지로 돌아옵니다.
- 삭제하기를 누르면 확인창이 뜨고, 확인하면 목록으로 이동합니다.
3번과 4번이 앞 절의 SPA에서는 불가능했던 것입니다.
3. SPA와 MPA 비교
같은 API 위에 두 가지 화면을 만들어봤습니다. 비교하면 아래와 같습니다.
| SPA | MPA | |
|---|---|---|
| 페이지 수 | 1개 | 기능마다 1개 |
| 화면 전환 | JavaScript가 일부만 교체 | 브라우저가 새 페이지를 로드 |
| 주소 | 바뀌지 않음 | 페이지마다 다름 |
| 특정 글 공유 | 별도 처리 필요 | 주소만 복사하면 됨 |
| 뒤로 가기 | 별도 처리 필요 | 자동으로 동작 |
| 코드 중복 | 적음 | 페이지마다 반복되는 부분 있음 |
| 처음 로딩 | 한 번만 | 페이지마다 |
MPA 코드를 보면 페이지마다 URLSearchParams나 fetch 패턴이 반복됩니다. 페이지가 20개, 30개로 늘어나면 이 반복이 부담이 됩니다. React, Vue 같은 프레임워크는 이 반복을 줄이면서 SPA의 단점(주소, 뒤로 가기)도 함께 해결한 도구입니다.
지금 우리가 만든 것이 그 도구들이 왜 생겼는지 보여주는 최소한의 형태라고 생각하시면 됩니다.
다음 절에서는 데이터를 데이터베이스에 저장하고, Live Server 없이 FastAPI 하나로 화면까지 서빙해보겠습니다.
연습문제
- 목록 페이지에 페이지네이션을 붙여보세요. 백엔드에
skip과limit쿼리 매개변수를 추가하고, 화면 아래에 이전과 다음 버튼을 만들어보세요. - 상세 페이지에서 없는 ID로 접근했을 때(
blog_detail.html?id=999) 어떤 화면이 나오는지 확인해보세요. - 주소에서
?id=부분을 아예 빼고 접속하면 어떻게 되는지 확인하고, 적절히 처리해보세요. - 네 개의 페이지에 공통으로 들어가는 머리글과 링크를 만들고, JavaScript로 각 페이지에 넣는 방법을 생각해보세요. 이 문제를 풀다 보면 왜 프레임워크가 필요한지 체감할 수 있습니다.