본문 바로가기

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로 열어보세요.

아래 흐름이 모두 동작하는지 확인합니다.

  1. 목록에서 글 제목을 클릭하면 상세 페이지로 이동합니다.
  2. 상세 페이지 주소에 ?id=3처럼 ID가 붙어 있습니다.
  3. 그 주소를 복사해서 새 탭에 붙여넣으면 같은 글이 나옵니다.
  4. 브라우저 뒤로 가기 버튼을 누르면 목록으로 돌아갑니다.
  5. 새 글 쓰기, 작성, 방금 쓴 글의 상세 페이지로 이동하는 순서로 진행됩니다.
  6. 수정하기를 누르면 기존 내용이 채워져 있고, 수정하면 상세 페이지로 돌아옵니다.
  7. 삭제하기를 누르면 확인창이 뜨고, 확인하면 목록으로 이동합니다.

3번과 4번이 앞 절의 SPA에서는 불가능했던 것입니다.

3. SPA와 MPA 비교

같은 API 위에 두 가지 화면을 만들어봤습니다. 비교하면 아래와 같습니다.

SPAMPA
페이지 수1개기능마다 1개
화면 전환JavaScript가 일부만 교체브라우저가 새 페이지를 로드
주소바뀌지 않음페이지마다 다름
특정 글 공유별도 처리 필요주소만 복사하면 됨
뒤로 가기별도 처리 필요자동으로 동작
코드 중복적음페이지마다 반복되는 부분 있음
처음 로딩한 번만페이지마다

MPA 코드를 보면 페이지마다 URLSearchParams나 fetch 패턴이 반복됩니다. 페이지가 20개, 30개로 늘어나면 이 반복이 부담이 됩니다. React, Vue 같은 프레임워크는 이 반복을 줄이면서 SPA의 단점(주소, 뒤로 가기)도 함께 해결한 도구입니다.

지금 우리가 만든 것이 그 도구들이 왜 생겼는지 보여주는 최소한의 형태라고 생각하시면 됩니다.

다음 절에서는 데이터를 데이터베이스에 저장하고, Live Server 없이 FastAPI 하나로 화면까지 서빙해보겠습니다.

연습문제

  1. 목록 페이지에 페이지네이션을 붙여보세요. 백엔드에 skip과 limit 쿼리 매개변수를 추가하고, 화면 아래에 이전과 다음 버튼을 만들어보세요.
  2. 상세 페이지에서 없는 ID로 접근했을 때(blog_detail.html?id=999) 어떤 화면이 나오는지 확인해보세요.
  3. 주소에서 ?id= 부분을 아예 빼고 접속하면 어떻게 되는지 확인하고, 적절히 처리해보세요.
  4. 네 개의 페이지에 공통으로 들어가는 머리글과 링크를 만들고, JavaScript로 각 페이지에 넣는 방법을 생각해보세요. 이 문제를 풀다 보면 왜 프레임워크가 필요한지 체감할 수 있습니다.