TOP
본문 바로가기
[파이썬 Projects]/<파이썬 웹개발>

[파이썬] FastAPI - 메모 앱 프로젝트 11: 회원 탈퇴

by 기록자_Recordian 2025. 5. 12.
728x90
반응형
이전 내용
 

[파이썬] FastAPI - 메모 앱 프로젝트 10: 환영 이메일 발송

이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 9: 소셜 로그인 추가(네이버)이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 9: 소셜 로그인 추가(카카오)이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 9:

puppy-foot-it.tistory.com


회원 탈퇴하기 (유저 삭제)

 

이번에는 회원 탈퇴 기능을 구현해 본다. 회원 탈퇴가 이뤄지면 유저 정보를 삭제해야 하는데, 서비스마다 회원 리소스 삭제에 대한 정책은 다를 수 있다. 예를 들면,

  • 민감 정보만 삭제하고, 유저 ID와 일부 속성을 암호화하여 별도의 테이블로 옮김.
  • 유저 리소스 삭제 ▶ 삭제 후 유저가 생성한 데이터(게시물 등) 남겨둠.
  • 유저 리소스 삭제 ▶ 삭제 후 유저가 생성한 데이터(게시물 등) 삭제.

등등 여러가지 방법이 있을 수 있다.

여기서는 유저 리소스를 삭제하면서 유저가 생성한 모든 데이터를 삭제하는 작업을 수행해 보도록 한다.

◆ controllers.py

controllers.py에 회원 탈퇴 라우터를 추가해 준다.

# 회원 탈퇴
@router.delete("/users/{id}")
async def delete_user(request: Request, id:int, db:Session = Depends(get_db)):
    user_id = request.session.get("id")
    if user_id is None:
        raise HTTPException(status_code=401, detail="Not Authorized")
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
    db.delete(user)

    try:
        db.commit() # 데이터베이스에서 사용자 정보 삭제
    except Exception:
        db.rollback() # 에러 발생 시 롤백
        raise HTTPException(status_code=500, detail="회원 탈퇴에 실패하였습니다. 다시 시도해 주세요.") 

    return {"message": "그동안 이용해 주셔서 감사합니다."}

회원 탈퇴 안내 이메일 발송하기

 

앞서 구현했던 회원 가입 시 이메일 발송 서비스를 응용하여, 회원 탈퇴 시에도 등록된 메일로 이메일을 발송하는 기능을 구현해 본다. 먼저 email_service.py에 클래스를 하나 더 만들어준다.

email_service.py

class EmailServiceBye:
    def send_email(
            self,
            receiver_email: str,
    ):
        sender_email = os.getenv('EMAIL_ADDRESS')
        password = os.getenv('EMAIL_PASSWORD')

        message = MIMEMultipart()
        message["From"] = sender_email
        message["To"] = receiver_email
        message["Subject"] = "탈퇴 완료 이메일."

        body = "그동안 메모 앱 서비스를 이용해 주셔서 감사합니다."
        message.attach(MIMEText(body, "plain"))

        with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
            server.login(sender_email, password)
            server.send_message(message)

 

◆ controllers.py

그리고, controllers.py에서 EmailServiceBye 클래스를 import 하고, 탈퇴 안내 이메일 전송 함수를 정의하고, 회원 탈퇴 라우터를 구현한다.

from email_service import EmailService, EmailServiceBye
import logging

# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# 탈퇴 안내 이메일 전송
def send_bye_email(email: str):
    # 이메일 전송
    email_service = EmailServiceBye() # EmailServiceBye 클래스 인스턴스 생성
    try:
        email_service.send_email(receiver_email=email) # 이메일 전송
        logger.info(f"탈퇴 안내 이메일을 {email}로 전송했습니다.")
    except Exception as e:
        logger.error(f"이메일 전송 실패: {e}")  # 에러 로깅
        
        
# 회원 탈퇴
@router.delete("/users/{id}")
async def delete_user(request: Request, id:int, background_tasks: BackgroundTasks, db:Session = Depends(get_db)):
    user_id = request.session.get("id")
    if user_id is None:
        raise HTTPException(status_code=401, detail="Not Authorized")
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")
    db.delete(user)

    try:
        db.commit() # 데이터베이스에서 사용자 정보 삭제
    except Exception:
        db.rollback() # 에러 발생 시 롤백
        raise HTTPException(status_code=500, detail="회원 탈퇴에 실패하였습니다. 다시 시도해 주세요.")
    
    # 백그라운드 작업 등록
    background_tasks.add_task(send_bye_email, user.email)

    return {"message": "그동안 이용해 주셔서 감사합니다. 탈퇴 안내 이메일이 발송되었습니다."}

회원 탈퇴 라우터에는 회원 탈퇴가 완료되면 데이터베이스를 커밋하고, 등록된 이메일로 회원 탈퇴 이메일이 발송되도록 백그라운드 작업을 등록해 두었다.

 

◆ memos.html

memos.html에 로그인 후 회원 탈퇴를 할 수 있도록, 버튼을 만들어두고 해당 버튼을 누르면 회원 탈퇴가 되도록 설정한다.

회원 탈퇴 버튼은 메모리스트 가장 아래에 추가해 주려고 한다.

[추가 부분]

<div class="text-end my-4">
<button onclick="deleteUser( {{ user_info.id }})" class="btn btn-sm btn-danger deleteUser-button">
    <i class="fas fa-user-slash"></i> 회원 탈퇴</button>
</div>

 

Font Awesome이란, 웹상에 많이 사용하는 아이콘을 벡터 형태로 제공해주는 서비스이다.

https://fontawesome.com/icons

아이콘 클래스 (class="") 의미
🗑️ 휴지통 fas fa-trash-alt 계정 삭제 느낌 (가장 흔하게 사용됨)
⚠️ 경고 삼각형 fas fa-exclamation-triangle 위험/경고의 의미를 줌
❌ X 마크 fas fa-user-times 사용자 제거 느낌
👤 ➖ fas fa-user-slash 계정 비활성화/탈퇴 의미로 명확

 

[전체 코드]

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>나의 메모</title>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css" rel="stylesheet">
    <style>
        .container {
            margin-top: 20px;
            max-width: 800px;
        }

        .card {
            margin-bottom: 20px;
            border: none;
            box-shadow: 0 4px 8px rgba(0,0,0,.1);
            background-color: #fff;
        }

        .memo-title, .memo-content {
            width: 100%;
            margin-bottom: 10px;
            border: 1px solid #ddd;
            background-color: #fff;
            padding: 10px;
        }

        .memo-title {
            font-size: 1.1rem;
        }

        .memo-content {
            min-height: 100px;
        }

        .edit-buttons {
            margin: 10px;
            text-align: right;
            margin-right: 0px;
            margin-bottom: 0px;
        }

        .edit-buttons .btn {
            background-color: #f8f9fa;
            border: none;
            border-radius: 5px;
            margin-left: 5px;
            padding: 5px 10px;
            color: #495057;
            transition: all 0.3s ease;
        }

        .edit-buttons .btn:hover {
            background-color: #e2e6ea;
            transform: scale(1.1);
        }

        .edit-buttons .btn-edit {
            background-color: #E74C3C;
            color: #fff;
        }

        .edit-buttons .btn-edit:hover {
            background-color: #C0392B;
        }

        .edit-buttons .btn-delete {
            background-color: #3498DB;
            color: #fff;
        }

        .edit-buttons .btn-delete:hover {
            background-color: #2980B9;
        }

        .btn-primary {
            background-color: #3F464D;
            border-color: #007bff;
        }

        .btn-primary:hover {
            background-color: #0056b3;
            border-color: #0056b3;
        }

        .btn-block {
            display: block;
            width: 100%;
        }

        .header-bar {
            background-color: #FF8066; /* 변경된 헤더바 배경색 */
            padding: 10px 0; /* 상하 패딩 */
            text-align: center; /* 텍스트 가운데 정렬 */
            border-radius: 10px; /* 둥근 꼭짓점 */
            box-shadow: 0 4px 6px rgba(0,0,0,.1); /* 그림자 효과 */
            animation: slideDown 0.5s ease-out; /* 슬라이드 다운 애니메이션 */
            margin: 10px;
            position: relative;
            display: flex; /* 플렉스박스 레이아웃 적용 */
            justify-content: center; /* 가로 중앙 정렬 */
            align-items: center; /* 세로 중앙 정렬 */
        }

        .header-item {
            position: absolute;
            top: 50%;
            transform: translateY(-50%);
        }

        .header-item:first-child {
            left: 20px;
        }

        .header-item:last-child {
            right: 20px;
        }

        .username-button, .logout-button {
            display: flex;
            align-items: center;
        }

        .username-button i, .logout-button i {
            margin-right: 5px;
        }

        .header-bar h1 {
            color: white; /* 헤더바 텍스트 색상 */
            margin: 0; /* 여백 제거 */
            font-size: 1.3em; /* 폰트 크기 조정 */
            font-weight: bold;
            transition: all 0.3s ease-in-out; /* 부드러운 변화 효과 */
        }

        .header-content {
            text-align: center;
        }

        .user-info {
            position: absolute; /* 절대 위치 지정 */
            top: 10px;
            right: 20px;
            font-size: 0.9rem; /* 폰트 크기 조정 */
        }

        .logout-button {
            margin-left: 10px; /* 로그아웃 버튼과 사용자 ID 사이의 간격 */ 
        }

        .btn-sm {
            padding: 0.15rem 0.5rem;
            font-size: .8rem;
            line-height: 1.5;
            border-radius: 0.2rem;
        }

        /* 슬라이드 다운 애니메이션 효과 */
        @keyframes slideDown {
            from {
                transform: translateY(-100%);
                opacity: 0;
            }
            to {
                transform: translateY(0);
                opacity: 1;
            }
        }
    </style>
    <script>
        function createMemo() {
            var title = document.getElementById('new-title').value;
            var content = document.getElementById('new-content').value;

            fetch('/memos', {
                method:'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ title: title, content: content})
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                window.location.reload(); // 페이지 새로고침
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }

        function toggleEdit(id) {
            var titleEl = document.getElementById('title-' +  id);
            var contentEl = document.getElementById('content-', id);
            var isReadOnly = titleEl.readOnly;

            titleEl.readOnly = !isReadOnly;
            contentEl.readOnly = !isReadOnly;

            if (!isReadOnly) {
                updateMemo(id);
            }
        }

        function updateMemo(id) {
            var title = document.getElementById('title-' + id).value;
            var content = document.getElementById('content-' + id).value;

            fetch('/memos/' + id, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ title: title, content: content })
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                alert('메모가 업데이트 되었습니다.');
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }

        function deleteMemo(id) {
            if (!confirm('메모를 삭제하시겠습니까?')) return;

            fetch('/memos/' + id, {
                method: 'DELETE',
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                window.location.reload(); // 페이지 새로고침
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }

        function logout() {
            fetch('/logout', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                }
            })
            .then(response => response.json())
            .then(data => {
                console.log(data);
                window.location.href = '/'; // 로그아웃 후 홈페이지로 리디렉트
            })
            .catch((error) => {
                console.error('Error:', error);
            });
        }

        function deleteUser(id) {
            if (!confirm('정말로 계정을 삭제하시겠습니까?')) return;

            fetch('/users/' + id, {
                method: 'DELETE',
            })
            .then(response => response.json())
            .then(data => {
                if (data.success) {
                    alert('계정이 성공적으로 삭제되었습니다.');
                    window.location.reload();
                } else {
                    alert('삭제 실패: ' + (data.message || '알 수 없는 오류'));
                }
            })
            .catch((error) => {
                console.error('Error:', error);
                alert('서버 오류가 발생했습니다.');
            });
        }
    </script>
</head>
<body>
    <div class="container">
        <!--헤더바 추가-->
        <div class="header-bar">
            <div class="header-item">
                <a href="#" class="btn btn-sm btn-danger username-button">
                    <i class="fas fa-user"></i> {{ username }}
                </a>
            </div>
            <h1>나의 메모</h1>
            <div class="header-item">
                <button onclick="logout()" class="btn btn-sm btn-danger logout-button">
                    <i class="fas fa-sign-out-alt"></i> 로그아웃</button>
            </div>
        </div>
        <div class="card">
            <div class="card-body">
                <input type="text" id="new-title" placeholder="새 메모 제목" class="form-control memo-title">
                <textarea id="new-content" placeholder="내용을 입력하세요" class="form-control memo-content"></textarea>
                <button onclick="createMemo()" class="btn btn-primary btn-block">메모 추가</button>
            </div>
        </div>

        {%for memo in memos %}
        <div class="card memo">
            <div class="card-body">
                <input type="text" id="title-{{ memo.id }}" value="{{ memo.title }}" class="form-control memo-title" readonly>
                <textarea id="content-{{ memo.id }}" class="form-control memo-content" readonly>{{ memo.content }}</textarea>
                <div class="edit-buttons">
                    <button onclick="toggleEdit({{ memo.id }})" class="btn-btn-edit"><i class="fas fa-edit"></i></button>
                    <button onclick="deleteMemo({{ memo.id }})" class="btn btn-delete"><i class="fas fa-trash-alt"></i></button>
                </div> 
            </div>
        </div>
        {% endfor %}
        <div class="text-end my-4">
        <button onclick="deleteUser( {{ user_info.id }})" class="btn btn-sm btn-danger deleteUser-button">
            <i class="fas fa-user-slash"></i> 회원 탈퇴</button>
        </div>
    </div>
</body>
</html>

 


회원 탈퇴 테스트

 

main.py를 실행하여 서버를 실행한 후, 로그인하여 회원 탈퇴를 테스트한 뒤, 탈퇴 안내 이메일이 제대로 발송되는지까지 테스트해 본다.

401에러가 발생하며, 삭제가 실패했다.

 

찾아보니, 로그인에서 request.session으로 ["username"] 만 저장하기 때문에,  "id"는 세션에 저장하지 않는다. 그러니 "id"를 꺼내오면 None이 나오고, 그로 인해 401 에러가 발생한다.

# 로그인
@router.post("/login")
async def login(request: Request, signin_data: UserLogin, db: Session=Depends(get_db)):
    user = db.query(User).filter(User.username == signin_data.username).first()
    if user and verify_password(signin_data.password, user.hashed_password):
        request.session["username"] = user.username
        return {"message": "로그인 성공!"}
    else:
        raise HTTPException(status_code=401, detail="로그인이 실패하였습니다.")

 

따라서, request.session["username"] = user.username 아래에 id를 세션에 저장하는 코드를 추가해 준다.

request.session["id"] = user.id # id를 세션에 저장

 

다시 서버를 실행하여 회원 탈퇴를 해보니, 아리송한 메시지를 받았다.

이건 탈퇴된 것도 안 된것도 아니여....

그런데 터미널에는 삭제가 잘 된 것으로 나온다.

 

그리고 데이터베이스에도 삭제가 잘 된것으로 확인된다.

 

네이버 계정으로 접속하여 네이버 메일을 확인해 보니, 탈퇴 완료 메일이 잘 발송되었다.

 

◆ controllers.py

찾아보니, 삭제가 잘 됐는데, '삭제 실패' 메시지를 받는 이유는 세 가지로 추측해 볼 수 있다고 한다.

  • 사용자는 삭제됐는가? → DB에서 진짜 삭제됐는지 확인 필요 ▶ 삭제 됨
  • 백엔드 콘솔에 에러 메시지가 있는가? → except Exception as e:로 수정해서 정확한 에러 로그를 확인 에러 메시지 없음.
  • 세션이 남아 있어서 오류가 생겼는가 → request.session.clear()를 호출

따라서, 세번째 이유 때문으로 보고 세션을 정리해 주는 코드를 추가해 준다. 그리고 탈퇴하면서 자동으로 로그아웃되며, home.html로 이동되도록 코드를 수정해 준다.

from fastapi.responses import RedirectResponse

# 회원 탈퇴
@router.delete("/users/{id}")
async def delete_user(request: Request, id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
    user_id = request.session.get("id")
    if user_id is None:
        raise HTTPException(status_code=401, detail="Not Authorized")

    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")

    db.delete(user)

    try:
        db.commit()
    except Exception as e:
        db.rollback()
        print(f"회원 탈퇴 오류: {e}")  # 에러 내용 출력
        raise HTTPException(status_code=500, detail="회원 탈퇴에 실패하였습니다. 다시 시도해 주세요.")

    # 세션 비우기
    request.session.clear()

    # 탈퇴 안내 이메일 발송
    background_tasks.add_task(send_bye_email, user.email)

    # 홈 페이지로 리디렉션
    return RedirectResponse(url="/")

 

memos.html 

그리고, memos.html 도 홈페이지도 리디렉트 되도록 수정해 준다.

function deleteUser(id) {
        // 계정 삭제 확인
        if (!confirm('정말로 계정을 삭제하시겠습니까?')) return;

        fetch('/users/' + id, {
            method: 'DELETE',
            headers: {
                'Content-Type': 'application/json'
            }
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                console.log(data);
                alert('계정이 성공적으로 삭제되었습니다.');
                window.location.href = '/'; // 로그아웃 후 홈페이지로 리디렉트
            } else {
                alert('삭제 실패: ' + (data.message || '알 수 없는 오류'));
            }
        })
        .catch((error) => {
            console.error('Error:', error);
            alert('서버 오류가 발생했습니다.');
        });
    }

 

다시 서버를 실행해서 탈퇴 테스트를 해봤는데, 실패했다.

 

회원 탈퇴 오류: (psycopg2.errors.ForeignKeyViolation) 오류:  "users" 테이블의 자료 갱신, 삭제 작업이 "memo_user_id_fkey" 참조키(foreign key) 제약 조건 - "memo" 테이블 - 을 위반했습니다
DETAIL:  (id)=(2) 키가 "memo" 테이블에서 여전히 참조됩니다.

[SQL: DELETE FROM users WHERE users.id = %(id)s]
[parameters: {'id': 2}]
(Background on this error at: https://sqlalche.me/e/20/gkpj)
INFO:     127.0.0.1:52548 - "DELETE /users/2 HTTP/1.1" 500 Internal Server Error

 

즉, users 테이블에서 특정 사용자를 삭제하려고 할 때, 그 사용자가 memo 테이블의 레코드에 아직 참조되고 있다는 의미로, test_user2의 경우 현재 작성된 메모가 있기 때문에 삭제가 불가하다.

따라서, controllers.py에서 사용자를 삭제할 때 사용자가 작성한 메모도 같이 작성되게끔 처리를 해줘야 한다.

db.delete(user) 위에 사용자가 작성한 모든 메모를 삭제해 주는 코드를 추가한다.

# 사용자가 작성한 모든 메모 삭제
memos = db.query(Memo).filter(Memo.user_id == user_id).all()
logger.info(f"{len(memos)}개의 메모를 삭제합니다.")
for memo in memos:
    db.delete(memo)

# 사용자 정보 삭제
db.delete(user)

 

그리고나서 다시 테스트를 했는데, 탈퇴는 잘 되나 307 Temporary Redirect와 405 Method Not Allowed 메시지가 발생한다.

 

- 307 Temporary Redirect:
307 응답은 요청이 다른 URL로 임시적으로 리디렉트 되었다는 것을 의미한다. 일반적으로 이는 HTTP 메서드가 변경되지 않을 것을 보장한다. 즉, DELETE 요청이 GET으로 변경되지 않고 리디렉트가 발생해야 하나, FastAPI에서 DELETE 요청을 위한 리다렉트를 명시적으로 처리하지 않으므로 이 상황은 정상적이지 않다.

 

- 405 Method Not Allowed:
리디렉트된 URL(이 경우 /)에서 DELETE 요청을 시도했기 때문에 발생한 오류. / 경로에 대해 DELETE 메서드를 처리할 수 없기 때문에 405 에러가 발생한 것이다.

 

<해결 방법>

1. FastAPI에서 적절한 리다이렉트 방지:
일반적으로 DELETE 요청에 대해 리다이렉트를 자동으로 발생시키지 않도록 서버를 설정하거나, 요청이 제대로 처리될 수 있는 다른 방안으로 전환.
2. 가장 일반적인 해결책:
redirect와 관련된 설정을 수정하여 DELETE 요청이 정상적으로 처리되도록 한다. FastAPI의 엔드포인트가 정확히 동작할 수 있도록 확인.
3. HTTP 메서드 흡수:
삭제 요청을 보낼 때 클라이언트에서 보낸 URL이 정확한지를 재확인.

 

어떤 부분이 잘못됐는지 확인해 보니, controllers.py에서 회원 탈퇴를 처리하고 홈페이지로 리디렉션 하는 부분이 문제였다.

# 홈 페이지로 리디렉션
return RedirectResponse(url="/")

 

따라서, 해당 부분을 아래와 같이 성공 메시지를 반환하도록 수정해 준다.

return {"success": True, "message": "회원 탈퇴가 완료되었습니다."}

 

다시 테스트를 해보니, 계정이 성공적으로 삭제되면서 메인 페이지로 리디렉션이 잘 된다.

 

◆ controllers.py

최종적으로, 각 단계에서 에러가 발생 시 잘 확인할 수 있도록 log 를 추가해 준다.

# 회원 탈퇴
@router.delete("/users/{id}")
async def delete_user(request: Request, id: int, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
    logger.info(f"회원 탈퇴 요청: 사용자 ID {id}")

    user_id = request.session.get("id")
    if user_id is None:
        logger.warning("세션에 사용자 ID가 없음.")
        raise HTTPException(status_code=401, detail="Not Authorized")

    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        logger.error(f"사용자 찾기 실패: ID {user_id}에 대한 사용자가 존재하지 않음.")
        raise HTTPException(status_code=404, detail="User를 찾을 수 없습니다.")

    # 사용자가 작성한 모든 메모 삭제
    memos = db.query(Memo).filter(Memo.user_id == user_id).all()
    logger.info(f"{len(memos)}개의 메모를 삭제합니다.")
    for memo in memos:
        db.delete(memo)

    # 사용자 정보 삭제
    db.delete(user)

    try:
        db.commit()
        logger.info(f"사용자 ID {user_id}가 성공적으로 삭제되었습니다.")
    except Exception as e:
        db.rollback()
        logger.error(f"회원 탈퇴 오류: {e}")  # 에러 내용 출력
        raise HTTPException(status_code=500, detail="회원 탈퇴에 실패하였습니다. 다시 시도해 주세요.")

    # 세션 비우기
    request.session.clear()

    # 탈퇴 안내 이메일 발송
    background_tasks.add_task(send_bye_email, user.email)

    # 탈퇴 성공 응답 리턴
    logger.info(f"회원 탈퇴 완료: 사용자 ID {user_id}에게 탈퇴 안내 이메일 발송")
    return {"success": True, "message": "회원 탈퇴가 완료되었습니다."}


다음 내용

 

[파이썬]FastAPI - 다른 컴퓨터에서 프로젝트(VSCODE, 깃허브)

이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 11: 회원 탈퇴이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 10: 환영 이메일 발송이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 9: 소셜 로그인 추가(네

puppy-foot-it.tistory.com

 

 

[파이썬] FastAPI - 메모 앱 프로젝트 12: 파일 분할하기

이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 11: 회원 탈퇴이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 10: 환영 이메일 발송이전 내용 [파이썬] FastAPI - 메모 앱 프로젝트 9: 소셜 로그인 추가(네

puppy-foot-it.tistory.com

 

 

 

728x90
반응형