티스토리 뷰
요즘 AI를 활용한 개발이 점점 일상화되고 있습니다.
저는 이번에 OpenAI의 GPT와 함께 Firestore DB와 FastAPI를 활용한 커뮤니티 웹사이트를 직접 만들어봤습니다.
처음부터 끝까지 GPT에게 단계별로 요청하고,
그에 맞춰 코드 작성과 수정, 디버깅을 거쳐 완성된 실전 프로젝트였습니다 💻
이 글에서는 다음과 같은 기능들을 어떻게 구현했는지 정리해보려고 합니다:
- FastAPI로 웹 서버 구성
- Firestore와 연동하여 글/댓글 저장
- JavaScript 기반 클라이언트 페이지네이션
- 글 작성, 수정, 삭제 및 비밀번호 검증
- 댓글 작성 및 삭제 기능
- "아이피 앞 두 자리" 표현 등 커스텀 처리
실제 운영 가능한 커뮤니티의 핵심 기능을 구현했으며,
단순한 코드 복사 수준을 넘어 실제 동작하는 서비스 형태로 완성되었습니다.
[챗GPT와 함께 커뮤니티 웹사이트 만들기 Start!]
Q1. GPT 질문내용
firebase의 firestore db써서 커뮤니티 웹페이지 하나 만들어줘 웹서버는 fastapi로 구현해주고
자세한 동작은 다음과 같아
1) 홈페이지에 현재 등록된 글들을 db에서받아와서 테이블형태로 표시하기
글은 한번에 10개의 글까지만 보여줄수있고 그 이상이넘어가면 아래에 페이지 이동 숫자링크들이 생긴다.
(1페이지부터 5페이지까지 클릭할수있고 6페이지 이상되면 5 숫자 옆에 ▶버튼이 생김. 해당버튼 누르면 6페이지부터 10페이지까지 보이게 넘어감. 다시 앞으로돌아오고싶으면 숫자들 맨왼쪽에 ◀버튼눌러야됨. 테이블 pagination은 클라이언트단(javascript)로 구현하기)
글 목록은 다음과같이
[글번호 글제목 작성자닉네임(작성자아이피 앞2자리) 작성날짜]
글제목 누르면 해당 글의 상세페이지로 이동되게
2) 글쓰기
홈페이지 글목록 테이블 (페이징포함) 우측하단에 [글쓰기]버튼 배치
해당 [글쓰기]버튼 누르면 글쓰기 폼으로 이동
닉네임 및 비밀번호, 글 제목, 내용 입력란 있으면 됨
[등록]버튼 눌렀을시에 해당 닉네임 비밀번호 글제목 글내용과 해당 글이 작성된 날짜시간(2025-06-10 15:31:30.9832 이런식으로, 이걸 꼭 striing형태로 변환해서 넣어줘) 정보가 db에 저장되게하기 (참고로 닉네임과 비밀번호엔 한글, 영문 및 숫자만 입력 가능하게해야함.띄어쓰기도 비허용, 그리고 글제목엔 한글,영문 및 숫자랑 띄어쓰기만 입력되게 해줘 특수문자 미허용. 내용엔 제한 없어)
3) 글 상세페이지 (+글 수정,삭제)
아까 홈페이지에 있던 글목록중 글제목 클릭하면 글의 상세페이지로 이동돼
상단에 글 제목 작성날짜 글 작성자 (닉네임(아이피앞2자리)) 이렇게 있고
가운데부분에 내용 있고
그 밑에 댓글다는란이랑 댓글목록들 있으면 돼.
그리고 글 상세페이지에는 만약 그 글 작성자가 나라면 글 수정/삭제가 가능해야겠지?
그래서 수정이랑 삭제버튼도 하단에 배치해줘. 그리고 수정이나 삭제버튼 눌렀을때
해당 글의 비밀번호를 입력하는페이지로 이동되고 만약 비밀번호가 맞아야 삭제나 수정이 가능하게 해주면 돼. 비번틀리면 비밀번호가 일치하지 않습니다 알람띄우고 전페이지로 이동하게 해주면되고
그리고 수정페이지는 아까 글쓰기 페이지에서 살짝 변형하면돼
닉네임이랑 비밀번호 입력하는 페이지는 없고 오로지 제목이랑 내용만 수정되게 해주고
제목이랑 내용에는 기존의 제목하고 기존의 내용이 미리 입력란에 써져있도록 해줘
등록버튼 대신 수정버튼으로 해주고 수정버튼 누르면 해당 글의 컨텐츠(제목, 내용) 수정된게 다시 반영되게 db에연동 알지?
4) 댓글
위에 글 상세페이지에 하단에 댓글란이 있어야돼.
일단 댓글 입력란에 닉네임 비밀번호랑 댓글 입력란 이렇게 있고 등록버튼 누르면 해당 댓글이 db에 저장되게(댓글 작성자닉네임, 댓글 작성자 아이피 앞2자리, 댓글 내용, 댓글 작성일시, 이 댓글이 작성된 글의 id)
그리고 댓글 작성폼 밑엔 해당 글에 달린 댓글들이 쭉 표시되게 해줘
그리고 그 댓글들 옆엔 각각 [삭제]버튼 있고
그 삭제버튼을 누르면 댓글의 비밀번호를 입력하는페이지로 이동되고 비밀번호가 맞아야 삭제하게끔 해주면 돼.
---------------------
위가 기본 기능들이고 db는 google의 firebase에있는 firestore 쓴다고했지?
컬렉션명들이랑 필드들 정의해줄게
일단 컬렉션은 두개야: posts, comments
1. posts의 문서및 필드들
posts에는 글들의 정보들이 담긴 글들 db인데, 글 새로 추가할때 해당 글의 id를 문서 id로 해줘. 단 문서id는 그 문서가 작성된 날짜시간(클라에서 db로 넘겨받은거) 2025-06-10 15:31:30.9832 을 그 문서의 id로 지정해줄거야. 그러니까 문서id랑 해당 post의 필드에있는 id값이랑 그 필드에 있는 날짜시간값이랑 전부 똑같아야돼 무슨말인지 이해했지?
자 각 post별 필드는 다음과같이 구성돼
id 닉네임 아이피앞2자리 비밀번호 제목 내용 날짜시간
2. comments의 문서및 필드들
comments콜렉션도 각 문서 id를 위 posts에서 한것처럼 해줘 그 문서가 작성된 날짜시간(클라에서 db로 넘겨받은거) 2025-06-10 15:31:30.9832 이런걸로 (똑같이 문서id, 필드id값, 필드 날짜시간 값 다 일치하게)
각 comment별 필드는 다음과같아
id 닉네임 아이피앞2자리 비밀번호 내용 날짜시간
*참고) ip앞두자리는 무조건 클라이언트단(javascript)에서 생성된걸 fastapi 서버에 넘겨주고 그걸 db에 저장하고 그렇게 진행될거야.
예를들어서 글 작성 시 최초로 ip 앞2자리를 서버랑 db에 넘겨주고(이때 생성된 ip는 javascript에서 가져온 ip임)
댓글 작성시에도 최초 ip 앞 두자리를 서버랑db에넘겨줘.
그럼 이제 글목록에 작성자닉넴옆에 표시되는 아이피두자리같은경우 db에저장된 거 가져와서 표시해주는거고 상세페이지에 있는 작성자: 작성자닉네임(작성자아이피) 이것도 마찬가지로 db에있는거 가져오게해주는거고 댓글에 표시되있는것들도 다 마찬가지. 이해갔지?
총 필요한 파일은 6가지가 될거야 fastapi_server.py (메인 서버파일)이랑 templates안에 있는 home.html이랑 post_detail.html이랑 post_add.html이랑 post_edit.html 그리고 check_password.html
만약 만들다가 더 필요한 파일이 있으면 또 추가해주고
이 6개의 코드를 전부 다 만들어서 알려줘.
A1. GPT 답변내용
좋습니다. 요청하신 내용에 따라 Firestore를 사용하는 FastAPI 기반 커뮤니티 웹앱 전체를 아래와 같이 구현했습니다. 아래는 총 6개 파일로 구성되어 있으며, 각각의 코드와 역할을 설명해드릴게요.
✅ 1. fastapi_server.py
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from google.cloud import firestore
import uvicorn
app = FastAPI()
db = firestore.Client()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
docs = db.collection("posts").order_by("id", direction=firestore.Query.DESCENDING).stream()
posts = [doc.to_dict() for doc in docs]
return templates.TemplateResponse("home.html", {"request": request, "posts": posts})
@app.get("/post/add", response_class=HTMLResponse)
async def post_add_form(request: Request):
return templates.TemplateResponse("post_add.html", {"request": request})
@app.post("/post/add")
async def post_add(
request: Request,
nickname: str = Form(...),
password: str = Form(...),
ip2: str = Form(...),
title: str = Form(...),
content: str = Form(...),
timestamp: str = Form(...)
):
doc = {
"id": timestamp,
"닉네임": nickname,
"비밀번호": password,
"아이피앞2자리": ip2,
"제목": title,
"내용": content,
"날짜시간": timestamp,
}
db.collection("posts").document(timestamp).set(doc)
return RedirectResponse("/", status_code=303)
@app.get("/post/{post_id}", response_class=HTMLResponse)
async def post_detail(request: Request, post_id: str):
post = db.collection("posts").document(post_id).get().to_dict()
comments_ref = db.collection("comments").where("post_id", "==", post_id).order_by("id")
comments = [c.to_dict() for c in comments_ref.stream()]
return templates.TemplateResponse("post_detail.html", {"request": request, "post": post, "comments": comments})
@app.post("/comment/add")
async def add_comment(
request: Request,
post_id: str = Form(...),
nickname: str = Form(...),
password: str = Form(...),
ip2: str = Form(...),
content: str = Form(...),
timestamp: str = Form(...)
):
doc = {
"id": timestamp,
"post_id": post_id,
"닉네임": nickname,
"비밀번호": password,
"아이피앞2자리": ip2,
"내용": content,
"날짜시간": timestamp,
}
db.collection("comments").document(timestamp).set(doc)
return RedirectResponse(f"/post/{post_id}", status_code=303)
@app.get("/post/delete/{post_id}", response_class=HTMLResponse)
async def check_password(request: Request, post_id: str):
return templates.TemplateResponse("check_password.html", {"request": request, "post_id": post_id, "mode": "delete"})
@app.post("/post/delete/{post_id}")
async def delete_post(post_id: str = Form(...), password: str = Form(...)):
doc = db.collection("posts").document(post_id).get()
if not doc.exists or doc.to_dict()["비밀번호"] != password:
return RedirectResponse(f"/post/{post_id}?err=비밀번호가 일치하지 않습니다", status_code=303)
db.collection("posts").document(post_id).delete()
return RedirectResponse("/", status_code=303)
@app.get("/post/edit/{post_id}", response_class=HTMLResponse)
async def check_password_edit(request: Request, post_id: str):
return templates.TemplateResponse("check_password.html", {"request": request, "post_id": post_id, "mode": "edit"})
@app.post("/post/edit/{post_id}")
async def edit_post_form(post_id: str = Form(...), password: str = Form(...)):
doc = db.collection("posts").document(post_id).get()
if not doc.exists or doc.to_dict()["비밀번호"] != password:
return RedirectResponse(f"/post/{post_id}?err=비밀번호가 일치하지 않습니다", status_code=303)
post = doc.to_dict()
return templates.TemplateResponse("post_edit.html", {"request": Request, "post": post})
@app.post("/post/update")
async def update_post(
post_id: str = Form(...),
title: str = Form(...),
content: str = Form(...)
):
db.collection("posts").document(post_id).update({"제목": title, "내용": content})
return RedirectResponse(f"/post/{post_id}", status_code=303)
@app.get("/comment/delete/{comment_id}/{post_id}", response_class=HTMLResponse)
async def check_comment_password(request: Request, comment_id: str, post_id: str):
return templates.TemplateResponse("check_password.html", {"request": request, "comment_id": comment_id, "post_id": post_id, "mode": "delete_comment"})
@app.post("/comment/delete/{comment_id}/{post_id}")
async def delete_comment(comment_id: str = Form(...), post_id: str = Form(...), password: str = Form(...)):
doc = db.collection("comments").document(comment_id).get()
if not doc.exists or doc.to_dict()["비밀번호"] != password:
return RedirectResponse(f"/post/{post_id}?err=비밀번호가 일치하지 않습니다", status_code=303)
db.collection("comments").document(comment_id).delete()
return RedirectResponse(f"/post/{post_id}", status_code=303)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
✅ 2. templates/home.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>커뮤니티</title>
<style>
table, th, td { border: 1px solid black; border-collapse: collapse; padding: 5px; }
.pagination { margin-top: 20px; }
.pagination button { margin: 0 2px; }
</style>
</head>
<body>
<h2>게시판</h2>
<table id="postTable">
<thead>
<tr>
<th>글번호</th>
<th>제목</th>
<th>작성자</th>
<th>작성일자</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination" id="pagination"></div>
<button onclick="location.href='/post/add'" style="position: fixed; bottom: 20px; right: 20px;">글쓰기</button>
<script>
const posts = {{ posts | tojson }};
const rowsPerPage = 10;
let currentPage = 1;
function renderTable(page) {
const tbody = document.querySelector("#postTable tbody");
tbody.innerHTML = "";
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
const sliced = posts.slice(start, end);
sliced.forEach((post, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${start + index + 1}</td>
<td><a href="/post/${post.id}">${post.제목}</a></td>
<td>${post.닉네임}(${post.아이피앞2자리})</td>
<td>${post.날짜시간}</td>
`;
tbody.appendChild(row);
});
}
function renderPagination() {
const totalPages = Math.ceil(posts.length / rowsPerPage);
const pagination = document.getElementById("pagination");
pagination.innerHTML = "";
let startPage = Math.floor((currentPage - 1) / 5) * 5 + 1;
let endPage = Math.min(startPage + 4, totalPages);
if (startPage > 1) {
const prev = document.createElement("button");
prev.innerText = "◀";
prev.onclick = () => { currentPage = startPage - 1; update(); };
pagination.appendChild(prev);
}
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement("button");
btn.innerText = i;
btn.disabled = (i === currentPage);
btn.onclick = () => { currentPage = i; update(); };
pagination.appendChild(btn);
}
if (endPage < totalPages) {
const next = document.createElement("button");
next.innerText = "▶";
next.onclick = () => { currentPage = endPage + 1; update(); };
pagination.appendChild(next);
}
}
function update() {
renderTable(currentPage);
renderPagination();
}
update();
</script>
</body>
</html>
✅ 3. templates/post_add.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>글쓰기</title></head>
<body>
<h2>글쓰기</h2>
<form action="/post/add" method="post" onsubmit="return validateForm();">
닉네임: <input type="text" name="nickname" pattern="[가-힣a-zA-Z0-9]+" required><br>
비밀번호: <input type="password" name="password" pattern="[가-힣a-zA-Z0-9]+" required><br>
제목: <input type="text" name="title" pattern="[가-힣a-zA-Z0-9 ]+" required><br>
내용: <textarea name="content" required></textarea><br>
<input type="hidden" name="ip2" id="ip2">
<input type="hidden" name="timestamp" id="timestamp">
<button type="submit">등록</button>
</form>
<script>
async function fetchIP2() {
const res = await fetch("https://api.ipify.org?format=json");
const data = await res.json();
document.getElementById("ip2").value = data.ip.split('.')[0];
}
document.getElementById("timestamp").value = new Date().toISOString();
fetchIP2();
</script>
</body>
</html>
✅ 4. templates/post_detail.html, post_edit.html, check_password.html 코드는 다음 응답에 이어서 제공합니다.
이어서 나머지 템플릿 파일들입니다:
✅ 4. templates/post_detail.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>{{ post.제목 }}</title></head>
<body>
<h2>{{ post.제목 }}</h2>
<p>작성자: {{ post.닉네임 }}({{ post.아이피앞2자리 }}) | {{ post.날짜시간 }}</p>
<hr>
<div>{{ post.내용 }}</div>
<hr>
<div>
<a href="/post/edit/{{ post.id }}">수정</a> |
<a href="/post/delete/{{ post.id }}">삭제</a>
</div>
<hr>
<h3>댓글</h3>
<form action="/comment/add" method="post">
닉네임: <input type="text" name="nickname" pattern="[가-힣a-zA-Z0-9]+" required><br>
비밀번호: <input type="password" name="password" pattern="[가-힣a-zA-Z0-9]+" required><br>
댓글내용: <input type="text" name="content" required><br>
<input type="hidden" name="post_id" value="{{ post.id }}">
<input type="hidden" name="ip2" id="ip2">
<input type="hidden" name="timestamp" id="timestamp">
<button type="submit">등록</button>
</form>
<ul>
{% for comment in comments %}
<li>{{ comment.닉네임 }}({{ comment.아이피앞2자리 }}) | {{ comment.날짜시간 }}<br>
{{ comment.내용 }}
<a href="/comment/delete/{{ comment.id }}/{{ post.id }}">[삭제]</a></li>
{% endfor %}
</ul>
<script>
async function fetchIP2() {
const res = await fetch("https://api.ipify.org?format=json");
const data = await res.json();
document.getElementById("ip2").value = data.ip.split('.')[0];
}
document.getElementById("timestamp").value = new Date().toISOString();
fetchIP2();
</script>
</body>
</html>
✅ 5. templates/post_edit.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>글 수정</title></head>
<body>
<h2>글 수정</h2>
<form action="/post/update" method="post">
제목: <input type="text" name="title" value="{{ post.제목 }}" pattern="[가-힣a-zA-Z0-9 ]+" required><br>
내용: <textarea name="content" required>{{ post.내용 }}</textarea><br>
<input type="hidden" name="post_id" value="{{ post.id }}">
<button type="submit">수정</button>
</form>
</body>
</html>
✅ 6. templates/check_password.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>비밀번호 확인</title></head>
<body>
<h2>비밀번호 확인</h2>
<form action="{% if mode == 'delete' %}/post/delete/{{ post_id }}{% elif mode == 'edit' %}/post/edit/{{ post_id }}{% else %}/comment/delete/{{ comment_id }}/{{ post_id }}{% endif %}" method="post">
비밀번호: <input type="password" name="password" required>
{% if mode == 'delete_comment' %}
<input type="hidden" name="comment_id" value="{{ comment_id }}">
<input type="hidden" name="post_id" value="{{ post_id }}">
{% else %}
<input type="hidden" name="post_id" value="{{ post_id }}">
{% endif %}
<button type="submit">확인</button>
</form>
</body>
</html>
🔧 Firestore 구조 요약 (최종 확인용)
posts (컬렉션)
- 문서 ID = 날짜시간 string (예: 2025-06-10 15:31:30.9832)
- 필드들:
- id, 닉네임, 비밀번호, 아이피앞2자리, 제목, 내용, 날짜시간
comments (컬렉션)
- 문서 ID = 날짜시간 string
- 필드들:
- id, post_id, 닉네임, 비밀번호, 아이피앞2자리, 내용, 날짜시간
필요한 경우 CSS 정리나 추가적 기능 (검색, 추천 등)도 연동 가능합니다. 지금 이 상태로 Firestore와 연동해서 실행하면 기본 커뮤니티가 완성됩니다. 어떤 부분을 추가하거나 개선하고 싶은지 알려주세요!
Q2. GPT 질문내용
db = firestore.Client()할때 내 파이어베이스 프로젝트 관련 정보들이 필요하지않아? 내 firebase에 community-web이라는 프로젝트를 새로 생성할거야 그리고 firestore추가할거고.. 그 절차 및 파이썬 서버파일에 넣어야되는 파일이나 코드 수정할부분 알려줘
A2. GPT 답변내용
맞습니다! db = firestore.Client()를 정상 작동시키려면 Firebase 프로젝트 인증 정보가 필요합니다. community-web이라는 Firebase 프로젝트를 만들고 Firestore를 연동하려면 아래 절차를 따르세요.
✅ 1. Firestore 프로젝트 생성 및 서비스 계정 키 발급 절차
🔹 (1) Firebase 프로젝트 생성
- Firebase 콘솔 접속
- 프로젝트 생성 → 이름: community-web
- Google Analytics는 사용하지 않아도 됩니다
🔹 (2) Firestore 활성화
- Firebase 콘솔 > community-web 프로젝트 > 좌측 메뉴 Firestore Database
- 시작하기 → 테스트 모드 선택 (나중에 보안룰 변경 가능)
- 위치는 서울(asia-northeast3) 추천
🔹 (3) 서비스 계정 키 발급
- Firebase 콘솔 > 좌측 하단 프로젝트 설정 클릭
- 서비스 계정 탭으로 이동
- "새 비공개 키 생성" 클릭 → JSON 파일 다운로드됨
이 JSON 파일은 매우 중요하므로 안전한 곳에 저장하세요. 이름 예: firebase_admin_sdk.json
✅ 2. 파이썬 서버 코드에서 인증 연동
🔹 (1) 필요한 패키지 설치
pip install google-cloud-firestore
🔹 (2) 서버 코드 수정 (인증 적용)
fastapi_server.py 맨 위에 아래 코드 추가:
import os
import firebase_admin
from firebase_admin import credentials, firestore
# 경로는 실제 다운로드한 JSON 키의 경로로 바꿔야 함
cred = credentials.Certificate("firebase_admin_sdk.json")
firebase_admin.initialize_app(cred)
db = firestore.client()
기존의 db = firestore.Client()는 제거하고, 위처럼 firebase_admin을 이용해 초기화해야 Firestore가 정상적으로 동작합니다.
✅ 파일 배치 예시
your_project/
│
├── firebase_admin_sdk.json ← 다운로드한 키 파일
├── fastapi_server.py
└── templates/
├── home.html
├── post_add.html
├── post_detail.html
├── post_edit.html
└── check_password.html
✅ 마무리
이제 uvicorn fastapi_server:app --reload로 서버를 실행하면 Firestore 인증을 통해 community_web 프로젝트와 연결된 Firestore에 접근할 수 있게 됩니다.
필요하시면 Firestore 보안 룰 설정이나 배포 관련 설정도 도와드릴게요. 계속 이어서 도와드릴까요?
우선 위 코드가 정상동작 하는지 실행시켜 보기 위해 구글 firebase console에서 해당 community-web프로젝트를 새로 생성하고 비공개 키를 발급받아 해당 디렉토리에 넣어야 합니다.
해당 과정은 다음과같습니다. (GPT가 설명해준 절차대로 실행)
(1) 파이어베이스 콘솔로이동하여 community-web이라는 프로젝트를 생성하고 빌드에서 firestore database를 선택하면
아래와같은 창이 뜬다. [데이터베이스 만들기]를 클릭하여 몇가지 설정 후 데이터베이스를 생성하도록 한다.
(2) 프로젝트 설정에서 비공개 키를 발급받아 파일로저장한다
(3)다운받은 json파일의 파일이름을 변경(여기서는 firebase_admin_sdk.json로)하여 루트 디렉토리(fastapi_server.py파일이 위치해있는 폴더)로 옮긴다
위와같이 파이어스토어 단에서의 작업도 다 마무리하고 해당 디렉토리 내에서 uvicorn fastapi_server:app --reload 명령어를 실행한후 127.0.0.1:8000으로 웹을 열면 웹이 뜬걸 확인할 수있습니다.
그런데 몇가지 버그들이 발견되었습니다.
GPT가 알려준 코드로 웹서버 띄운 후 커뮤니티 활동 중 발견한 버그들 및 해결과정
1) 파라미터 중 comment_id나 post_id의 자료형 정의
@app.post("/post/delete/{post_id}")
async def delete_post(post_id: str = Form(...), password: str = Form(...)):
예를들어 post_id는 Form형태로 받아오는게 아니다 그냥 post_id: str, 이렇게 정의해줘야 한다
이런 부분 때문에 에러가 났음 --> gpt에 물어서 해당 부분들 몇군데 전부 수정
2) firebase에서의 복합인덱스 문제
Firestore에서 where + order_by를 함께 쓸 때는 복합 인덱스(composite index)가 반드시 있어야 함
파이어스토어에서 직접 인덱싱 해주는것보다 파이썬 내에서 리스트를 정렬하는게 더 쉽기 때문에
order_by('id')제거한다음에 만들어진 comments 딕셔너리 리스트에서 각 딕셔너리의 id를 내림차순으로 정렬하는걸로 fastapi_server.py 내 코드를 수정함
- 에러메시지 복붙하여 gpt에 질문하여 해결: gpt 답글 일부 발췌 ↓
order_by("id")를 제거하고 나서, 파이썬에서 id 필드를 기준으로 정렬하면 Firestore 인덱스 없이도 원하는 정렬을 구현할 수 있습니다.
✅ 적용 예시 (내림차순 정렬)
fastapi_server.py의 post_detail() 함수에서 아래처럼 수정하면 됩니다:
🔧 기존 코드
comments_ref = db.collection("comments").where("post_id", "==", post_id).order_by("id")
comments = [c.to_dict() for c in comments_ref.stream()]
✅ 수정된 코드
comments_ref = db.collection("comments").where("post_id", "==", post_id)
comments = [c.to_dict() for c in comments_ref.stream()]
comments.sort(key=lambda x: x["id"], reverse=True)
💡 정렬 기준에 id 필드를 쓰는 이유?
- timestamp가 2025-06-10T07:51:04.510Z 같은 ISO 형식 문자열이기 때문에, 문자열 기준으로도 시간순 정렬이 정확히 됩니다.
- 즉, id = timestamp로 썼기 때문에 별도 datetime 변환 없이도 문자열 정렬로 최신 순서가 유지됩니다.
🟢 결론
- ✅ Firestore 인덱스 없이 댓글 내림차순 정렬 가능
- ✅ order_by() 제거하면 에러 해결
- ✅ 파이썬 코드에서 .sort()로 유연하게 제어 가능
3) 글 상세페이지에서 글 수정페이지로 이동 시 에러발생
- 에러메시지 복붙하여 gpt에 질문하여 해결: gpt 답글 일부 발췌 ↓
에러 메시지 핵심은 여기입니다:
TypeError: string indices must be integers, not 'dict'
그리고 이 줄이 문제를 일으키고 있습니다:
return templates.TemplateResponse("post_edit.html", {"request": Request, "post": post})
✅ 문제 원인
Request
이건 FastAPI의 Request 클래스 자체를 넘기고 있습니다.
**인스턴스가 아니라 타입(class)**이기 때문에 템플릿 내부에서 .get() 호출이 불가능해서 오류가 납니다.
✅ 해결 방법
@app.post("/post/edit/{post_id}")
async def edit_post_form(request: Request, post_id: str, password: str = Form(...)):
...
return templates.TemplateResponse("post_edit.html", {"request": request, "post": post})
🔧 즉, 함수 파라미터에 request: Request를 추가하고,
템플릿 렌더링 시 Request 클래스가 아니라 해당 인스턴스를 넘겨줘야 합니다.
🧩 전체 수정 전/후 비교
❌ 잘못된 버전
@app.post("/post/edit/{post_id}")
async def edit_post_form(post_id: str, password: str = Form(...)):
...
return templates.TemplateResponse("post_edit.html", {"request": Request, "post": post})
✅ 수정된 버전
@app.post("/post/edit/{post_id}")
async def edit_post_form(request: Request, post_id: str, password: str = Form(...)):
...
return templates.TemplateResponse("post_edit.html", {"request": request, "post": post})
🟢 정리
항목 설명
문제 원인 | Request 클래스를 템플릿에 넘김 |
해결 방법 | 함수 파라미터에 request: Request 추가 후 인스턴스를 넘겨줌 |
수정 위치 | edit_post_form() 함수 내부 |
4)
- 아이피 앞 2자리가 나와야되는데 앞 1자리만 나오는 문제
- 비밀번호가 일치하지 않습니다 에러메시지 띄워야함 (현재는 비번 불일치 시 그냥 이전페이지로 돌아감 아무 노티스가 없음)
- 위 추가 요구사항들 gpt에 질문하여 해결: gpt 답글 일부 발췌 ↓
좋습니다! 아래에 두 가지 요청에 따라 정확히 수정해야 할 위치만 간단명료하게 정리해드릴게요.
✅ 1. "아이피 앞 두 자리" 버그 수정
📍 수정 대상: post_add.html + post_detail.html (JavaScript 내 IP 파싱 부분)
✅ 변경 전 (현재 코드)
document.getElementById("ip2").value = data.ip.split('.')[0];
✅ 변경 후 (앞 두 자리로)
const parts = data.ip.split('.');
document.getElementById("ip2").value = parts[0] + "." + parts[1];
✅ 2. 비밀번호 틀렸을 때 상세페이지 상단에 메시지 출력
📍 수정 대상: fastapi_server.py → /post/{post_id} 라우터
✅ 변경 전
return templates.TemplateResponse("post_detail.html", {"request": request, "post": post, "comments": comments})
✅ 변경 후 (쿼리 파라미터에서 err 메시지 받아 전달)
err = request.query_params.get("err", "")
return templates.TemplateResponse("post_detail.html", {
"request": request,
"post": post,
"comments": comments,
"err": err
})
📍 수정 대상: post_detail.html 상단
✅ 변경 후 추가할 부분 (<body> 안 맨 위에 추가)
{% if err %}
<div style="color:red; font-weight:bold;">{{ err }}</div>
{% endif %}
이렇게 하면 비밀번호 오류 발생 시, 상세페이지에 다음처럼 뜨게 됩니다:
비밀번호가 일치하지 않습니다
🟢 요약 체크리스트
항목 수정 위치 조치
아이피 앞 2자리 표시 | post_add.html, post_detail.html | ip.split('.')[0] → parts[0] + "." + parts[1] |
비밀번호 오류 메시지 전송 | fastapi_server.py, /post/{post_id} 라우터 | err = request.query_params.get(...) 추가 |
비밀번호 오류 메시지 표시 | post_detail.html 상단 | {% if err %} ... {% endif %} 표시 블록 추가 |
필요 시 수정된 전체 파일도 패치본으로 정리해드릴 수 있습니다. 요청만 주세요!
이렇게 모든 수정사항들을 반영하여 만들어진 최종 코드들 및 폴더구조는 다음과 같습니다.
📁 최종 폴더 구조
community_project/
│
├── fastapi_server.py # FastAPI 메인 서버 코드
├── firebase_admin_sdk.json # Firebase 서비스 계정 키 (절대 노출 금지)
│
├── static/ # 정적 파일 폴더 (CSS/JS 등 필요한 경우 사용)
│
├── templates/ # HTML 템플릿 폴더
│ ├── home.html # 메인 페이지 (글 목록 + 페이지네이션)
│ ├── post_add.html # 글 작성 페이지
│ ├── post_detail.html # 글 상세보기 + 댓글
│ ├── post_edit.html # 글 수정 페이지
│ └── check_password.html # 글/댓글 삭제·수정용 비밀번호 확인 페이지
📌 각 파일 설명
파일/폴더 설명
fastapi_server.py | 전체 라우터 및 Firestore 연동 포함한 FastAPI 웹 서버 |
firebase_admin_sdk.json | Firebase 콘솔에서 발급한 서비스 계정 키 (절대 외부에 공개 금지) |
templates/home.html | 글 목록 및 페이징 UI |
templates/post_add.html | 글 작성 폼 |
templates/post_detail.html | 글 내용 + 댓글 목록 + 댓글 작성 |
templates/post_edit.html | 글 수정 폼 (기존 내용 미리 채워짐) |
templates/check_password.html | 글/댓글 수정/삭제 시 비밀번호 확인용 페이지 |
static/ | 필요 시 CSS/JS 등 추가 가능 (현재는 미사용) |
그리고 현재까지의 모든 코드들은 아래와 같습니다.
✅ 1. fastapi_server.py
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
#from google.cloud import firestore
import os
import firebase_admin
from firebase_admin import credentials, firestore
import uvicorn
app = FastAPI()
# 경로는 실제 다운로드한 JSON 키의 경로
cred = credentials.Certificate("firebase_admin_sdk.json")
firebase_admin.initialize_app(cred)
db = firestore.client()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
docs = db.collection("posts").order_by("id", direction=firestore.Query.DESCENDING).stream()
posts = [doc.to_dict() for doc in docs]
return templates.TemplateResponse("home.html", {"request": request, "posts": posts})
@app.get("/post/add", response_class=HTMLResponse)
async def post_add_form(request: Request):
return templates.TemplateResponse("post_add.html", {"request": request})
@app.post("/post/add")
async def post_add(
request: Request,
nickname: str = Form(...),
password: str = Form(...),
ip2: str = Form(...),
title: str = Form(...),
content: str = Form(...),
timestamp: str = Form(...)
):
doc = {
"id": timestamp,
"닉네임": nickname,
"비밀번호": password,
"아이피앞2자리": ip2,
"제목": title,
"내용": content,
"날짜시간": timestamp,
}
db.collection("posts").document(timestamp).set(doc)
return RedirectResponse("/", status_code=303)
@app.get("/post/{post_id}", response_class=HTMLResponse)
async def post_detail(request: Request, post_id: str):
post = db.collection("posts").document(post_id).get().to_dict()
comments_ref = db.collection("comments").where("post_id", "==", post_id)
comments = [c.to_dict() for c in comments_ref.stream()]
comments.sort(key=lambda x: x["id"], reverse=True)
err = request.query_params.get("err", "")
return templates.TemplateResponse("post_detail.html", {
"request": request,
"post": post,
"comments": comments,
"err": err
})
@app.post("/comment/add")
async def add_comment(
request: Request,
post_id: str = Form(...),
nickname: str = Form(...),
password: str = Form(...),
ip2: str = Form(...),
content: str = Form(...),
timestamp: str = Form(...)
):
doc = {
"id": timestamp,
"post_id": post_id,
"닉네임": nickname,
"비밀번호": password,
"아이피앞2자리": ip2,
"내용": content,
"날짜시간": timestamp,
}
db.collection("comments").document(timestamp).set(doc)
return RedirectResponse(f"/post/{post_id}", status_code=303)
@app.get("/post/delete/{post_id}", response_class=HTMLResponse)
async def check_password(request: Request, post_id: str):
return templates.TemplateResponse("check_password.html", {"request": request, "post_id": post_id, "mode": "delete"})
@app.post("/post/delete/{post_id}")
async def delete_post(post_id: str, password: str = Form(...)):
doc = db.collection("posts").document(post_id).get()
if not doc.exists or doc.to_dict()["비밀번호"] != password:
return RedirectResponse(f"/post/{post_id}?err=비밀번호가 일치하지 않습니다", status_code=303)
db.collection("posts").document(post_id).delete()
return RedirectResponse("/", status_code=303)
@app.get("/post/edit/{post_id}", response_class=HTMLResponse)
async def check_password_edit(request: Request, post_id: str):
return templates.TemplateResponse("check_password.html", {"request": request, "post_id": post_id, "mode": "edit"})
@app.post("/post/edit/{post_id}")
async def edit_post_form(request: Request, post_id: str, password: str = Form(...)):
doc = db.collection("posts").document(post_id).get()
if not doc.exists or doc.to_dict()["비밀번호"] != password:
return RedirectResponse(f"/post/{post_id}?err=비밀번호가 일치하지 않습니다", status_code=303)
post = doc.to_dict()
return templates.TemplateResponse("post_edit.html", {"request": request, "post": post})
@app.post("/post/update")
async def update_post(
post_id: str = Form(...),
title: str = Form(...),
content: str = Form(...)
):
db.collection("posts").document(post_id).update({"제목": title, "내용": content})
return RedirectResponse(f"/post/{post_id}", status_code=303)
@app.get("/comment/delete/{comment_id}/{post_id}", response_class=HTMLResponse)
async def check_comment_password(request: Request, comment_id: str, post_id: str):
return templates.TemplateResponse("check_password.html", {"request": request, "comment_id": comment_id, "post_id": post_id, "mode": "delete_comment"})
@app.post("/comment/delete/{comment_id}/{post_id}")
async def delete_comment(comment_id: str, post_id: str, password: str = Form(...)):
doc = db.collection("comments").document(comment_id).get()
if not doc.exists or doc.to_dict()["비밀번호"] != password:
return RedirectResponse(f"/post/{post_id}?err=비밀번호가 일치하지 않습니다", status_code=303)
db.collection("comments").document(comment_id).delete()
return RedirectResponse(f"/post/{post_id}", status_code=303)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
✅ 2. templates/home.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>커뮤니티</title>
<style>
table, th, td { border: 1px solid black; border-collapse: collapse; padding: 5px; }
.pagination { margin-top: 20px; }
.pagination button { margin: 0 2px; }
</style>
</head>
<body>
<h2>게시판</h2>
<table id="postTable">
<thead>
<tr>
<th>글번호</th>
<th>제목</th>
<th>작성자</th>
<th>작성일자</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination" id="pagination"></div>
<button onclick="location.href='/post/add'" style="position: fixed; bottom: 20px; right: 20px;">글쓰기</button>
<script>
const posts = {{ posts | tojson }};
const rowsPerPage = 10;
let currentPage = 1;
function renderTable(page) {
const tbody = document.querySelector("#postTable tbody");
tbody.innerHTML = "";
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
const sliced = posts.slice(start, end);
sliced.forEach((post, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${start + index + 1}</td>
<td><a href="/post/${post.id}">${post.제목}</a></td>
<td>${post.닉네임}(${post.아이피앞2자리})</td>
<td>${post.날짜시간}</td>
`;
tbody.appendChild(row);
});
}
function renderPagination() {
const totalPages = Math.ceil(posts.length / rowsPerPage);
const pagination = document.getElementById("pagination");
pagination.innerHTML = "";
let startPage = Math.floor((currentPage - 1) / 5) * 5 + 1;
let endPage = Math.min(startPage + 4, totalPages);
if (startPage > 1) {
const prev = document.createElement("button");
prev.innerText = "◀";
prev.onclick = () => { currentPage = startPage - 1; update(); };
pagination.appendChild(prev);
}
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement("button");
btn.innerText = i;
btn.disabled = (i === currentPage);
btn.onclick = () => { currentPage = i; update(); };
pagination.appendChild(btn);
}
if (endPage < totalPages) {
const next = document.createElement("button");
next.innerText = "▶";
next.onclick = () => { currentPage = endPage + 1; update(); };
pagination.appendChild(next);
}
}
function update() {
renderTable(currentPage);
renderPagination();
}
update();
</script>
</body>
</html>
✅ 3. templates/post_add.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>글쓰기</title></head>
<body>
<h2>글쓰기</h2>
<form action="/post/add" method="post" onsubmit="return validateForm();">
닉네임: <input type="text" name="nickname" pattern="[가-힣a-zA-Z0-9]+" required><br>
비밀번호: <input type="password" name="password" pattern="[가-힣a-zA-Z0-9]+" required><br>
제목: <input type="text" name="title" pattern="[가-힣a-zA-Z0-9 ]+" required><br>
내용: <textarea name="content" required></textarea><br>
<input type="hidden" name="ip2" id="ip2">
<input type="hidden" name="timestamp" id="timestamp">
<button type="submit">등록</button>
</form>
<script>
async function fetchIP2() {
const res = await fetch("https://api.ipify.org?format=json");
const data = await res.json();
const parts = data.ip.split('.');
document.getElementById("ip2").value = parts[0] + "." + parts[1];
}
document.getElementById("timestamp").value = new Date().toISOString();
fetchIP2();
</script>
</body>
</html>
✅ 4. templates/post_detail.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>{{ post.제목 }}</title></head>
<body>
{% if err %}
<div style="color:red; font-weight:bold;">{{ err }}</div>
{% endif %}
<h2>{{ post.제목 }}</h2>
<p>작성자: {{ post.닉네임 }}({{ post.아이피앞2자리 }}) | {{ post.날짜시간 }}</p>
<hr>
<div>{{ post.내용 }}</div>
<hr>
<div>
<a href="/post/edit/{{ post.id }}">수정</a> |
<a href="/post/delete/{{ post.id }}">삭제</a>
</div>
<hr>
<h3>댓글</h3>
<form action="/comment/add" method="post">
닉네임: <input type="text" name="nickname" pattern="[가-힣a-zA-Z0-9]+" required><br>
비밀번호: <input type="password" name="password" pattern="[가-힣a-zA-Z0-9]+" required><br>
댓글내용: <input type="text" name="content" required><br>
<input type="hidden" name="post_id" value="{{ post.id }}">
<input type="hidden" name="ip2" id="ip2">
<input type="hidden" name="timestamp" id="timestamp">
<button type="submit">등록</button>
</form>
<ul>
{% for comment in comments %}
<li>{{ comment.닉네임 }}({{ comment.아이피앞2자리 }}) | {{ comment.날짜시간 }}<br>
{{ comment.내용 }}
<a href="/comment/delete/{{ comment.id }}/{{ post.id }}">[삭제]</a></li>
{% endfor %}
</ul>
<script>
async function fetchIP2() {
const res = await fetch("https://api.ipify.org?format=json");
const data = await res.json();
const parts = data.ip.split('.');
document.getElementById("ip2").value = parts[0] + "." + parts[1];
}
document.getElementById("timestamp").value = new Date().toISOString();
fetchIP2();
</script>
</body>
</html>
✅ 5. templates/post_edit.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>글 수정</title></head>
<body>
<h2>글 수정</h2>
<form action="/post/update" method="post">
제목: <input type="text" name="title" value="{{ post.제목 }}" pattern="[가-힣a-zA-Z0-9 ]+" required><br>
내용: <textarea name="content" required>{{ post.내용 }}</textarea><br>
<input type="hidden" name="post_id" value="{{ post.id }}">
<button type="submit">수정</button>
</form>
</body>
</html>
✅ 6. templates/check_password.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>비밀번호 확인</title></head>
<body>
<h2>비밀번호 확인</h2>
<form action="{% if mode == 'delete' %}/post/delete/{{ post_id }}{% elif mode == 'edit' %}/post/edit/{{ post_id }}{% else %}/comment/delete/{{ comment_id }}/{{ post_id }}{% endif %}" method="post">
비밀번호: <input type="password" name="password" required>
{% if mode == 'delete_comment' %}
<input type="hidden" name="comment_id" value="{{ comment_id }}">
<input type="hidden" name="post_id" value="{{ post_id }}">
{% else %}
<input type="hidden" name="post_id" value="{{ post_id }}">
{% endif %}
<button type="submit">확인</button>
</form>
</body>
</html>
위 코드들로 웹서버 띄웠을때 동작 화면들은 다음과 같습니다
(글목록 표시, 글쓰기, 글수정하기, 글삭제, 댓글달기, 댓글수정하기 등등 테스트)
핵심 기능들은 완성되었으나 디자인이 미흡하여 bootstrap을 사용해 디자인을 수정해달라고 다시 gpt에 요청해보겠습니다.
Q3. GPT 질문내용
현재까지 최종 코드들 6개(fastapi_server.py, home.html, post_add.html, post_detail.html, post_edit.html, check_password.html )에 아래 추가사항들 반영해줘
[추가사항]
1) 글쓰거나 수정시 닉넴비번 제목내용란 (앞뒤 공백있으면 strip으로 제외하고)비어있으면 에러알람alert뜨게하고(ㅇㅇ를 입력해주세요) 그냥 return하기
2) 디자인이 아쉬워
가장 대중적인 bootstrap으로 디자인 전부 수정해줘 (html파일이라면 모두다!
그리고 base.html파일만들어서 가장 상단바에 홈 이라는 메뉴바 만들어줘 그거 누르면 무조건 홈페이지로 이동하는거야)
bootstrap5가 낫겠지? 색상은 눈이 편한 파란색으로
-----------
저 위에 2가지 다 반영해서
수정된 최종 fastapi_server.py랑 home.html, post_add.html, post_detail.html, post_edit.html, check_password.html
전부 나한테 다시 알려줘 그리고 base.html도 추가로 만들어주고
A3. GPT 답변내용
모든 추가사항을 반영한 최종 완성 코드 전체를 아래 zip 파일에 담았습니다:
📦 최종 커뮤니티 웹사이트 코드 다운로드 (Bootstrap 적용)
✅ 반영된 주요 변경 사항 요약
🔹 [1] 입력 유효성 검사 (strip() + alert)
- 글쓰기/수정/댓글작성 폼에 앞뒤 공백 제거 및 빈값 알림 적용
- JavaScript로 구현 (alert("ㅇㅇ을 입력해주세요") 형식)
🔹 [2] Bootstrap 5 적용 (눈편한 파란 테마)
- 모든 HTML 템플릿에 Bootstrap 5 적용
- base.html 생성 후 상단 바(홈 메뉴) 삽입
- {% include 'base.html' %}로 각 페이지에 공통 네비게이션 반영
📁 포함된 파일 목록
final_community_bootstrap_project/
├── fastapi_server.py
└── templates/
├── base.html ← 상단바 포함 공통 템플릿
├── home.html ← 글 목록 + 페이지네이션
├── post_add.html ← 글쓰기 (유효성 검사 포함)
├── post_edit.html ← 글 수정
├── post_detail.html ← 상세 보기 + 댓글 (Bootstrap 디자인)
└── check_password.html ← 비밀번호 확인 폼
필요하시면 배포, Firestore 설정, requirements.txt, 또는 GitHub 업로드 구조도 도와드릴게요.
최종 완성!
이제 디자인까지 수정한 최종 "커뮤니티 웹사이트"가 완성이 되었어요.
다만, GPT답변창에서는 📦 최종 커뮤니티 웹사이트 코드 다운로드 (Bootstrap 적용)
위 링크를 클릭하면 해당 community폴더가 통째로 압축파일로 받아졌는데 복사해서 가져온 글에서는 다운이 안되요.
해당 압축파일을 community.zip이라는 이름으로 따로 첨부하였습니다. (단, 파이어베이스 인증키 정보인 firebase_admin_sdk.json만 제외 <이건 개인이 다운받은 압축키 파일로 다시 넣어줘야함)
아래는 위 코드의 서버를 띄워 최종으로 완성된 웹사이트의 동작 예시 화면입니다.
위와같이 디자인만 수정했는데 제법 그럴듯한 커뮤니티 웹사이트가 완성이 된 모습입니다.
이렇게 해서 GPT의 도움을 받아 🎯 FastAPI + Firebase DB 기반의 실시간 커뮤니티 웹사이트를 완성해봤습니다! 💬💡
처음엔 막막했지만, 하나씩 질문하고 적용해보는 과정을 통해 🛠
**혼자서도 충분히 실제 서비스를 만들 수 있다**는 자신감을 얻었어요 💪✨
앞으로도 GPT를 활용해 🔥 더 많은 웹/앱을 만들어보고 싶고,
그 과정을 이 블로그에 차곡차곡 기록해보려 합니다 📚📝
그럼 다음 글에서 또 만나요! 🫶
**[실전] GPT로 웹/앱 개발하기** 시리즈는 계속됩니다! 🚀
#ChatGPT #FastAPI #커뮤니티웹사이트 #GPT개발 #파이썬개발 #웹앱개발 #혼자개발하기
'[실전] GPT로 앱&웹&프로그램 개발' 카테고리의 다른 글
GPT로 랜덤채팅 웹사이트 만들기: FastAPI + WebSocket 실전 구축기 (4) | 2025.06.12 |
---|---|
[챗지피티+pygame] 장애물 피하기 게임 웹앱으로 배포하기2 - 앱 배포 (0) | 2025.06.11 |
[챗지피티+pygame] 장애물 피하기 게임 웹앱으로 배포하기1 - 웹 배포 (4) | 2025.06.06 |
[챗지피티+pygame] 지피티를 이용해 리듬게임 만들기 (6) | 2025.02.01 |