WEB 개발

[FastAPI] 비밀번호 변경 구현하기(feat. Redis, SMTP)

mayhun28 2025. 7. 25. 17:16

지난 포스팅에 python으로 이메일 보내기를 구현했었다.

2025.06.02 - [분류 전체보기] - [Python] 파이썬으로 이메일 발송하기

 

[Python] 파이썬으로 이메일 발송하기

최근 FastAPI로 백엔드를 구현하면서 회원가입 인증, 비밀번호 변경 등 메일 자동화가 필요하여 파이썬으로 이메일 발송하는 방법에 대해 포스팅 하려한다. 이전 회사에선 gmail 워크스페이스를 사

mayhun.tistory.com

SMTP를 사용해서 웹에서는 회원가입 축하메일, 비밀번호 변경 이메일 인증번호 전송 등 다양한 분야에 사용할수 있다.

오늘은 FastAPI에서 SMTP를 사용해 패스워드 변경 하는 로직을 구현하려고 한다.


왜 Redis 인가?

비밀번호 재설정 기능을 구현할때, 인증번호를 생성하고 이를 임시로 저장해둘 저장소가 필요하다. 

보통 두 가지 선택지가 있다.

  • 관계형 데이터베이스 (MySQL, PostgreSQL 등)
  • 인메모리 키-값 저장소 (Redis)

인증번호는 일시적이고 휘발성 데이터이기 때문에 Redis에 저장하는 데이터이다.

  • 일반적으로 3~10분 정도만 유효
  • 사용 후 즉시 삭제 되거나 만료 되어야함

이런 데이터는 영구 저장 보다는 일시 저장에 적합하다.

Redis의 TTL(Time-To-Live) 기능은 이런 데이터를 저장하고 일정 시간이 지나면 자동으로 삭제해주는데 최적화 되어 있다.


1. Redis 설정

Redis는 Docker 컨테이너로 실행할 예정이다.
일반적인 관계형 데이터베이스(MySQL, PostgreSQL 등)는 데이터의 영속성이 중요하기 때문에 Docker 사용 시 볼륨 마운트, 백업 전략 등 세심한 관리가 필요하다.
반면 Redis는 임시성 데이터를 처리하기 위한 인메모리 저장소이므로, Docker로 실행해도 데이터 유실에 대한 부담이 적다.

 

  • docker-compose.yml
    • depends_on 옵션으로 Redis 서비스가 먼저 실행된 후 FastAPI 컨테이너가 실행되도록 설정
version: "3.9"

services:
  web:
    build: .
    container_name: fastapi-crud
    ports:
      - "8000:8000"
    volumes:
      - .:/app
    env_file:
      - .env
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000
    depends_on:
      - redis

  redis:
    image: redis:7
    container_name: redis
    ports:
      - "6379:6379"
    restart: always

 

  • .env
    • Redis를 사용하기 위한 환경변수 설정
REDIS_HOST=redis
REDIS_PORT=6379
  • app/utils/redis_client.py
    • python에서 redis를 사용하기 위한 연결 설정
    • decode_responses=True 는 byte형식이 아닌 문자열(str)로 자동 디코딩을 해
import redis
import os

r = redis.Redis(
    host=os.getenv("REDIS_HOST", "redis"),
    port=int(os.getenv("REDIS_PORT", 6379)),
    db=0,
    decode_responses=True
)

2. SMTP 및 인증관련 설정

  •  app/schema.py
    • email 검증 pydantic 설정
class EmailRequest(BaseModel):
    email: EmailStr = Field(..., json_schema_extra={"example": "hong@naver.com"})

 

  • app/utils/security.py
    • 인증번호 생성함수 작성
    •  random.choices() 함수를 사용해 0~9까지의 숫자 중에서 무작위로 선택한 숫자를 이어 붙여 숫자 6자리 문자열 형태의 인증번호를 반환
import random
import string

def create_code(length: int = 6) -> str:
    return ''.join(random.choices(string.digits, k=length))

 

  • app/utils/email.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import os

EMAIL_HOST = "smtp.gmail.com"
EMAIL_PORT = 587
EMAIL_ADDRESS = os.getenv("EMAIL_USER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASS")

def send_email_code(to_email: str, code: str):
    subject = "비밀번호 초기화 인증번호"
    body = f"인증번호는 다음과 같습니다: {code}"

    msg = MIMEMultipart()
    msg["From"] = EMAIL_ADDRESS
    msg["To"] = to_email
    msg["Subject"] = subject

    msg.attach(MIMEText(body, "plain"))

    try:
        server = smtplib.SMTP(EMAIL_HOST, EMAIL_PORT)
        server.starttls()
        server.login(EMAIL_ADDRESS, EMAIL_PASSWORD)
        server.send_message(msg)
        server.quit()
        print(f"[SUCCESS] 이메일 전송 완료 → {to_email}")
    except Exception as e:
        print(f"[ERROR] 이메일 전송 실패: {e}")

비밀번호 변경 로직

3개의 단계로 이루어진다.

1. 인증번호 요청 단계 (/auth/password/reset-code)

사용자가 비밀번호 변경을 요청하면, 이메일 주소를 입력받아 인증번호를 생성하고 Redis에 저장한다.

이메일 주소는 토큰에 암호화 하여 reset_token이라는 이름으로 쿠키에 담아 클라이언트에 전달 된다.

 

2. 인증번호 검증 단계 (/auth/password/verify-code)

클라이언트는 이메일로 받은 인증번호를 입력하고 서버에 전달한다. 서버는 쿠키에 저장된 reset_token에서 이메일을 추출하고, Redis에서 저장된 인증번호와 일치여부는 확인한다.

검증에 성공하면 기존 reset_token은 제거하고, 비밀번호 변경용 change_token을 새롭게 발급하여 쿠키에 저장한다.

 

3. 비밀번호 변경 단계 (/auth/password/reset)

클라이언트는 변경할 비밀번호를 서버에 요청 보내고, 서버는 쿠키의 change_token을 사용해 사용자의 이메일을 확인한다.

요청 받은 서버는 이메일로 Redis에서 인증번호 검증이 완료되었는지 확인 후 완료 되었으면 사용자 조회 및 비밀번호 변경을 수행한 후 redis의 인증번호와 쿠키에서 change_token을 삭제한다.

 

3. 인증번호 메일 전송 엔드포인트 설정

  • app/routers/auth.py
from app.utils.security import create_code
from app.schema import EmailRequest
from app.utils.redis_client import r
from app.utils.email import send_email_code

@router.post('/passowrd/reset-code', summary='패스워드 변경 인증번호 요청')
def send_reset_code(request: EmailRequest):
    code = create_code()    # 인증번호 생성
    r.setex(f"reset_code:{request.email}", 300, code)   # redis에 등록 300초(5분) 동안 유지함
    send_email_code(request.email, code)    # 인증번호 메일 전송
    return {"message": "인증번호가 이메일로 전송되었습니다."}

 


4.FastAPI 실행 및 테스트

FastAPI를 실행하고 localhost:8000/docs 문서를 확인하면 /auth/password/reset-code API 가 생성되어있다.

 

비밀번호를 변경할 이메일로 변경 후 Execute를 눌러 실행하면 메일이 발송된다.

메일이 왔으나 굉장히 예쁘지 않다.

Jinja2를 사용해 html 로 템플릿을 꾸미고 메일을 발송해보는것으로 변경해보겟다.

  • app/templates/email_verification.html
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <style>
    body { font-family: 'Arial', sans-serif; background-color: #f9f9f9; padding: 20px; }
    .container { background-color: #ffffff; padding: 30px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.05); max-width: 500px; margin: auto; }
    .code { font-size: 24px; font-weight: bold; color: #007BFF; margin: 20px 0; }
    .footer { font-size: 12px; color: #888; margin-top: 20px; }
  </style>
</head>
<body>
  <div class="container">
    <h2>비밀번호 초기화 인증번호</h2>
    <p>요청하신 인증번호는 아래와 같습니다:</p>
    <div class="code">{{ code }}</div>
    <p>해당 인증번호는 5분 동안 유효합니다.</p>
    <div class="footer">
      본 메일은 시스템에 의해 자동 발송되었습니다.<br />
      문의: admin@mayworld.com
    </div>
  </div>
</body>
</html>

 

HTML 템플릿을 만든 후 Jinja를 사용하기 위해 send_email_code를 수정해준다.

  • app/utils/email.py
import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from jinja2 import Environment, FileSystemLoader

EMAIL_HOST = "smtp.gmail.com"
EMAIL_PORT = 587
EMAIL_ADDRESS = os.getenv("EMAIL_USER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASS")

# 템플릿 환경 설정 (templates 폴더 기준)
env = Environment(loader=FileSystemLoader("app/templates"))

def send_email_code(to_email: str, code: str):
    # HTML 템플릿 렌더링
    template = env.get_template("email_verification.html")
    html_content = template.render(code=code)

    subject = "비밀번호 초기화 인증번호"

    msg = MIMEMultipart("alternative")
    msg["From"] = EMAIL_ADDRESS
    msg["To"] = to_email
    msg["Subject"] = subject

    msg.attach(MIMEText(html_content, "html"))

    try:
        server = smtplib.SMTP(EMAIL_HOST, EMAIL_PORT)
        server.starttls()
        server.login(EMAIL_ADDRESS, EMAIL_PASSWORD)
        server.send_message(msg)
        server.quit()
        print(f"[SUCCESS] 인증번호 이메일 전송 완료 → {to_email}")
    except Exception as e:
        print(f"[ERROR] 이메일 전송 실패: {e}")

 

수정 한 뒤 다시 테스트를 하면 아래와 같이 메일이 발송된다.


5. 인증번호 전송 수정

  • jwt 토큰으로 reset_token을 발급하여 쿠키에 저장하는 코드 추가
@router.post('/passowrd/reset-code', summary='패스워드 변경 인증번호 요청')
def send_reset_code(request: EmailRequest, response: Response):
    
    key = f"reset_code:{request.email}"

    # 기존 인증번호 존재시 삭제
    if r.exists(key):
        r.delete(key)

    code = create_code()    # 인증번호 생성

    data = {
        "code": code,
        "verified": False
    }
    r.setex(key, 300, json.dumps(data)) # redis에 등록 300초(5분) 동안 유지함
    send_email_code(request.email, code)    # 인증번호 메일 전송

    # reset_token 발급
    reset_token = create_access_token(
        data={"sub": request.email, "purpose": "verify_code"},
        expires_delta=timedelta(minutes=5)
    )

    response.set_cookie(
        key="reset_token",
        value=reset_token,
        httponly=True,
        secure=False,
        max_age=300,
        path="/",
        samesite="strict"
    )

    return {"message": "인증번호가 이메일로 전송되었습니다."}

6. 인증번호 검증

인증번호를 생성해서 email 을 key로 하여 redis에 저장을 하는 로직을 구현하였고, 이제는 인증번호가 맞는지 검증하는 로직을 구현해야한다.

 

입력받을 스키마를 작성한다. 이메일은 jwt 디코딩을 통해 얻을 것이기 때문에 verify-code 값만 받으면 된다.

  • app/schema.py
    • CodeVerifyRequests 모델을 만든다.
class CodeVerifyRequest(BaseModel):
    code: str

 

인증번호 검증 엔드포인트 생성

  • app/routers/auth.py
    • 요청받은 이메일을 key로 code를 redis에서 찾아오고 code 검증을 수행한다.
    • 검증이 완료 되면 verified 옵션을 변경 후 다시 5분으로 갱신한다.
    • 기존 reset_token을 삭제하고, 비밀번호 변경을 위한 change_token을 설정한다.
@router.post("/password/verify-code", summary='인증번호 검증')
def verify_code(request: CodeVerifyRequest, response:Response, reset_token: str = Cookie(...)):

    # reset_token 디코딩 → 이메일 추출
    payload = decode_token(reset_token)
    if payload.get("purpose") != "verify_code":
        raise HTTPException(status_code=403, detail="인증번호 검증용 토큰이 아닙니다.")
    
    email = payload.get("sub")
    key = f"reset_code:{email}"
    value = r.get(key)

    if not value:
        raise HTTPException(status_code=400, detail="인증번호가 만료되었거나 존재하지 않습니다.")

    try:
        data = json.loads(value)
    except json.JSONDecodeError:
        raise HTTPException(status_code=500, detail="저장된 인증 데이터 오류")

    if data.get("code") != request.code:
        raise HTTPException(status_code=400, detail="인증번호가 올바르지 않습니다.")

    # 인증 완료 → Redis 상태 갱신
    r.setex(key, 300, json.dumps({"code": request.code, "verified": True}))

    # change_token 발급 (비밀번호 재설정 전용)
    change_token = create_access_token(
        data={"sub": email, "purpose": "reset_password"},
        expires_delta=timedelta(minutes=5)
    )

    # 쿠키 설정 (기존 reset_token 삭제 → change_token 설정)
    response.delete_cookie("reset_token")
    response.set_cookie(
        key="change_token",
        value=change_token,
        httponly=True,
        secure=False,
        max_age=300,
        path="/",
        samesite="strict"
    )

    return {"message": "인증번호 확인 완료. 비밀번호 변경 가능"}

 


7.  비밀번호 변경

인증번호 검증까지 완료 된뒤 비밀번호 변경 로직이다.

사용자 정보는 기존 crud의 get_user_by_email을 사용하여 DB 객체를 가져온뒤 crud에 reset_password 함수를 생성하여 DB에 비밀번호를 업데이트 한다.

 

  • app/utils/crud
    • get_user_by_email을 통해 DB 객체를 가져온뒤 reset_password를 통해 비밀번호를 업데이트 한다.
def get_user_by_email(db: Session, email: str):
    '''
    회원가입시 동일 이메일 존재 여부를 위한 조회
    '''
    return db.query(models.User).filter(models.User.email == email).first()
    
def reset_password(db: Session, user: models.User, new_password: str):
    '''
    비밀번호 변경
    '''
    hashed_pw = hash_password(new_password)
    user.hashed_pw = hashed_pw
    db.commit()

 

  • app/routers/auth.py
@router.post("/password/reset", summary="비밀번호 변경")
def reset_password(request: PasswordResetRequest, response: Response, change_token: str = Cookie(...), db: Session = Depends(get_db)):

    payload = decode_token(change_token)

    if payload.get("purpose") != "reset_password":
        raise HTTPException(status_code=403, detail="비밀번호 변경 토큰이 아닙니다.")
    
    email = payload.get('sub')

    key = f"reset_code:{email}"
    value = r.get(key)

    if not value:
        raise HTTPException(status_code=400, detail="인증이 완료되지 않았습니다.")

    data = json.loads(value)

    if not data.get("verified"):
        raise HTTPException(status_code=403, detail="이메일 인증을 완료해주세요.")

    # 사용자 조회
    user = crud.get_user_by_email(db, email)
    
    # 비밀번호 변경
    crud.reset_password(db=db, user=user, new_password=request.new_password)
    
    # 인증번호 및 토큰 제거
    r.delete(key)
    response.delete_cookie("change_token")

    return {"message": "비밀번호가 변경되었습니다."}

 

테스트

1. 인증번호 요청

정상적으로 반환후 reset_token이 쿠키에 저장되고, 인증번호로 인증번호가 전달된다.

 

 

2. 인증번호 검증

토큰값을 입력후 인증번호를 전송하면, 검증 후 기존 reset_token은 삭제되고, change_token이 저장된다.

 

3. 비밀번호 변경

토큰값을 입력후 변경할 비밀번호를 전송하면 비밀번호가 변경된다.