FastAPI로 완벽한 블로그 만들기: 이미지 업로드
Daniel Hayes
Full-Stack Engineer · Leapcell

이전 글에서이전 글에서는 FastAPI 블로그에 댓글 답글 기능을 구현하여 댓글 섹션의 상호 작용성을 크게 향상시켰습니다.
이제 게시물과 댓글에 대한 기능은 상당히 완성되었습니다. 하지만 게시물 자체는 일반 텍스트만 지원하므로 다소 단조롭습니다.
이 글에서는 게시물에 이미지 업로드 기능을 추가하여 블로그 콘텐츠를 텍스트와 이미지 모두 풍부하고 표현력 있게 만들 것입니다.
이미지 업로드 구현의 원리는 다음과 같습니다.
- 사용자가 프론트엔드 페이지에서 이미지를 선택하고 업로드합니다.
- 백엔드는 이미지를 받아 객체 스토리지 서비스에 저장합니다.
- 백엔드는 이미지에 대한 공개적으로 액세스 가능한 URL을 반환합니다.
- 프론트엔드는 이 URL을 Markdown 형식(

)으로 게시물 콘텐츠 텍스트 상자에 삽입합니다. - 게시물 콘텐츠가 최종적으로 웹페이지로 렌더링될 때 브라우저는 이 URL을 사용하여 이미지를 가져와 표시합니다.
단계 1: S3 호환 객체 스토리지 준비
먼저 사용자가 업로드한 이미지를 저장할 장소가 필요합니다. 서버의 하드 드라이브에 직접 저장할 수도 있지만, 현대 웹 애플리케이션에서는 유지 관리가 쉽고 확장 가능하며 비용 효율적이므로 객체 스토리지 서비스(예: AWS S3)를 사용하는 것이 좋습니다.
편의를 위해 데이터베이스 및 백엔드 호스팅을 제공할 뿐만 아니라 S3 호환 객체 스토리지 서비스도 제공하는 Leapcell을 계속 사용하겠습니다.
Leapcell 메인 인터페이스에 로그인하고 "객체 스토리지 생성"을 클릭합니다.
이름을 입력하여 객체 스토리지를 생성합니다.
객체 스토리지 세부 정보 페이지에서 Endpoint, Access Key ID, Secret Access Key와 같은 연결 매개변수를 볼 수 있습니다. 이 매개변수는 나중에 백엔드 구성에 사용됩니다.
인터페이스는 브라우저에서 직접 파일을 업로드하고 관리할 수 있는 매우 편리한 UI도 제공합니다.
단계 2: 백엔드에 이미지 업로드 API 구현
다음으로 파일 업로드를 처리할 FastAPI 백엔드를 구축해 보겠습니다.
1. 종속성 설치
S3 호환 객체 스토리지 서비스에 파일을 업로드하려면 boto3
(Python용 AWS SDK)이 필요합니다. 또한 게시물을 표시할 때 Markdown 형식을 HTML로 변환하는 Markdown 파싱 라이브러리가 필요합니다. 여기서는 markdown2
를 선택하겠습니다.
requirements.txt
파일에 추가합니다.
# requirements.txt # ... 기타 패키지 boto3 markdown2
그런 다음 설치 명령을 실행합니다.
pip install -r requirements.txt
2. 업로드 서비스 생성
코드를 깔끔하게 유지하기 위해 파일 업로드 기능에 대한 새 서비스 파일을 생성합니다.
프로젝트의 루트 디렉토리에 uploads_service.py
라는 새 파일을 만듭니다. 이 서비스는 S3와의 핵심 통신 로직을 담당합니다.
# uploads_service.py import boto3 import uuid from fastapi import UploadFile # --- S3 구성 --- # 이러한 값은 환경 변수에서 읽는 것이 좋습니다. S3_ENDPOINT_URL = "https://objstorage.leapcell.io" S3_ACCESS_KEY_ID = "YOUR_ACCESS_KEY_ID" S3_SECRET_ACCESS_KEY = "YOUR_SECRET_ACCESS_KEY" S3_BUCKET_NAME = "my-fastapi-blog-images" # 귀하의 버킷 이름 S3_PUBLIC_URL = f"https://{S3_BUCKET_NAME}.leapcellobj.com" # 귀하의 버킷 공개 액세스 URL # S3 클라이언트 초기화 s3_client = boto3.client( "s3", endpoint_url=S3_ENDPOINT_URL, aws_access_key_id=S3_ACCESS_KEY_ID, aws_secret_access_key=S3_SECRET_ACCESS_KEY, region_name="us-east-1", # S3 호환 스토리지의 경우 리전은 종종 명목상입니다. ) def upload_file_to_s3(file: UploadFile) -> str: """ 파일을 S3에 업로드하고 공개 URL을 반환합니다. """ try: # 충돌을 방지하기 위해 고유한 파일 이름 생성 file_extension = file.filename.split(".")[-1] unique_filename = f"{uuid.uuid4()}.{file_extension}" s3_client.upload_fileobj( file.file, # 파일과 같은 객체 S3_BUCKET_NAME, unique_filename, ExtraArgs={ "ContentType": file.content_type, "ACL": "public-read", # 파일을 공개적으로 읽을 수 있도록 설정 }, ) # 파일의 공개 URL 반환 return f"{S3_PUBLIC_URL}/{unique_filename}" except Exception as e: print(f"S3 업로드 오류: {e}") raise
참고: 구현을 단순화하기 위해 S3 연결 매개변수가 하드코딩되어 있습니다. 실제 프로젝트에서는 이 민감한 정보를 환경 변수에 저장하고 os.getenv()
를 사용하여 읽는 것이 좋습니다.
3. 업로드 라우트 생성
이제 routers
폴더에 uploads.py
라는 새 파일을 만들고 API 라우트를 정의합니다.
# routers/uploads.py from fastapi import APIRouter, Depends, UploadFile, File from auth_dependencies import login_required import uploads_service router = APIRouter() @router.post("/uploads/image") def upload_image( user: dict = Depends(login_required), # 로그인한 사용자만 업로드 가능 file: UploadFile = File(...) ): """ 업로드된 이미지 파일을 받아 S3에 업로드하고 URL을 반환합니다. """ url = uploads_service.upload_file_to_s3(file) return {"url": url}
마지막으로 메인 애플리케이션 main.py
에서 이 새 라우터 모듈을 마운트합니다.
# main.py # ... 기타 가져오기 from routers import posts, users, auth, comments, uploads # uploads 라우터 가져오기 # ... app = FastAPI(lifespan=lifespan) # ... # 라우터 포함 app.include_router(posts.router) app.include_router(users.router) app.include_router(auth.router) app.include_router(comments.router) app.include_router(uploads.router) # uploads 라우터 마운트
단계 3: 프론트엔드에 FilePicker API 통합
백엔드가 준비되었으니 new-post.html
프론트엔드 페이지를 수정하여 업로드 기능을 추가해 보겠습니다.
업로드를 처리하는 방법에는 최신 FilePicker API와 기존 <input type="file">
두 가지 방법이 있습니다.
기존 방법: <input type="file">
는 호환성이 뛰어나 모든 브라우저에서 지원됩니다. 하지만 API는 다소 오래되었고 직관적이지 않으며 사용자 경험이 좋지 않습니다.
최신 방법: File System Access API는 사용하기 쉽고 더 강력하며 더 나은 사용자 경험을 제공할 수 있습니다. 하지만 기존 방법만큼 호환성이 좋지 않으며 보안 컨텍스트(HTTPS)에서 실행해야 합니다.
블로그가 최신 프로젝트이므로 FilePicker API를 사용하여 파일 업로드를 구현하겠습니다.
templates/new-post.html
을 열고 textarea
위에 툴바와 "이미지 업로드" 버튼을 추가합니다.
{% include "_header.html" %} <form action="/posts" method="POST" class="post-form"> <div class="form-group"> <label for="title">제목</label> <input type="text" id="title" name="title" required /> </div> <div class="form-group"> <label for="content">내용</label> <div class="toolbar"> <button type="button" id="upload-image-btn">이미지 업로드</button> </div> <textarea id="content" name="content" rows="10" required></textarea> </div> <button type="submit">제출</button> </form> <script> document.addEventListener("DOMContentLoaded", () => { const uploadBtn = document.getElementById("upload-image-btn"); const contentTextarea = document.getElementById("content"); uploadBtn.addEventListener("click", async () => { try { const [fileHandle] = await window.showOpenFilePicker({ types: [ { description: "이미지", accept: { "image/*": [".png", ".jpeg", ".jpg", ".gif", ".webp"] }, }, ], }); const file = await fileHandle.getFile(); uploadFile(file); } catch (error) { // 사용자가 파일 선택을 취소하면 AbortError가 발생합니다. 무시합니다. if (error.name !== "AbortError") { console.error("FilePicker 오류:", error); } } }); function uploadFile(file) { if (!file) return; const formData = new FormData(); formData.append("file", file); // 간단한 로딩 표시기 표시 uploadBtn.disabled = true; uploadBtn.innerText = "업로드 중..."; fetch("/uploads/image", { method: "POST", body: formData, // 참고: FormData를 사용할 때 Content-Type 헤더를 수동으로 설정할 필요는 없습니다. }) .then((response) => response.json()) .then((data) => { if (data.url) { // 반환된 이미지 URL을 Markdown 형식으로 텍스트 상자에 삽입 const markdownImage = ``; insertAtCursor(contentTextarea, markdownImage); } else { alert("업로드 실패. 다시 시도해 주세요."); } }) .catch((error) => { console.error("업로드 오류:", error); alert("업로드 중 오류가 발생했습니다."); }) .finally(() => { uploadBtn.disabled = false; uploadBtn.innerText = "이미지 업로드"; }); } // 커서 위치에 텍스트 삽입 지원 함수 function insertAtCursor(myField, myValue) { if (myField.selectionStart || myField.selectionStart === 0) { var startPos = myField.selectionStart; var endPos = myField.selectionEnd; myField.value = myField.value.substring(0, startPos) + myValue + myField.value.substring(endPos, myField.value.length); myField.selectionStart = startPos + myValue.length; myField.selectionEnd = startPos + myValue.length; } else { myField.value += myValue; } } }); </script> {% include "_footer.html" %}
단계 4: 이미지 포함 게시물 렌더링
게시물 콘텐츠에 이미지에 대한 Markdown 링크를 성공적으로 삽입했지만, 여전히 텍스트 문자열로만 렌더링됩니다. 게시물 세부 정보 페이지 post.html
에서 Markdown 형식을 실제 HTML로 변환해야 합니다.
1. 라우트에서 Markdown 구문 분석
routers/posts.py
에서 get_post_by_id
함수를 수정합니다. 게시물 데이터를 템플릿에 전달하기 전에 markdown2
로 콘텐츠를 구문 분석합니다.
# routers/posts.py # ... 기타 가져오기 import markdown2 # markdown2 가져오기 # ... @router.get("/posts/{post_id}", response_class=HTMLResponse) def get_post_by_id( request: Request, post_id: uuid.UUID, session: Session = Depends(get_session), user: dict | None = Depends(get_user_from_session), ): post = session.get(Post, post_id) comments = comments_service.get_comments_by_post_id(post_id, session) # Markdown 콘텐츠 구문 분석 if post: post.content = markdown2.markdown(post.content) return templates.TemplateResponse( "post.html", { "request": request, "post": post, "title": post.title, "user": user, "comments": comments, }, )
2. 게시물 세부 정보 페이지 뷰 업데이트
마지막으로 templates/post.html
을 수정하여 구문 분석된 HTML을 올바르게 렌더링하도록 합니다. 이전에 줄 바꿈을 처리하기 위해 {{ post.content | replace(' ', '<br>') | safe }}
을 사용했습니다. 이제 콘텐츠가 이미 HTML이므로 safe
필터만 사용하면 됩니다.
{# templates/post.html #} {# ... #} <article class="post-detail"> <h1>{{ post.title }}</h1> <small>{{ post.createdAt.strftime('%Y-%m-%d') }}</small> <div class="post-content">{{ post.content | safe }}</div> </article> {# ... #}
safe
필터는 Jinja2에게 이 변수의 콘텐츠가 안전하며 HTML 이스케이핑이 필요하지 않음을 알립니다. 이를 통해 이미지 <img>
태그 및 기타 Markdown 서식이 올바르게 렌더링될 수 있습니다.
실행 및 테스트
이제 애플리케이션을 다시 시작합니다.
uvicorn main:app --reload
로그인한 후 "새 게시물" 페이지로 이동하면 새로운 "이미지 업로드" 버튼이 보입니다. 클릭하여 업로드할 파일을 선택합니다.
이미지를 선택합니다. 업로드가 완료되면 이미지에 대한 Markdown 링크가 텍스트 상자에 자동으로 삽입됩니다.
게시물을 발행하고 게시물 상세 페이지로 이동합니다. 이미지가 성공적으로 렌더링된 것을 볼 수 있습니다. 그리고 덤으로 게시물 콘텐츠는 이제 Markdown 구문을 지원합니다!
축하합니다. 블로그가 이제 이미지 업로드(및 Markdown)를 지원합니다! 이제부터 여러분의 블로그는 분명 훨씬 더 흥미로워질 것입니다.