본문 바로가기

SPA로 블로그 구현하기

1. SPA로 블로그 구현하기

이번 절에서는 SPA(Single Page Application) 방식으로 블로그를 구현해보겠습니다. SPA는 하나의 페이지 안에서 JavaScript를 사용하여 동적으로 콘텐츠를 변경하는 방식입니다. 페이지 새로고침 없이 화면이 바뀌기 때문에 사용자 입장에서는 화면이 깜빡이지 않습니다.

이번 절의 API 구성은 아래와 같습니다.

경로메서드설명
/blogsGET블로그 글 목록 조회
/blogsPOST블로그 글 생성
/blogs/{blog_id}GET블로그 글 상세 조회
/blogs/{blog_id}PUT블로그 글 수정
/blogs/{blog_id}DELETE블로그 글 삭제

2. 백엔드 API 구현

먼저 FastAPI를 사용하여 블로그 API를 구현합니다. 현재는 데이터베이스 대신 메모리(리스트)에 데이터를 저장하는 방식을 사용합니다. 데이터베이스는 05-5절에서 붙입니다.

main.py 파일에 아래 코드를 작성합니다.

from datetime import date
from itertools import count

from fastapi import FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field

app = FastAPI(title="위니브 블로그 API")

# CORS 설정
# 실무에서는 "*" 대신 내 서비스 도메인만 넣습니다.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)


class BlogCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    content: str = Field(min_length=1)


class BlogUpdate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    content: str = Field(min_length=1)


class Blog(BlogCreate):
    id: int
    author: str
    created_at: date
    updated_at: date


id_counter = count(4)  # 초기 데이터가 3개이므로 4부터 시작합니다

blogs: list[Blog] = [
    Blog(
        id=1,
        title="Hello",
        content="World",
        author="admin",
        created_at=date(2026, 1, 6),
        updated_at=date(2026, 1, 6),
    ),
    Blog(
        id=2,
        title="FastAPI",
        content="Python",
        author="admin",
        created_at=date(2026, 1, 7),
        updated_at=date(2026, 1, 7),
    ),
    Blog(
        id=3,
        title="Django",
        content="Python",
        author="admin",
        created_at=date(2026, 1, 8),
        updated_at=date(2026, 1, 8),
    ),
]


def find_blog(blog_id: int) -> Blog:
    """ID로 블로그를 찾고, 없으면 404를 발생시킵니다."""
    for blog in blogs:
        if blog.id == blog_id:
            return blog
    raise HTTPException(status_code=404, detail="Blog not found")


@app.get("/blogs", tags=["블로그"])
def read_blogs() -> list[Blog]:
    """최신 글이 위에 오도록 뒤집어서 반환합니다."""
    return blogs[::-1]


@app.get("/blogs/{blog_id}", tags=["블로그"])
def read_blog(blog_id: int) -> Blog:
    return find_blog(blog_id)


@app.post("/blogs", status_code=status.HTTP_201_CREATED, tags=["블로그"])
def create_blog(blog_data: BlogCreate) -> Blog:
    today = date.today()
    blog = Blog(
        id=next(id_counter),
        title=blog_data.title,
        content=blog_data.content,
        author="admin",
        created_at=today,
        updated_at=today,
    )
    blogs.append(blog)
    return blog


@app.put("/blogs/{blog_id}", tags=["블로그"])
def update_blog(blog_id: int, blog_data: BlogUpdate) -> Blog:
    blog = find_blog(blog_id)
    blog.title = blog_data.title
    blog.content = blog_data.content
    blog.updated_at = date.today()
    return blog


@app.delete("/blogs/{blog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["블로그"])
def delete_blog(blog_id: int) -> None:
    blog = find_blog(blog_id)
    blogs.remove(blog)

코드에서 눈여겨볼 부분은 아래와 같습니다.

find_blog 함수: 상세 조회, 수정, 삭제에서 매번 같은 반복문을 쓰는 대신 함수 하나로 묶었습니다. 글이 삭제될 수 있으므로 리스트 인덱스로 조회해서는 안 되고, ID를 비교해서 찾아야 합니다. 없을 때 404를 던지는 처리도 한 곳에 모입니다.

itertools.count로 ID 발급: ID를 len(blogs) + 1로 만들면 버그가 생깁니다. 글이 3개일 때 2번 글을 지우면 남은 글은 1번과 3번, 개수는 2개입니다. 이 상태에서 새 글을 만들면 ID가 3이 되어 이미 있는 3번과 겹칩니다. count는 한 번 쓴 번호를 다시 쓰지 않습니다.

date 타입: 날짜를 문자열로 다루면 날짜 비교나 정렬을 할 때 문제가 생깁니다. Pydantic이 date 객체를 "2026-01-06" 형태의 문자열로 알아서 바꿔 응답하므로, JSON으로 나가는 모양은 문자열과 똑같습니다.

2.1 API 먼저 확인하기

화면을 만들기 전에 API가 제대로 동작하는지 확인합니다. 서버를 실행하고 api.http 파일로 테스트해보세요.

fastapi dev
@baseUrl = http://127.0.0.1:8000

### 목록 조회
GET {{baseUrl}}/blogs

### 상세 조회
GET {{baseUrl}}/blogs/1

### 생성
POST {{baseUrl}}/blogs
Content-Type: application/json

{
    "title": "첫 글",
    "content": "안녕하세요"
}

### 수정
PUT {{baseUrl}}/blogs/1
Content-Type: application/json

{
    "title": "고친 제목",
    "content": "고친 내용"
}

### 삭제
DELETE {{baseUrl}}/blogs/1

### 없는 글 조회 (404가 나와야 정상입니다)
GET {{baseUrl}}/blogs/999

화면을 만들다가 문제가 생겼을 때, 백엔드가 문제인지 프론트엔드가 문제인지 구분하는 것이 가장 어렵습니다. 이렇게 API를 먼저 확인해두면 이후에 문제가 생겨도 프론트엔드 쪽을 먼저 보면 됩니다.

3. 프론트엔드 구현

프론트엔드는 순수한 HTML과 JavaScript를 사용하여 구현합니다. 이는 동작 원리를 보다 쉽게 이해하기 위함입니다. 추후 React, Vue.js 등의 프레임워크를 사용하여 구현할 수도 있습니다.

3.1 HTML 구조

index.html 파일을 만들고 아래 내용을 작성합니다. HTML은 크게 목록, 상세 조회, 생성, 수정, 삭제 섹션으로 구분됩니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>위니브 블로그 (SPA)</title>
</head>
<body>
    <h1>위니브 블로그 (SPA)</h1>

    <h2>목록</h2>
    <button class="btn_blogs">목록 불러오기</button>
    <div class="blogs"></div>

    <h2>상세 조회</h2>
    <input type="number" class="details_blogid" placeholder="블로그 ID">
    <button class="btn_blogdetails">상세 정보 불러오기</button>
    <div class="blogdetails"></div>

    <h2>생성</h2>
    <input type="text" class="create_blogtitle" placeholder="제목">
    <input type="text" class="create_blogcontent" placeholder="내용">
    <button class="btn_blogcreate">블로그 생성</button>

    <h2>수정</h2>
    <input type="number" class="edit_blogid" placeholder="블로그 ID">
    <input type="text" class="edit_blogtitle" placeholder="제목">
    <input type="text" class="edit_blogcontent" placeholder="내용">
    <button class="btn_blogedit">블로그 수정</button>

    <h2>삭제</h2>
    <input type="number" class="delete_blogid" placeholder="블로그 ID">
    <button class="btn_blogdelete">블로그 삭제</button>

    <script src="app.js"></script>
</body>
</html>

<script> 태그 안에 코드를 직접 쓰지 않고 app.js 파일을 따로 분리했습니다. HTML과 JavaScript가 섞여 있으면 나중에 읽기 어렵기 때문입니다.

3.2 JavaScript 이벤트 처리

각 기능별로 이벤트 리스너를 추가하여 API와 통신하도록 구현합니다. app.js 파일을 만들고 아래 내용을 작성합니다.

// API 주소를 한 곳에 모아둡니다.
// 05-5절에서 FastAPI가 화면까지 서빙하게 되면 이 값을 ""로 바꿉니다.
const API = "http://127.0.0.1:8000";

// 화면 요소
const blogsEl = document.querySelector(".blogs");
const blogDetailsEl = document.querySelector(".blogdetails");

// 목록 그리기
async function loadBlogs() {
    const response = await fetch(`${API}/blogs`);
    if (!response.ok) {
        alert("목록을 불러오지 못했습니다.");
        return;
    }
    const data = await response.json();

    blogsEl.innerHTML = "";
    data.forEach((blog) => {
        const item = document.createElement("div");
        item.innerHTML = `
            <h3>${blog.id}. ${blog.title}</h3>
            <p>${blog.content}</p>
            <p>작성자: ${blog.author}</p>
            <p>작성일: ${blog.created_at} / 수정일: ${blog.updated_at}</p>
            <hr>
        `;
        blogsEl.appendChild(item);
    });
}

document.querySelector(".btn_blogs").addEventListener("click", loadBlogs);

// 상세 조회
document.querySelector(".btn_blogdetails").addEventListener("click", async () => {
    const id = document.querySelector(".details_blogid").value;
    if (id === "") {
        alert("블로그 ID를 입력해주세요.");
        return;
    }

    const response = await fetch(`${API}/blogs/${id}`);
    if (response.status === 404) {
        blogDetailsEl.innerHTML = "<p>해당 글이 없습니다.</p>";
        return;
    }

    const blog = await response.json();
    // 화면 전체를 다시 그리지 않고 이 부분만 바꿉니다.
    // 그래서 브라우저가 깜빡이지 않고, 다른 영역도 그대로 유지됩니다.
    blogDetailsEl.innerHTML = `
        <h3>${blog.id}. ${blog.title}</h3>
        <p>${blog.content}</p>
        <p>작성자: ${blog.author}</p>
        <p>작성일: ${blog.created_at} / 수정일: ${blog.updated_at}</p>
    `;
});

// 생성
document.querySelector(".btn_blogcreate").addEventListener("click", async () => {
    const title = document.querySelector(".create_blogtitle").value;
    const content = document.querySelector(".create_blogcontent").value;

    const response = await fetch(`${API}/blogs`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title, content }),
    });

    if (!response.ok) {
        alert("제목과 내용을 모두 입력해주세요.");
        return;
    }
    await loadBlogs();  // 만든 뒤 목록을 다시 불러옵니다
});

// 수정
document.querySelector(".btn_blogedit").addEventListener("click", async () => {
    const id = document.querySelector(".edit_blogid").value;
    const title = document.querySelector(".edit_blogtitle").value;
    const content = document.querySelector(".edit_blogcontent").value;

    if (id === "") {
        alert("블로그 ID를 입력해주세요.");
        return;
    }

    const response = await fetch(`${API}/blogs/${id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title, content }),
    });

    if (!response.ok) {
        alert("수정에 실패했습니다.");
        return;
    }
    await loadBlogs();
});

// 삭제
document.querySelector(".btn_blogdelete").addEventListener("click", async () => {
    const id = document.querySelector(".delete_blogid").value;
    if (id === "") {
        alert("블로그 ID를 입력해주세요.");
        return;
    }

    const response = await fetch(`${API}/blogs/${id}`, { method: "DELETE" });
    if (!response.ok) {
        alert("삭제에 실패했습니다.");
        return;
    }
    await loadBlogs();
});

// 페이지가 열리면 바로 목록을 한 번 불러옵니다
loadBlogs();

코드에서 눈여겨볼 부분은 아래와 같습니다.

API 상수: http://127.0.0.1:8000을 요청마다 반복해서 적지 않고 상수 하나에 모아두었습니다. 05-5절에서 실제로 이 주소가 바뀌는데, 그때 한 줄만 고치면 됩니다.

response.ok 확인: 서버가 422나 404를 반환했을 때 화면에서 아무 일도 일어나지 않으면 사용자는 왜 안 되는지 알 수 없습니다. 응답 상태를 확인하고 안내 메시지를 보여줍니다.

작업 후 목록 다시 불러오기: 글을 만들거나 고치거나 지운 뒤 loadBlogs()를 다시 호출합니다. 이렇게 하지 않으면 화면이 그대로여서 새로고침을 해야 결과를 확인할 수 있습니다.

fetch는 404가 와도 에러를 내지 않습니다

fetch는 서버로부터 응답을 받기만 하면 성공으로 봅니다. 404든 500이든 마찬가지입니다. 네트워크 자체가 끊겼을 때만 예외가 발생합니다.

그래서 response.ok(상태 코드가 200번대인지) 또는 response.status를 직접 확인해야 합니다. try...catch만 써두면 서버 에러를 잡지 못합니다. 처음 배울 때 자주 놓치는 부분입니다.

4. 실행

터미널 두 개가 필요합니다.

  1. 첫 번째 터미널에서 FastAPI 서버를 실행합니다.

    fastapi dev
    
  2. index.html을 VS Code에서 우클릭하고 Open with Live Server를 선택합니다.

브라우저가 http://127.0.0.1:5500/index.html로 열리고, 페이지가 열리자마자 목록이 표시되면 성공입니다.

목록이 나오지 않는다면 개발자 도구 콘솔을 확인해보세요. CORS 에러가 보인다면 main.py의 미들웨어 설정을 다시 확인하시면 됩니다.

5. SPA의 성질 확인해보기

상세 조회 입력칸에 1을 넣고 버튼을 눌러보세요. 그다음 2를 넣고 다시 눌러보세요.

주소창이 바뀌지 않고, 화면 전체가 새로 그려지지도 않으며, 위쪽의 목록도 그대로 남아 있습니다. 필요한 부분만 JavaScript가 바꿔치기했기 때문입니다. 이것이 SPA의 성질입니다.

동시에 SPA의 단점도 여기서 드러납니다.

SPAMPA
화면 전환깜빡임 없음페이지가 새로 로드됨
주소 공유주소가 그대로라 특정 글을 공유하기 어려움주소로 특정 글을 가리킬 수 있음
뒤로 가기별도 처리가 없으면 동작하지 않음브라우저가 알아서 처리
검색엔진별도 처리가 필요함기본적으로 잘 수집됨

지금 상태에서 3번 글을 보고 있는 화면을 친구에게 공유할 방법이 없습니다. 주소가 index.html로 똑같기 때문입니다. 뒤로 가기를 눌러도 이전 글로 돌아가지 않습니다.

실제 SPA 프레임워크들은 이 문제를 라우터라는 기능으로 해결합니다. 다음 절에서는 라우터 없이, 페이지를 여러 개로 나누는 MPA(Multi Page Application) 방식으로 같은 블로그를 다시 만들어보겠습니다.

연습문제

  1. 목록의 각 글 옆에 삭제 버튼을 붙이고, 그 버튼을 누르면 해당 글이 바로 삭제되도록 만들어보세요.
  2. 생성 후에 입력칸이 비워지도록 만들어보세요.
  3. 서버를 끈 상태에서 버튼을 눌러보세요. 어떤 에러가 나는지 확인하고, try...catch로 사용자에게 안내 메시지를 보여주도록 고쳐보세요.
  4. 제목을 빈 값으로 두고 생성 버튼을 눌러보세요. 서버가 422를 반환하는데, 응답의 detail을 읽어 어떤 필드가 문제인지 화면에 표시해보세요.