SPA로 블로그 구현하기
1. SPA로 블로그 구현하기
이번 챕터에서는 SPA(Single Page Application) 방식으로 블로그를 구현해보겠습니다. SPA는 하나의 페이지 안에서 JavaScript를 사용하여 동적으로 콘텐츠를 변경하는 방식입니다. 이를 통해 페이지 새로고침 없이 부드러운 사용자 경험을 제공할 수 있습니다.
1.1 백엔드 API 구현
먼저 FastAPI를 사용하여 블로그 API를 구현했습니다. 현재는 데이터베이스 대신 메모리(리스트)에 데이터를 저장하는 방식을 사용했습니다.
주요 기능은 다음과 같습니다:
- 블로그 글 목록 조회 (GET /blogs)
- 블로그 글 상세 조회 (GET /blogs/{blog_id})
- 블로그 글 생성 (POST /blogs)
- 블로그 글 수정 (PUT /blogs/{blog_id})
- 블로그 글 삭제 (DELETE /blogs/{blog_id})
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI()
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
# 모든 URL에서 접근 허용, 실무에서는 내 서비스 도메인만 넣어주시면 됩니다.
# admin은 보통 다른 URL에서 접근하도록 합니다. /admin, admin.example.com 사용하지 않습니다.
allow_credentials=True, # 쿠키 허용
allow_methods=["*"], # 모든 HTTP 메서드 허용
allow_headers=["*"], # 모든 HTTP 헤더 허용
)
blogs = [
{
"id": 1,
"title": "Hello",
"content": "World",
"author": "admin",
"created_at": "2025-01-06",
"updated_at": "2025-01-06",
},
{
"id": 2,
"title": "FastAPI",
"content": "Python",
"author": "admin",
"created_at": "2025-01-07",
"updated_at": "2025-01-07",
},
{
"id": 3,
"title": "Django",
"content": "Python",
"author": "admin",
"created_at": "2025-01-08",
"updated_at": "2025-01-08",
},
]
# 블로그 글 목록 조회
@app.get("/blogs")
def read_blogs():
reversed_data = blogs[::-1]
return reversed_data
# 블로그 글 상세 조회
@app.get("/blogs/{blog_id}")
def read_blog(blog_id: int):
# 삭제될 수 있으므로 그냥 인덱스로 조회해서는 안됩니다.
for blog in blogs:
if blog["id"] == blog_id:
return blog
return {"message": "Not Found"}
class BlogCreate(BaseModel):
title: str
content: str
# 블로그 글 생성
@app.post("/blogs")
def create_blog(blog_create_data: BlogCreate):
# datetime 객체를 통해 현재 시간을 가져와서 문자로 할당하도록 하겠습니다.
print("들어오긴 했음")
import datetime
now = datetime.datetime.now()
created_at = now.strftime("%Y-%m-%d")
# 여기에 id 부분은 len보다는 id의 최댓값으로 설정하는 것이 좋습니다.
# 다만 이 부분은 데이터베이스를 사용하지 않아서 이렇게 처리하였습니다.
blog = {
"id": len(blogs) + 1,
"title": blog_create_data.title,
"content": blog_create_data.content,
"author": "admin",
"created_at": created_at,
"updated_at": created_at,
}
blogs.append(blog)
return blog
class BlogUpdate(BaseModel):
title: str
content: str
# 블로그 글 수정
@app.put("/blogs/{blog_id}")
def update_blog(blog_id: int, blog_update_data: BlogUpdate):
print(blog_id, blog_update_data)
for blog in blogs:
if blog["id"] == blog_id:
blog["title"] = blog_update_data.title
blog["content"] = blog_update_data.content
# 수정 시간을 업데이트
import datetime
now = datetime.datetime.now()
updated_at = now.strftime("%Y-%m-%d")
blog["updated_at"] = updated_at
return blog
return {"message": "Not Found"}
# 블로그 글 삭제
@app.delete("/blogs/{blog_id}")
def delete_blog(blog_id: int):
for blog in blogs:
if blog["id"] == blog_id:
blogs.remove(blog)
return {"message": "Deleted"}
return {"message": "Not Found"}
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI()
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
# 모든 URL에서 접근 허용, 실무에서는 내 서비스 도메인만 넣어주시면 됩니다.
# admin은 보통 다른 URL에서 접근하도록 합니다. /admin, admin.example.com 사용하지 않습니다.
allow_credentials=True, # 쿠키 허용
allow_methods=["*"], # 모든 HTTP 메서드 허용
allow_headers=["*"], # 모든 HTTP 헤더 허용
)
blogs = [
{
"id": 1,
"title": "Hello",
"content": "World",
"author": "admin",
"created_at": "2025-01-06",
"updated_at": "2025-01-06",
},
{
"id": 2,
"title": "FastAPI",
"content": "Python",
"author": "admin",
"created_at": "2025-01-07",
"updated_at": "2025-01-07",
},
{
"id": 3,
"title": "Django",
"content": "Python",
"author": "admin",
"created_at": "2025-01-08",
"updated_at": "2025-01-08",
},
]
# 블로그 글 목록 조회
@app.get("/blogs")
def read_blogs():
reversed_data = blogs[::-1]
return reversed_data
# 블로그 글 상세 조회
@app.get("/blogs/{blog_id}")
def read_blog(blog_id: int):
# 삭제될 수 있으므로 그냥 인덱스로 조회해서는 안됩니다.
for blog in blogs:
if blog["id"] == blog_id:
return blog
return {"message": "Not Found"}
class BlogCreate(BaseModel):
title: str
content: str
# 블로그 글 생성
@app.post("/blogs")
def create_blog(blog_create_data: BlogCreate):
# datetime 객체를 통해 현재 시간을 가져와서 문자로 할당하도록 하겠습니다.
print("들어오긴 했음")
import datetime
now = datetime.datetime.now()
created_at = now.strftime("%Y-%m-%d")
# 여기에 id 부분은 len보다는 id의 최댓값으로 설정하는 것이 좋습니다.
# 다만 이 부분은 데이터베이스를 사용하지 않아서 이렇게 처리하였습니다.
blog = {
"id": len(blogs) + 1,
"title": blog_create_data.title,
"content": blog_create_data.content,
"author": "admin",
"created_at": created_at,
"updated_at": created_at,
}
blogs.append(blog)
return blog
class BlogUpdate(BaseModel):
title: str
content: str
# 블로그 글 수정
@app.put("/blogs/{blog_id}")
def update_blog(blog_id: int, blog_update_data: BlogUpdate):
print(blog_id, blog_update_data)
for blog in blogs:
if blog["id"] == blog_id:
blog["title"] = blog_update_data.title
blog["content"] = blog_update_data.content
# 수정 시간을 업데이트
import datetime
now = datetime.datetime.now()
updated_at = now.strftime("%Y-%m-%d")
blog["updated_at"] = updated_at
return blog
return {"message": "Not Found"}
# 블로그 글 삭제
@app.delete("/blogs/{blog_id}")
def delete_blog(blog_id: int):
for blog in blogs:
if blog["id"] == blog_id:
blogs.remove(blog)
return {"message": "Deleted"}
return {"message": "Not Found"}
3.2 프론트엔드 구현
프론트엔드는 순수한 HTML과 JavaScript를 사용하여 구현했습니다. 이는 동작 원리를 보다 쉽게 이해하기 위함입니다. 추후 React, Vue.js 등의 프레임워크를 사용하여 구현할 수도 있습니다.
3.2.1 HTML 구조
HTML은 크게 다음과 같은 섹션으로 구분됩니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<h1>SPA</h1>
<div class="blogs"></div>
<button class="btn_blogs">목록 불러오기</button>
<div class="blogdetails"></div>
<input type="text" class="details_blogid" placeholder="블로그 ID">
<button class="btn_blogdetils">상세 정보 불러오기</button>
<div class="blogcreate"></div>
<input type="text" class="create_blogtitle" placeholder="블로그 제목">
<input type="text" class="create_blogcontent" placeholder="블로그 내용">
<button class="btn_blogcreate">블로그 생성</button>
<div class="blogedit"></div>
<input type="text" class="edit_blogid" placeholder="블로그 ID">
<input type="text" class="edit_blogtitle" placeholder="블로그 제목">
<input type="text" class="edit_blogcontent" placeholder="블로그 내용">
<button class="btn_blogedit">블로그 수정</button>
<div class="blogdelete"></div>
<input type="text" class="delete_blogid" placeholder="블로그 ID">
<button class="btn_blogdelete">블로그 삭제</button>
<script>
// JavaScript 코드
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<h1>SPA</h1>
<div class="blogs"></div>
<button class="btn_blogs">목록 불러오기</button>
<div class="blogdetails"></div>
<input type="text" class="details_blogid" placeholder="블로그 ID">
<button class="btn_blogdetils">상세 정보 불러오기</button>
<div class="blogcreate"></div>
<input type="text" class="create_blogtitle" placeholder="블로그 제목">
<input type="text" class="create_blogcontent" placeholder="블로그 내용">
<button class="btn_blogcreate">블로그 생성</button>
<div class="blogedit"></div>
<input type="text" class="edit_blogid" placeholder="블로그 ID">
<input type="text" class="edit_blogtitle" placeholder="블로그 제목">
<input type="text" class="edit_blogcontent" placeholder="블로그 내용">
<button class="btn_blogedit">블로그 수정</button>
<div class="blogdelete"></div>
<input type="text" class="delete_blogid" placeholder="블로그 ID">
<button class="btn_blogdelete">블로그 삭제</button>
<script>
// JavaScript 코드
</script>
</body>
</html>
3.2.2 JavaScript 이벤트 처리
각 기능별로 이벤트 리스너를 추가하여 API와 통신하도록 구현했습니다.
<script>
// 블로그 목록
const blogs = document.querySelector('.blogs');
const blogdetails = document.querySelector('.blogdetails');
const blogedit = document.querySelector('.blogedit');
const blogdelete = document.querySelector('.blogdelete');
// 버튼 및 입력창
const btn_blogs = document.querySelector('.btn_blogs');
const details_blogid = document.querySelector('.details_blogid');
const btn_blogdetils = document.querySelector('.btn_blogdetils');
const create_blogtitle = document.querySelector('.create_blogtitle');
const create_blogcontent = document.querySelector('.create_blogcontent');
const btn_blogcreate = document.querySelector('.btn_blogcreate');
const edit_blogid = document.querySelector('.edit_blogid');
const edit_blogtitle = document.querySelector('.edit_blogtitle');
const edit_blogcontent = document.querySelector('.edit_blogcontent');
const btn_blogedit = document.querySelector('.btn_blogedit');
const delete_blogid = document.querySelector('.delete_blogid');
const btn_blogdelete = document.querySelector('.btn_blogdelete');
// 블로그 목록
btn_blogs.addEventListener('click', async () => {
const response = await fetch('http://127.0.0.1:8000/blogs');
const data = await response.json();
console.log(data);
blogs.innerHTML = '';
data.forEach(element => {
blogs.innerHTML += `
<div>
<h2>${element.title}</h2>
<p>${element.created_at}</p>
<p>${element.updated_at}</p>
<p>${element.id}</p>
<p>${element.author}</p>
<p>${element.content}</p>
</div>
`;
});
});
// 블로그 상세 정보
btn_blogdetils.addEventListener('click', async () => {
// 만약에 details_blogid.value 값이 비어있다면
if (details_blogid.value === '') {
alert('블로그 ID를 입력해주세요.');
return;
}
const response = await fetch(`http://127.0.0.1:8000/blogs/${details_blogid.value}`);
const data = await response.json();
console.log(data);
// blogs.innerHTML = '';
// 이렇게 했을 때의 장점?
// 1. 사용자의 브라우저는 깜빡이지 않습니다.
// 2. 다른 UI를 재로드 하지 않아도 됩니다.
blogdetails.innerHTML = `
<div>
<h2>${data.title}</h2>
<p>${data.created_at}</p>
<p>${data.updated_at}</p>
<p>${data.id}</p>
<p>${data.author}</p>
<p>${data.content}</p>
</div>
`;
});
// 블로그 생성
btn_blogcreate.addEventListener('click', async () => {
const response = await fetch('http://127.0.0.1:8000/blogs', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: create_blogtitle.value,
content: create_blogcontent.value
})
});
});
// 블로그 수정
btn_blogedit.addEventListener('click', async () => {
// 만약에 edit_blogid.value 값이 비어있다면
if (edit_blogid.value === '') {
alert('블로그 ID를 입력해주세요.');
return;
}
const response = await fetch(`http://127.0.0.1:8000/blogs/${edit_blogid.value}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: edit_blogtitle.value,
content: edit_blogcontent.value
})
});
});
// 블로그 삭제
btn_blogdelete.addEventListener('click', async () => {
// 만약에 delete_blogid.value 값이 비어있다면
if (delete_blogid.value === '') {
alert('블로그 ID를 입력해주세요.');
return;
}
const response = await fetch(`http://127.0.0.1:8000/blogs/${delete_blogid.value}`, {
method: 'DELETE'
});
});
</script>
<script>
// 블로그 목록
const blogs = document.querySelector('.blogs');
const blogdetails = document.querySelector('.blogdetails');
const blogedit = document.querySelector('.blogedit');
const blogdelete = document.querySelector('.blogdelete');
// 버튼 및 입력창
const btn_blogs = document.querySelector('.btn_blogs');
const details_blogid = document.querySelector('.details_blogid');
const btn_blogdetils = document.querySelector('.btn_blogdetils');
const create_blogtitle = document.querySelector('.create_blogtitle');
const create_blogcontent = document.querySelector('.create_blogcontent');
const btn_blogcreate = document.querySelector('.btn_blogcreate');
const edit_blogid = document.querySelector('.edit_blogid');
const edit_blogtitle = document.querySelector('.edit_blogtitle');
const edit_blogcontent = document.querySelector('.edit_blogcontent');
const btn_blogedit = document.querySelector('.btn_blogedit');
const delete_blogid = document.querySelector('.delete_blogid');
const btn_blogdelete = document.querySelector('.btn_blogdelete');
// 블로그 목록
btn_blogs.addEventListener('click', async () => {
const response = await fetch('http://127.0.0.1:8000/blogs');
const data = await response.json();
console.log(data);
blogs.innerHTML = '';
data.forEach(element => {
blogs.innerHTML += `
<div>
<h2>${element.title}</h2>
<p>${element.created_at}</p>
<p>${element.updated_at}</p>
<p>${element.id}</p>
<p>${element.author}</p>
<p>${element.content}</p>
</div>
`;
});
});
// 블로그 상세 정보
btn_blogdetils.addEventListener('click', async () => {
// 만약에 details_blogid.value 값이 비어있다면
if (details_blogid.value === '') {
alert('블로그 ID를 입력해주세요.');
return;
}
const response = await fetch(`http://127.0.0.1:8000/blogs/${details_blogid.value}`);
const data = await response.json();
console.log(data);
// blogs.innerHTML = '';
// 이렇게 했을 때의 장점?
// 1. 사용자의 브라우저는 깜빡이지 않습니다.
// 2. 다른 UI를 재로드 하지 않아도 됩니다.
blogdetails.innerHTML = `
<div>
<h2>${data.title}</h2>
<p>${data.created_at}</p>
<p>${data.updated_at}</p>
<p>${data.id}</p>
<p>${data.author}</p>
<p>${data.content}</p>
</div>
`;
});
// 블로그 생성
btn_blogcreate.addEventListener('click', async () => {
const response = await fetch('http://127.0.0.1:8000/blogs', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: create_blogtitle.value,
content: create_blogcontent.value
})
});
});
// 블로그 수정
btn_blogedit.addEventListener('click', async () => {
// 만약에 edit_blogid.value 값이 비어있다면
if (edit_blogid.value === '') {
alert('블로그 ID를 입력해주세요.');
return;
}
const response = await fetch(`http://127.0.0.1:8000/blogs/${edit_blogid.value}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: edit_blogtitle.value,
content: edit_blogcontent.value
})
});
});
// 블로그 삭제
btn_blogdelete.addEventListener('click', async () => {
// 만약에 delete_blogid.value 값이 비어있다면
if (delete_blogid.value === '') {
alert('블로그 ID를 입력해주세요.');
return;
}
const response = await fetch(`http://127.0.0.1:8000/blogs/${delete_blogid.value}`, {
method: 'DELETE'
});
});
</script>
다음 챕터에서는 이 코드를 MPA(Multi Page Application) 방식으로 변경하여 구현해보도록 하겠습니다.