이번 포스팅에서는 MongoDB, Flask, Next.js 를 활용하여 동적 블로그 컴포넌트를 구축한 경험을 공유합니다.
해당 과정의 경우 현재 기존 티스토리 블로그 포스팅을 크롤링하여 MongoDB에 저장하고 이를 Next.js 프론트엔드에서 동적으로 출력할 수 있도록 한 방법을 설명합니다.
개요
해당 프로젝트에서는 MongoDB, Flask, Next.js 를 사용하여 동적 블로그 컴포넌트를 구축하였습니다.
기존 티스토리 블로그 포스팅을 크롤링하여 MongoDB에 저장하고, Flask API를 통해 Next.js 프론트엔드에서 데이터를 동적으로 렌더링할 수 있도록 구현하였습니다.
블로그 포스팅 크롤링 및 데이터 저장
우선 첫 번째 단계는 기존 티스토리 블로그에 포스팅한 데이터를 크롤링하여 MongoDB에 저장하는 과정입니다.
해당 과정을 설명하기 앞서 블로그 포스팅을 가져오기 위한 방법으로 최초 2가지 방법(Tistory API, 크롤링)이 있었으나, 크롤링을 선택한 이유는 Tistory API의 경우 올해 2월 종료된 것으로 확인되어, 파이썬을 이용한 크롤링을 통해 데이터를 수집하여 저장하였습니다.
이를 위해 파이썬의 "requests", "BeautifulSoup" 라이브러리를 활용하여 블로그 데이터를 크롤링하였으며, 크롤링된 데이터는 MongoDB의 "blogs" 컬렉션에 저장하는 과정을 구현하였습니다.
크롤링 및 데이터 저장 코드
import requests
from bs4 import BeautifulSoup
import time
import pymongo
from dotenv import load_dotenv
import os
def mongo_connect():
try:
load_dotenv()
mongo_username = os.environ.get('MONGO_USERNAME_BLOGS')
mongo_password = os.environ.get('MONGO_PASSWORD_BLOGS')
mongo_host = os.environ.get('MONGO_HOST')
mongo_port = os.environ.get('MONGO_PORT')
mongo_db = os.environ.get('MONGO_BLOGS_DB')
if not all([mongo_username, mongo_password, mongo_host, mongo_port, mongo_db]):
raise ValueError("환경 변수 중 하나 이상이 설정되지 않았습니다.")
client = pymongo.MongoClient(f"mongodb://{mongo_username}:{mongo_password}@{mongo_host}:{mongo_port}/{mongo_db}")
db = client[mongo_db]
collection = db['blogs']
collection.create_index("link", unique=True) # 중복 방지를 위한 인덱스 설정
return collection
except Exception as e:
print(f"몽고DB에 연결하는 동안 오류가 발생했습니다. {e}")
return None
def mongo_insert_one(collection, data):
try:
# 데이터 검증
required_fields = ["num", "image", "title", "description", "link", "category"]
if not all(field in data for field in required_fields):
raise ValueError("데이터 형식이 올바르지 않습니다.")
collection.insert_one(
{
"num": data["num"],
"image": data["image"],
"title": data["title"],
"description": data["description"],
"link": data["link"],
"category": data["category"]
}
)
print(f"데이터 삽입이 완료되었습니다. {data['num']}")
except pymongo.errors.DuplicateKeyError:
print(f"중복된 데이터 삽입 시도 {data['num']}")
return None
def get_total_page(url):
res = requests.get(url)
soup = BeautifulSoup(res.content, 'html.parser')
# 모든 페이지 번호를 가진 span 요소 선택
page_numbers = soup.select('#paging > li > a > span')
if page_numbers:
# 마지막 페이지 번호를 가져옴
try:
total_page = page_numbers[-2].get_text()
return int(total_page)
except (ValueError, IndexError) as e:
print(f"페이지 번호를 파싱하는 중 오류 발생: {e}")
return 18 # 기본 페이지로 18 반환
else:
print("페이지 번호를 찾을 수 없습니다.")
return 18 # 페이지가 없을 경우 기본 18 반환
db = mongo_connect()
if db is None:
print("몽고DB에 연결하는 동안 오류가 발생했습니다.")
else:
for page in range(1, get_total_page("https://maker5587.tistory.com/") + 1):
url = f"https://maker5587.tistory.com/?page={page}"
res = requests.get(url)
soup = BeautifulSoup(res.content, 'html.parser')
data = soup.select('.post')
for index, i in enumerate(data):
datas = dict() # 빈 딕셔너리로 초기화
try:
datas["num"] = (index + 1) + ((page - 1) * 5)
datas["image"] = i.select_one('.object-cover').get('data-src') if i.select_one('.object-cover') else "No Image"
datas["title"] = i.select_one('.title').get_text()
datas["description"] = i.select_one('.summary').get_text()
datas["category"] = i.select_one('.metainfo a').get_text().split('/')[0].replace('·', '').strip()
datas["link"] = f"https://maker5587.tistory.com{i.select_one('.title a').get('href')}"
mongo_insert_one(db, datas)
except Exception as e:
print(f"데이터를 가져오는 중 오류가 발생했습니다: {e}")
finally:
time.sleep(2)
주요 코드 설명
- MongoDB 연결 : "mongo_connect" 함수는 .env 파일(환경 변수 파일)을 참조하여 MongoDB에 연결하고 "blogs" 컬렉션을 반환하도록 설계한 함수입니다. 해당 함수에서 크롤링 데이터의 중복을 방지하기 위해 "link" 필드에 고유 인덱스를 생성하였습니다.
- 데이터 삽입 : "mongo_insert_one()" 함수는 크롤링된 데이터를 MongoDB에 삽입하는 함수입니다. 데이터 검증을 통해 필드가 누락된 경우 또는 중복된 데이터가 존재하는 경우 데이터가 삽입되지 않도록 구현하였습니다.
- 전체 페이지 수 계산 : "get_total_page()" 함수는 블로그의 전체 페이지 수를 계한하여 확인된 모든 페이지에서 데이터를 크롤링할 수 있도록 하였습니다.
- 메인 코드 : 각 블로그 페이지에서 포스팅된 데이터를 파싱하여 "num", "image", "title", "description", "category", "link" 필드로 구분하고 "blogs" 컬렉션에 저장하도록 구현하였습니다.
백엔드 코드 구현
Flask 를 사용하여 MongoDB에서 데이터를 가져오는 API를 아래와 같이 구축하였습니다.
해당 API는 프론트엔드에서 요청을 받아 요청된 카테고리 또는 전체 포스팅 내역을 반환하도록 구현하였습니다.
from flask import Blueprint, request, jsonify
from flask_cors import CORS
from pymongo import MongoClient
from dotenv import load_dotenv
import os
from bson import ObjectId
blogs_bp = Blueprint('blogs', __name__)
load_dotenv()
MONGO_USERNAME = os.environ.get('MONGO_USERNAME')
MONGO_PASSWORD = os.environ.get('MONGO_PASSWORD')
MONGO_HOST = os.environ.get('MONGO_HOST')
MONGO_PORT = os.environ.get('MONGO_PORT')
MONGO_DB = os.environ.get('MONGO_DB')
MONGO_COLLECTION = os.environ.get('MONGO_COLLECTION')
def connect_mongo():
client = MongoClient(f"mongodb://{MONGO_USERNAME}:{MONGO_PASSWORD}@{MONGO_HOST}:{MONGO_PORT}/{MONGO_DB}")
db = client[MONGO_DB]
collection = db[MONGO_COLLECTION]
return collection
def find_blogs_post(collection, category):
if category == "ALL":
blogs = collection.find()
return list(blogs)
try:
blogs = collection.find({"category": category})
return list(blogs)
except ValueError:
return None
def json_serializable(data):
for item in data:
item['_id'] = str(item['_id']) # Convert ObjectId to string
return data
@blogs_bp.route('/blogs', methods=['POST'])
def get_blogs():
db = connect_mongo()
category = request.json.get('category')
blogs_post = find_blogs_post(db, category)
blogs_post = json_serializable(blogs_post)
return jsonify(blogs_post)
※ 해당 코드 json_serializable 함수를 통해 데이터를 직렬화하는 과정을 추가하였는데 해당 과정이 필요한 이유는 MongoDB에는 문서의 고유 ID를 "_id" 필드에 저장하는데 기본적으로 ObjectId 라는 데이터 타입으로 저장됩니다.
그러나 해당 타입의 경우 JSON에서 기본적으로 직렬화할 수 없는 타입으로 확인되어, 해당 데이터를 JSON으로 반환하기 위해 ObjectId를 문자열로 변환해주는 과정을 추가하여 에러가 발생되지 않도록 구현하였습니다.
프론트엔드 코드 구현
위 Flask API를 통해 데이터를 응답받아 프론트 측에서 출력할 수 있도록 아래와 같이 Next.js Component 를 구현하였습니다.
해당 컴포넌트는 사용자가 카테고리를 선택할 경우 백엔드 측에 요청을 보내 해당 카테고리에 포함된 내역을 응답으로 받아 출력할 수 있도록 구현된 코드입니다.
또한, 추가로 가져온 데이터의 유효성을 확인하고, HTML을 렌더링할 때 XSS 공격을 방지하기 위해 DOMPurify 라이브러리를 활용하였습니다.
import React, { useEffect, useState } from "react";
import styles from "../styles/Blogs.module.css";
import axios from "axios";
import DOMPurify from 'dompurify';
const sanitizeURL = (url, defaultURL = "") => {
try {
const safeURL = new URL(DOMPurify.sanitize(url));
if (['http:', 'https:', 'data:'].includes(safeURL.protocol)) {
return safeURL.toString();
}
} catch (e) {
console.warn('Invalid URL:', url);
}
return defaultURL;
};
function Blogs() {
const [category, setCategory] = useState("ALL");
const [activeCategory, setActiveCategory] = useState("ALL"); // New state for tracking active category
const [blogs, setBlogs] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const blogsPerPage = 9;
const defaultImage = "https://t3.ftcdn.net/jpg/04/84/88/76/360_F_484887682_Mx57wpHG4lKrPAG0y7Q8Q7bJ952J3TTO.jpg";
const fetchBlogs = async (category) => {
try {
const requestData = { category };
const response = await axios.post(
"https://api.jongwook.xyz/blogs",
requestData,
{
headers: { "Content-Type": "application/json" }
}
);
const sortedBlogs = response.data.sort((a, b) => b.num - a.num);
const updatedBlogs = sortedBlogs.map(blog => ({
...blog,
image: sanitizeURL(blog.image, defaultImage),
link: sanitizeURL(blog.link, "#"),
title: DOMPurify.sanitize(blog.title),
description: DOMPurify.sanitize(blog.description)
}));
setBlogs(updatedBlogs);
setCurrentPage(1);
} catch (error) {
console.error(error);
} finally {
console.log("Blogs are fetched");
}
};
useEffect(() => {
fetchBlogs(category);
}, [category]);
const totalPages = Math.ceil(blogs.length / blogsPerPage);
const currentBlogs = blogs.slice(
(currentPage - 1) * blogsPerPage,
currentPage * blogsPerPage
);
const handleCategoryClick = (newCategory) => {
setCategory(newCategory);
setActiveCategory(newCategory); // Update active category
};
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
return (
<div className={styles.blogs} id="blogs">
<div className={styles.contentBox}>
<div className={styles.titleBox}>
<div className={styles.title}>BLOGS</div>
</div>
<div className={styles.blogCategorys}>
<button className={`${styles.categoryBtn} ${activeCategory === "ALL" ? styles.active : ""}`} onClick={() => handleCategoryClick("ALL")}>ALL</button>
<button className={`${styles.categoryBtn} ${activeCategory === "보안 관제 관련" ? styles.active : ""}`} onClick={() => handleCategoryClick("보안 관제 관련")}>보안 관제 관련</button>
<button className={`${styles.categoryBtn} ${activeCategory === "CERT" ? styles.active : ""}`} onClick={() => handleCategoryClick("CERT")}>CERT</button>
<button className={`${styles.categoryBtn} ${activeCategory === "Cloud" ? styles.active : ""}`} onClick={() => handleCategoryClick("Cloud")}>Cloud</button>
<button className={`${styles.categoryBtn} ${activeCategory === "Programming" ? styles.active : ""}`} onClick={() => handleCategoryClick("Programming")}>Programming</button>
<button className={`${styles.categoryBtn} ${activeCategory === "Project" ? styles.active : ""}`} onClick={() => handleCategoryClick("Project")}>Project</button>
</div>
<div className={styles.blogsContainer}>
{currentBlogs.map((blog) => (
<div key={blog._id} className={styles.blog}>
<img src={blog.image} alt={blog.title} className={styles.blogImage} />
<div className={styles.blogTitle}>{blog.title}</div>
<div className={styles.blogDescription}>{blog.description}</div>
<a href={blog.link} target="_blank" rel="noopener noreferrer">
자세히 보기
</a>
</div>
))}
</div>
<div className={styles.pagination}>
<button
className={styles.pageBtn}
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
이전 페이지
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
className={styles.pageBtn}
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
다음 페이지
</button>
</div>
</div>
</div>
);
}
export default Blogs;
위 코드에 대해 좀 더 상세하게 알아보도록 하겠습니다.
상태 관리 및 기본 설정
const [category, setCategory] = useState("ALL");
const [activeCategory, setActiveCategory] = useState("ALL");
const [blogs, setBlogs] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const blogsPerPage = 9;
- "category"와 "activeCategory" 상태 : 선택된 카테고리를 저장하는 상태
- "blogs" 상태 : Flask API로부터 받은 응답을 저장하는 상태
- "currentPage" 상태 : 현재 페이지를 관리하는 상태
- "blogsPerPage" : 한 페이지에 출력될 포스팅 갯수를 정의한 변수
데이터 검증 코드 구현
const sanitizeURL = (url, defaultURL = "") => {
try {
const safeURL = new URL(DOMPurify.sanitize(url));
if (['http:', 'https:', 'data:'].includes(safeURL.protocol)) {
return safeURL.toString();
}
} catch (e) {
console.warn('Invalid URL:', url);
}
return defaultURL;
};
- "sanitizeURL" 함수 : URL 유효성을 검증하여 정상적인 프로토콜을 포함한 데이터만 출력될 수 있도록 구현
- "DOMPurify" 라이브러리 : DOMPurify 라이브러리를 활용하여 악성 스크립트가 실행되지 않도록 구현(XSS 공격 방지)
백엔드에 블로그 데이터 요청
const fetchBlogs = async (category) => {
try {
const requestData = { category };
const response = await axios.post("https://api.jongwook.xyz/blogs", requestData, {
headers: { "Content-Type": "application/json" }
});
const sortedBlogs = response.data.sort((a, b) => b.num - a.num);
const updatedBlogs = sortedBlogs.map(blog => ({
...blog,
image: sanitizeURL(blog.image, defaultImage),
link: sanitizeURL(blog.link, "#"),
title: DOMPurify.sanitize(blog.title),
description: DOMPurify.sanitize(blog.description)
}));
setBlogs(updatedBlogs);
setCurrentPage(1);
} catch (error) {
console.error(error);
} finally {
console.log("Blogs are fetched");
}
};
- "fetchBlogs" 함수 : 선택된 카테고리의 블로그 데이터를 백엔드 측에 요청하여 응답으로 받아오는 함수
- 블로그 데이터의 경우 유효성 검사 및 DOMPurify 를 이용하여 안전한 이미지, 링크, 제목만 포함되도록 구현
블로그 포스팅 렌더링 및 상호작용 인터페이스 구현
return (
<div className={styles.blogs} id="blogs">
<div className={styles.contentBox}>
<div className={styles.titleBox}>
<div className={styles.title}>BLOGS</div>
</div>
<div className={styles.blogCategorys}>
<button className={`${styles.categoryBtn} ${activeCategory === "ALL" ? styles.active : ""}`} onClick={() => handleCategoryClick("ALL")}>ALL</button>
...
</div>
<div className={styles.blogsContainer}>
{currentBlogs.map((blog) => (
<div key={blog._id} className={styles.blog}>
<img src={blog.image} alt={blog.title} className={styles.blogImage} />
<div className={styles.blogTitle}>{blog.title}</div>
<div className={styles.blogDescription}>{blog.description}</div>
<a href={blog.link} target="_blank" rel="noopener noreferrer">
자세히 보기
</a>
</div>
))}
</div>
<div className={styles.pagination}>
<button className={styles.pageBtn} onClick={() => handlePageChange(currentPage - 1)} disabled={currentPage === 1}>이전 페이지</button>
<span>Page {currentPage} of {totalPages}</span>
<button className={styles.pageBtn} onClick={() => handlePageChange(currentPage + 1)} disabled={currentPage === totalPages}>다음 페이지</button>
</div>
</div>
</div>
);
- 카테고리 버튼 구현 및 활성화 상태 출력 : 선택된 카테고리에 따라 버튼 스타일이 변경되도록 구현하여 사용자가 현재 선택된 카테고리를 인지할 수 있도록 구현
트러블 슈팅 내역 : 크롤링 과정에서 발생한 문제 해결
이미지 데이터가 없는 데이터 크롤링 시 이전 데이터가 중복 출력되는 현상 해결
티스토리 블로그 포스팅 내역을 크롤링하는 과정에서 썸네일 이미지가 없는 게시글을 크롤링할 경우 이전 데이터가 중복되는 현상이 발생하였습니다.
문제 코드의 경우 아래와 같습니다.
import requests
from bs4 import BeautifulSoup
import time
import pymongo
from dotenv import load_dotenv
import os
def mongo_connect():
try:
load_dotenv()
mongo_username = os.environ.get('MONGO_USERNAME')
mongo_password = os.environ.get('MONGO_PASSWORD')
mongo_host = os.environ.get('MONGO_HOST')
mongo_port = os.environ.get('MONGO_PORT')
mongo_db = os.environ.get('MONGO_DB')
if not all([mongo_username, mongo_password, mongo_host, mongo_port, mongo_db]):
raise ValueError("환경 변수 중 하나 이상이 설정되지 않았습니다.")
client = pymongo.MongoClient(f"mongodb://{mongo_username}:{mongo_password}@{mongo_host}:{mongo_port}")
db = client[mongo_db]
return db
except Exception as e:
print(f"몽고DB에 연결하는 동안 오류가 발생했습니다. {e}")
return None
def mongo_insert_one(db, data):
try:
# 데이터 검증
required_fields = ["num", "image", "title", "description", "link"]
if not all(field in data for field in required_fields):
raise ValueError("데이터 형식이 올바르지 않습니다.")
db.blogs.insert_one(
{
"num": data["num"],
"image": data["image"],
"title": data["title"],
"description": data["description"],
"link": data["link"]
}
)
except Exception as e:
print(f"데이터를 삽입하는 동안 오류가 발생했습니다. {e}")
return None
for page in range(1, 19):
url = f"https://maker5587.tistory.com/?page={page}"
res = requests.get(url)
soup = BeautifulSoup(res.content, 'html.parser')
data = soup.select('.post')
for index, i in enumerate(data):
try:
datas = {
"num" = (index + 1) + ((page - 1) * 5)
"image" = i.select_one('.object-cover').get('data-src')
"title" = i.select_one('.title').get_text()
"description" = i.select_one('.summary').get_text()
"link" = f"https://maker5587.tistory.com{i.select_one('.title a').get('href')}"
}
print(f"{datas["num"]}. {datas["image"]} {datas["title"]} {datas["description"]} {datas["link"]}")
print('\n')
except AttributeError:
print(f"{datas["num"]}. No Image {datas["title"]} {datas["description"]} {datas["link"]}\n\n")
finally:
time.sleep(0.5)
이와 같은 코드를 사용하여 블로그 포스팅을 진행했을 시 썸네일 이미지가 없는 포스팅을 크롤링할 경우 이전 데이터가 중복으로 출력되는 현상을 경험했는데 원인은 아래와 같았습니다.
원인
try-except 블록 내부에서 datas 딕셔너리 데이터를 정의하는 방식과 관련이 있었습니다.
현재 datas 딕셔너리는 try 코드 블록 내 정의되어 있으며, AttributeError 발생 시 except 구문으로 이동하여 코드를 실행하는 구조로 되어 있는데 이때 딕셔너리의 일부 또는 전체가 재정의 되지 않은 상태에서 except 구문을 실행하여 이전에 크롤링된 데이터가 정의된 딕셔너리가 사용되어 다음과 같은 버그가 발생하는 것으로 확인되었습니다.
해결 방안
"datas" 딕셔너리를 "try-except" 블록 외부에서 초기화 하도록 구현하여 항상 새로운 데이터를 사용할 수 있도록 구현하여 해당 버그를 해결하였습니다.
datas = dict() # 빈 딕셔너리로 초기화
try:
datas["num"] = (index + 1) + ((page - 1) * 5)
datas["image"] = i.select_one('.object-cover').get('data-src') if i.select_one('.object-cover') else "No Image"
datas["title"] = i.select_one('.title').get_text()
datas["description"] = i.select_one('.summary').get_text()
datas["category"] = i.select_one('.metainfo a').get_text().split('/')[0].replace('·', '').strip()
datas["link"] = f"https://maker5587.tistory.com{i.select_one('.title a').get('href')}"
mongo_insert_one(db, datas)
except Exception as e:
print(f"데이터를 가져오는 중 오류가 발생했습니다: {e}")
이미지 데이터가 없는 경우 KeyError 발생 에러
import requests
from bs4 import BeautifulSoup
import time
import pymongo
from dotenv import load_dotenv
import os
def mongo_connect():
try:
load_dotenv()
mongo_username = os.environ.get('MONGO_USERNAME')
mongo_password = os.environ.get('MONGO_PASSWORD')
mongo_host = os.environ.get('MONGO_HOST')
mongo_port = os.environ.get('MONGO_PORT')
mongo_db = os.environ.get('MONGO_DB')
if not all([mongo_username, mongo_password, mongo_host, mongo_port, mongo_db]):
raise ValueError("환경 변수 중 하나 이상이 설정되지 않았습니다.")
client = pymongo.MongoClient(f"mongodb://{mongo_username}:{mongo_password}@{mongo_host}:{mongo_port}")
db = client[mongo_db]
return db
except Exception as e:
print(f"몽고DB에 연결하는 동안 오류가 발생했습니다. {e}")
return None
def mongo_insert_one(db, data):
try:
# 데이터 검증
required_fields = ["num", "image", "title", "description", "link"]
if not all(field in data for field in required_fields):
raise ValueError("데이터 형식이 올바르지 않습니다.")
db.blogs.insert_one(
{
"num": data["num"],
"image": data["image"],
"title": data["title"],
"description": data["description"],
"link": data["link"]
}
)
except Exception as e:
print(f"데이터를 삽입하는 동안 오류가 발생했습니다. {e}")
return None
for page in range(1, 19):
url = f"https://maker5587.tistory.com/?page={page}"
res = requests.get(url)
soup = BeautifulSoup(res.content, 'html.parser')
data = soup.select('.post')
for index, i in enumerate(data):
**datas = dict() # 빈 딕셔너리로 초기화**
try:
datas["num"] = (index + 1) + ((page - 1) * 5)
datas["image"] = i.select_one('.object-cover').get('data-src')
datas["title"] = i.select_one('.title').get_text()
datas["description"] = i.select_one('.summary').get_text()
datas["link"] = f"https://maker5587.tistory.com{i.select_one('.title a').get('href')}"
print(f'{datas["num"]}. {datas["image"]} {datas["title"]} {datas["description"]} {datas["link"]}')
print('\n')
except AttributeError:
# 이미지 데이터가 없을 경우
datas["image"] = "No Image" # 이미지 없는 경우 처리
print(f'{datas["num"]}. {datas["image"]} {datas["title"]} {datas["description"]} {datas["link"]}\n\n')
finally:
time.sleep(0.5)
위에서 try-except 구문 외부에서 딕셔너리를 초기화하여 첫 번째 버그 문제는 해결되었으나, 이미지 데이터가 없을 경우 except 코드 블록 내 코드가 실행되나, title, description, link 데이터는 정의되지 않은 상태이기에 KeyError 발생이 확인됨
해결 방안
"if i.select_one('.object-cover') else "No Image" 코드를 아래와 같이 datas[”image”] 값 정의 과정에 추가하여 오류를 처리하였습니다.
datas["image"] = i.select_one('.object-cover').get('data-src') if i.select_one('.object-cover') else "No Image"
이 코드를 사용하여 이미지가 없는 경우 "No Image"라는 기본 값을 설정했습니다.
'Project > resume' 카테고리의 다른 글
Flask와 Next.js를 이용한 인증 시스템 구현 (JWT, Authlib) (2) | 2024.08.29 |
---|---|
AI Chatbot 응답 개선 : 프론트엔드 코드 최적화 (0) | 2024.08.24 |
AI Chatbot 추천 질문 시스템 구현 / OpenAI Assistant Intruction 을 활용한 AI 응답 지정 (0) | 2024.08.17 |
[Github Actions] CI/CD 파이프라인 구축 / Github Actions 사용법 / Snyk Application CI 파이프라인 통합 / EC2 배포 자동화 (0) | 2024.08.13 |
MongoDB를 이용하여 Python에서 IP기반 요청 제한 구현 (0) | 2024.08.12 |