[파이썬 Projects]/<파이썬 웹개발>

[파이썬] FastAPI - 메모 앱 프로젝트 4: 사용자별 메모 관리(feat. Almebic)

기록자_Recordian 2025. 5. 9. 16:27
728x90
반응형
이전 내용
 

[파이썬] FastAPI - 메모 앱 프로젝트 3: 사용자 인증

이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 2: CRUD 구현이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 1: 초기 설정, DB 연동이전 내용 [파이썬] FastAPI - 성능 개선 팁이전 내용 [FastAPI] 미들웨어(middl

puppy-foot-it.tistory.com


사용자별 메모 관리 구현

 

이번에는 사용자별 메모 관리를 구현한다.

사용자별 메모 관리 기능을 구현하기 위해 메모 모델을 수정하여 각 메모가 특정 사용자에게 속하도록 설정하고, 이를 바탕으로 사용자가 자신의 메모만 조회할 수 있도록 애플리케이션의 엔드포인트를 개선한다.

 

◆ 데이터베이스 관계형 구조 모듈 추가

이를 위해 SQLAlchemy의 relationship, ForeignKey 모듈을 추가한다.

from sqlalchemy.orm import sessionmaker, Session, relationship
from sqlalchemy import create_engine, Table, Column, Integer, String, ForeignKey

 

★ SQLAlchemy의 relationship, ForeignKey

SQLAlchemy에서 제공하는 relationship과 ForeignKey는 데이터베이스의 관계형 구조를 정의하고 조작하는 데 매우 중요한 역할을 한다.

 

1. ForeignKey: 데이터의 무결성을 유지하고, 서로 관련된 데이터 간의 관계를 명확하게 설정

ForeignKey는 하나의 테이블의 특정 컬럼이 다른 테이블의 기본 키(primary key)를 참조하도록 지정하는 객체다. 이를 통해 두 테이블 간의 관계를 설정할 수 있다.
예를 들어, Users 테이블과 memo 테이블이 있을 때, 각 메모(memo)가 특정 사용자(user)에 의해 작성되었다는 관계를 설정할 수 있다.

 

2. relationship: 데이터베이스에서 관계형 데이터를 쉽게 탐색하고 조작

relationship은 SQLAlchemy ORM에서 두 클래스(테이블) 간의 관계를 설정하기 위해 사용된다. 이 함수는 해당 관계를 통해 객체 지향적으로 데이터를 조작할 수 있도록 한다.
예를 들어, 사용자가 작성한 모든 게시물을 가져온다거나, 특정 게시물의 작성자를 조회하는 등의 작업을 간편하게 한다.


◆ Memo 모델 코드 수정

그리고 나서, 앞서 작성했던 Memo 모델에 사용자 참조를 추가한다.

# Memo 모델 정의
class Memo(Base):
    __tablename__ = 'memo'
    id = Column(Integer, primary_key=True, index=True) # 정수형, PK 설정
    user_id = Column(Integer, ForeignKey('users.id')) # 사용자 참조 추가
    title = Column(String(100), nullable=False) # 제목, 문자열, 값이 없는 예외 경우 방지
    content = Column(String(1000), nullable=False) # 내용, 문자열, 값이 없는 예외 경우 방지

    user = relationship("User") # 사용자와의 관계 설정
  • 이 변경을 통해 Memo 인스턴스는 user_id 속성을 통해 연관된 User 인스턴스와 연결된다.

◆ 메모 조회 엔드포인트 수정

사용자별 메모 관리를 위해 메모 조회 엔드포인트를 수정한다.

# 메모 조회
@app.get("/memos")
async def list_memos(request: Request, db: Session = Depends(get_db)):
    # 사용자별 메모 관리
    username = request.session.get("username")
    if username is None:
        raise HTTPException(status_code=401, detail="Not Authorized")
    user = db.query(User).filter(User.username == username).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
    memos = db.query(Memo).filter(Memo.user_id == user.id).all()
    # 리스트 내포 사용하여 전체 정보 반환
    return templates.TemplateResponse("memos.html", {"request": request, "memos": memos})
  • 이 엔드포인트는 로그인한 사용자의 ID를 사용하여 Memo 테이블에서 해당 사용자의 메모만 필터링하여 memos.html에 전송한다.
  • memos.html은 기존 코드에서 설정한 대로 Jinja2Templates(directory="templates")에 위치 ▶ 작성 필요

◆ 메모 생성 엔드포인트 수정

메모 생성 기능에서 현재 로그인한 사용자의 ID를 Memo 모델에 저장한다.

# 메모 생성
@app.post("/memos")
async def create_user(request: Request, memo: MemoCreate, db:Session = Depends(get_db)):
    username = request.session.get("username")
    if username is None:
        raise HTTPException(status_code=401, detail="Not Authorized")
    user = db.query(User).filter(User.username == username).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
    new_memo = Memo(user_id=user.id, title = memo.title, content = memo.content)
    db.add(new_memo)
    db.commit()
    db.refresh(new_memo)
    # 새로 생성된 사용자 정보 반환
    return new_memo

 

◆ 메모 수정 엔드포인트 수정

메모 업데이트(수정) 기능에서는 해당 메모가 현재 로그인한 사용자의 것인지 확인한다.

# 메모 수정
@app.put("/memos/{memo_id}")
async def update_user(request:Request, memo_id: int, memo: MemoUpdate, db: Session = Depends(get_db)):
    username = request.session.get("username")
    if username is None:
        raise HTTPException(status_code=401, detail="Not Authorized")
    user = db.query(User).filter(User.username == username).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
    db_memo = db.query(Memo).filter(Memo.id == memo_id).first()
    if db_memo is None:
        return {"error": "User를 찾을 수 없습니다."}
    
    # 사용자 정보 업데이트
    if memo.title is not None:
        db_memo.title = memo.title
    if memo.content is not None:
        db_memo.content = memo.content

    db.commit()
    db.refresh(db_memo)
    return db_memo

 

◆ 메모 삭제 엔드포인트 수정

메모 삭제 기능에서도 현재 로그인한 사용자가 해당 메모의 소유자인지 확인한다.

# 메모 삭제
@app.delete("/memos/{memo_id}")
async def delete_user(request:Request, memo_id: int, db: Session = Depends(get_db)):
    username = request.session.get("username")
    if username is None:
        raise HTTPException(status_code=401, detail="Not Authorized")
    user = db.query(User).filter(User.username == username).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
    db_memo = db.query(Memo).filter(Memo.id == memo_id, Memo.user_id == user.id).first()
    if db_memo is None:
        return {"error": "Memo를 찾을 수 없습니다."}
    db.delete(db_memo)
    db.commit()
    return {"message": "Memo가 삭제되었습니다."}

 

★ 공통 코드

FastAPI를 사용하여 사용자의 인증 및 데이터베이스에서 해당 사용자를 조회하는 코드

username = request.session.get("username")
if username is None:
    raise HTTPException(status_code=401, detail="Not Authorized")
user = db.query(User).filter(User.username == username).first()
if user is None:
    raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")


- username = request.session.get("username")
현재 사용자 세션에서 "username"이라는 키를 사용하여 사용자 이름을 가져옴. 사용자가 로그인할 때 세션에 사용자 정보를 저장하면, 이후 요청에서 이 정보를 사용할 수 있다.

- if username is None:
    raise HTTPException(status_code=401, detail="Not Authorized")
username이 None인 경우, 즉 세션에 사용자 이름이 존재하지 않으면, 사용자에게 인증되지 않은 상태로 간주하여 HTTP 401 상태 코드를 반환.
HTTP 401: 이 상태 코드는 "인증되지 않음" ▶ 사용자가 로그인을 하지 않았거나 세션이 만료된 경우 발생
detail: 오류 메시지를 설명하는 데 도움이 되는 세부 정보를 담고 있음.

- user = db.query(User).filter(User.username == username).first()
데이터베이스에서 User 모델을 쿼리하여, 세션에 저장된 사용자 이름과 일치하는 첫 번째 사용자를 찾는다.

- if user is None:
    raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
user가 None인 경우, 즉 데이터베이스에서 일치하는 사용자를 찾지 못한 경우에 HTTP 404 상태 코드를 반환
HTTP 404: "찾을 수 없음" 제공된 사용자 이름에 해당하는 사용자 정보가 데이터베이스에 존재하지 않을 때 발생

 


memos.html

 

사용자별 메모 조회 기능을 위한 memos.html 템플릿을 templates 폴더 내에 만든다.

memos.html은 HTML 파일을 렌더링하기 위해 Jinja2 템플릿을 엔진을 사용한다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>나의 메모</title>
    <style>
        body {font-family: Arial, Helvetica, sans-serif;}
        .memo {margin-bottom: 20px; padding: 10px; border: 1px solid #ddd}
        .memo-title { font-weight: bold;}
    </style>
</head>
<body>
    <h1>나의 메모</h1>
    <p><a href="/memos/create">새 메모 추가</a></p>
    {% for memo in memos %}
    <div class="memo">
        <h2 class="memo-title">{{ memo.title }}</h2>
        <p>{{ memo.content }}</p>
        <a href="/memos/update/{{ memo.id }}">수정</a>" |
        <a href="/memos/delete/{{ memo.id }}">삭제</a>"
    </div>
    {% endfor %}
</body>
</html>

 

[전체 구조]

부분 설명
<!DOCTYPE html> HTML5 문서임을 선언
<head> 웹페이지 설정 (문자 인코딩, 제목, 스타일)
<body> 웹페이지 본문 (실제 사용자에게 보이는 부분)
{% for memo in memos %} ... {% endfor %} 메모 리스트를 반복해서 출력하는 Jinja2 반복문
{{ memo.title }} 메모 객체의 title 값을 출력
{{ memo.content }} 메모 객체의 content 값 출력

 

[코드 상세 설명]

1. <!DOCTYPE html>
HTML5 형식의 문서

 

2. <html lang="ko">
웹페이지가 한국어로 작성됨을 의미

 

3. <head> ... </head>
웹페이지의 메타 정보들이 들어가는 부분

태그 설명
</meta charset="utf-8"> 유니코드(한글 등 다양한 문자)를 정상적으로 표시
<title> 브라우저 탭에 표시될 제목: "나의 메모"
<style> HTML 요소들의 스타일(CSS)을 지정

 

- CSS 스타일

body {
    font-family: Arial, Helvetica, sans-serif;
}
.memo {
    margin-bottom: 20px;
    padding: 10px;
    border: 1px solid #ddd;
}
.memo-title {
    font-weight: bold;
}
  • font-family: 글꼴을 지정
  • .memo: 메모 박스의 여백, 테두리 등을 설정
  • .memo-title: 제목을 굵게 표시

4. <body> ... </body>

웹페이지의 메인 콘텐츠

  • <h1>나의 메모</h1>: 가장 큰 제목으로 "나의 메모" 표시
  • <a href="/memos/create">새 메모 추가</a>: 클릭하면 새 메모 작성 페이지(/memos/create)로 이동
  • {% for memo in memos %} ... {% endfor %}: 메모 리스트(memos)를 반복해서 하나씩 보여주는 Jinja2 문법

- 반복문 안 구조: 각 memo 항목 표시 방법

<div class="memo">
    <h2 class="memo-title">{{ memo.title }}</h2>
    <p>{{ memo.content }}</p>
    <a href="/memos/update/{{ memo.id }}">수정</a> |
    <a href="/memos/delete/{{ memo.id }}">삭제</a>
</div>
  • memo.title: 메모의 제목
  • memo.content: 메모의 본문
  • memo.id: 메모의 고유 번호. 수정/삭제 링크에서 사용됨.

memos.html - FastAPI 연동

 

HTML 템플릿이 FastAPI와 연동되는 방식은 MVC 패턴(Model-View-Controller)과 비슷한 흐름을 따른다.

 

[전체 흐름]

  • 클라이언트(브라우저)가 /memos 같은 URL에 요청을 보냄
  • ▼ FastAPI의 라우터가 요청을 받아 처리
  • ▼ 필요한 데이터를 DB에서 조회
  • ▼ 데이터를 HTML 템플릿에 삽입(Jinja2 렌더링)
  • 완성된 HTML을 브라우저에 응답
단계 설명
1. 사용자 요청 브라우저에서 /memos 접속
2. 라우터 처리 FastAPI가 라우터 함수 실행
3. DB 조회 DB에서 메모 데이터를 가져옴
4. 템플릿 렌더링 memos.html에 데이터 삽입
5. HTML 응답 완성된 HTML이 사용자에게 전달됨

 

[구성 요소별 이론 정리]

구성요소 설명 예시
Route (라우터) 사용자의 요청을 처리하는 FastAPI 함수 @app.get("/memos")
Template 엔진 HTML 템플릿에 Python 데이터를 삽입해 최종 HTML을 생성 Jinja2Templates
Context (문맥) 템플릿에 전달하는 Python 변수들 {"memos": memos, "request": request}
Template (.html 파일) 데이터를 받아서 사용자에게 보여주는 틀 memos.html
Request 객체 템플릿 엔진이 내부적으로 필요한 요청 정보 request: Request

 

★ 왜 request 객체를 넘겨야 할까?

FastAPI에서 Jinja2Templates.TemplateResponse를 사용할 때는 반드시 request 객체를 넘겨야 한다.

  • 내부적으로 템플릿에서 url_for 같은 기능을 쓰거나, 세션 등 확장 기능을 사용할 수 있도록 하기 위해
  • 구조적으로 모든 템플릿은 현재 요청 정보를 알고 있어야 하기 때문

★ 템플릿이 데이터를 받아서 HTML을 만드는 과정

{% for memo in memos %}
  <h2>{{ memo.title }}</h2>
  <p>{{ memo.content }}</p>
{% endfor %}
  • memos라는 이름의 리스트를 반복해서 출력
  • {{ memo.title }} 같은 부분은 Jinja2 문법으로 파이썬 객체를 문자열로 삽입
  • 결과적으로 사용자가 /memos에 접속하면, 이 HTML이 서버에서 미리 완성돼서 브라우저에 도착

 

Jinja2 템플릿 관련 문법은 아래 링크 확인

 

[파이썬] FastAPI - FastAPI와 Jinja2 고급 문법

시작에 앞서해당 내용은 , Dave Lee 지음. BJ Public 출판.내용을 토대로 작성되었습니다. 보다 자세한 사항은 해당 교재를 참고하시기 바랍니다.이전 내용 [파이썬] FastAPI - 템플릿시작에 앞서해당 내

puppy-foot-it.tistory.com


메모 관리 기능 테스트

 

대략적인 테이블 구조가 수정되었으므로 기능이 제대로 잘 구현됐는지 테스트를 해야 한다.

현재 프론트엔드 페이지가 없으므로, main.py를 실행하여 서버를 실행한 뒤 POST MAN으로 테스트를 수행한다.

 

◆ 회원 가입

◆ 로그인

◆ 메모 생성(에러 발생)

로그인 후, 새 메모를 생성해 본다.

로그인이 되어있는데, user_id가 자동으로 생성되지 않는다.

에러 메시지를 확인해 보니,

sqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedColumn) 오류:  "user_id" 칼럼은 "memo" 릴레이션(relation)에 없음
LINE 1: INSERT INTO memo (user_id, title, content) VALUES (2, '테스...
                          ^

[SQL: INSERT INTO memo (user_id, title, content) VALUES (%(user_id)s, %(title)s, %(content)s) RETURNING memo.id]
[parameters: {'user_id': 2, 'title': '테스트 메모', 'content': '메모를 남겨 봅니다!'}]
(Background on this error at: https://sqlalche.me/e/20/f405)

 

즉, 기존에 Memo 모델에는 user_id 라는 컬럼이 없었는데, 수정하면서 해당 컬럼을 수정했기 때문이며, 서버를 실행한다고 해서 기존에 있던 테이블에 새로운 컬럼이 자동으로 추가되지 않기 때문이다.

▶ FastAPI와 SQLAlchemy를 사용할 때, Base 클래스에서 정의한 모델(Memo)은 Python 코드에 정의된 데이터 구조일 뿐이다. 이 모델은 "어떻게 데이터를 다룰 것인가"를 정의하지만, 실제 데이터베이스에 테이블이나 컬럼을 자동으로 추가하거나 수정하지 않는다.

 

즉, "처음부터 DB 테이블이 없을 때"는 Base.metadata.create_all(bind=engine)를 사용하면 모델 정의에 따라 테이블은 생성되지만, 이미 존재하는 테이블에 새로운 칼럼(user_id)을 자동으로 추가해주지는 않는다.

작업 create_all()이 하는 일
테이블이 없는 경우 ✅ 새로 생성함
테이블이 이미 있는 경우 ❌ 기존 구조는 수정 안 함 (컬럼 추가 ❌)

기존 테이블에 자동으로 칼럼 추가하기
Alembic

 

★ 이미 있는 테이블에 칼럼을 추가하는 방법

1. 수동으로 SQL 쿼리문 입력

ALTER TABLE memo ADD COLUMN user_id INTEGER;

 

2. Alembic 마이그레이션 도구 사용

Alembic은 SQLAlchemy용 데이터베이스 마이그레이션 도구로, 모델에 변경사항이 생기면 그것을 감지해서 DB 스키마를 자동으로 업데이트하는 스크립트를 만들어 준다.

▶ SQL 쿼리문을 입력하면 금방 해결할 수 있으나, 새로운 것을 배운다는 느낌으로 해당 사항으로 해결해 보기로 한다.

 

2-1. 프로젝트에 Alembic 설치

conda install alembic

 

2-2. Alembic 초기화 (한 번만 실행)

alembic init alembic

▶ 프로젝트에 alembic/ 폴더와 alembic.ini 설정파일이 생김

 

2-3. env.py 설정

alembic/env.py 파일을 열고, SQLAlchemy의 Base와 DB 연결 등록

# 파일: alembic/env.py
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

from main import Base  # 실제 모델 정의가 있는 곳 (현재 main.py)
target_metadata = Base.metadata

 

데이터베이스 연결 URL도 alembic.ini에서 수정

# 파일: alembic.ini
sqlalchemy.url = postgresql://postgres:1234@localhost/memo_app

 

2-4. 마이그레이션 스크립트 자동 생성

alembic revision --autogenerate -m "Add user_id to memo"

이 명령어가 변경된 모델(Memo의 user_id)과 DB 구조를 비교해서 자동으로 스크립트를 만들어 준다.

 

2-5. 마이그레이션 적용 (DB 스키마 실제 반영)

alembic upgrade head

해당 명령어를 입력하면 진짜 DB에 user_id 컬럼이 생긴다.

 

실제로 SQL Shell 에서 테이블을 조회해보니, user_id가 생성된 것을 확인했다.

 

다시 서버를 실행하여 Postman 으로 메모 생성 테스트를 진행해 본다.

메모 생성 테스트가 성공하였다.


메모 관리 테스트 재개

 

◆ 메모 조회

현재 로그인한 사용자가 작성한 모든 메모를 조회한다.

출력은 memos.html의 Jinja2 템플릿 엔진 관련 문법을 기반으로 기존에 동일 사용자 ID로 입력한 새 메모 추가, 메모 내용이 포함되어 있음을 확인할 수 있다.

 

◆ 메모 수정

메모 ID 4번의 메모를 수정해 본다.

 

◆ 메모 삭제

3번 메모를 삭제해 본다.

삭제가 성공하였다.

 

◆ 로그 아웃

로그아웃을 성공적으로 수행하면, 서버 측에서 세션 정보를 삭제하고 클라이언트에게 로그아웃 성공 메시지를 반환한다.


[참고]

가장 빠른 풀스택을 위한 플라스크 & FastAPI


다음 내용

 

 

728x90
반응형