최근 Next.js와 FastAPI 기반의 프로젝트에서 회원가입 시 이메일 인증 기능을 구현하게 되었습니다. Firebase나 Resend 같은 훌륭한 메일링 서비스도 많지만, 이미 프로덕션 배포에 NCP(네이버 클라우드)를 사용하고 있어 관리의 통일성을 위해 이메일 인증 또한 NCP를 활용하기로 결정했습니다.
하지만 막상 구현하려니 스프링부트 기반의 자료는 찾을 수 있었으나 Python 및 FastAPI 환경에서의 레퍼런스는 찾기 어렵더라구요. 이에 저와 같은 고민을 하실 분들을 위해, 그리고 개인적인 학습 기록을 위해 구현 과정을 짤막하게 정리해 보았습니다.
구현 목표
- NCP Cloud Outbound Mailer 서비스를 통한 메일 발송
- SPF/DKIM 보안 설정을 통한 스팸 분류 방지
- FastAPI 및
httpx를 이용한 비동기 API 호출 구현 - Redis를 이용한 인증번호 관리 (선택 사항)
1. NCP 설정
코드를 작성하기 전, 네이버 클라우드 플랫폼에서 서비스 신청과 키 발급을 선행해야 합니다.
1️⃣ 서비스 이용 신청
NCP 콘솔의 Services > Cloud Outbound Mailer 메뉴로 이동하여 Subscription에서 이용 신청을 진행합니다.
2️⃣ 도메인 등록
Domain Management → 도메인 등록 메뉴에서 발송에 사용할 주소를 등록합니다. 이 주소가 이메일 뒤에 붙는 주소가 됩니다. (noreply@domain.com 등..) 입력 후 인증 토큰까지 DNS 레코드로 등록해야 정상적인 발송이 가능합니다.
도메인 관리에 가비아를 사용하신다면, 'DNS 관리툴'에서 레코드 수정이 가능합니다. 적용까지 오래 걸리지는 않았고, 1분 내외로 걸리는 것으로 보입니다.
3️⃣ SPF / DKIM 설정
일반 사용자에게 대량으로 메일을 발송할 때 스팸으로 분류되는 것을 막기 위해 필수적인 과정이라고 합니다.
- Domain Management → SPF / DKIM 인증 메뉴에서 인증 토큰을 발급합니다.
-
화면에 표시되는 TXT 레코드 값을 DNS 레코드에 추가 등록합니다.
- NCP 콘솔에서 새로고침 후 인증 상태가 '설정(인증 완료)' 변경되었는지 확인합니다.
4️⃣ API 인증키 발급
SPF / DKIM은 콘솔에서 발급받았으나, API Key는 NCP 콘솔이 아닌 네이버 클라우드 포털의 마이페이지에서 발급받아야 합니다. 저도 왜 그런진 잘 모르겠습니다.. 아마 계정 전체에 관한 것이라서 그런 듯 싶습니다.
- 포털 마이페이지 > 계정 관리 > 인증키 관리로 이동합니다. (또는 여기를 눌러 이동하세요.)
- 신규 API 인증키 생성을 클릭합니다.
- 발급된
Access Key ID와Secret Key를 저장합니다.
여기까지 하셨으면, 네이버 클라우드에서 해야 할 일은 모두 끝났습니다.
2. FastAPI 백엔드 구현
백엔드 구현은 FastAPI를 사용했으며, 요청과 응답, 그리고 인증을 위한 더 좋은 방식들이 많이 있겠습니다만, 가장 간단한 구현 예시 코드를 들어 설명드립니다.
1️⃣ 라이브러리 설치
비동기 HTTP 요청 처리를 위해 httpx를 사용했습니다.
pip install httpx2️⃣ 환경변수 설정
발급받은 키 정보를 환경변수로 관리합니다. 아까 마이페이지에서 발급받은 두 개의 키와, 메일링에 사용할 주소(도메인)을 환경변수로 .env 파일에 저장하고 config.py에서 로드하여 사용합니다.
# backend/app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# ENV에 동일한 key의 환경변수가 존재해야 합니다.
NCP_ACCESS_KEY: str
NCP_SECRET_KEY: str
NCP_SENDER_ADDRESS: str
class Config:
env_file = ".env"
settings = Settings()3️⃣ 메일 발송 서비스 구현
NCP API 호출 시 헤더에 HMAC-SHA256 알고리즘으로 생성한 Signature를 포함해야 합니다. httpx를 사용하여 API를 호출하는 클래스를 작성했습니다.
# backend/app/core/mail.py
import time
import hashlib
import hmac
import base64
import httpx
from backend.app.core.config import settings
class NCPMailService:
def __init__(self):
self.api_url = "https://mail.apigw.ntruss.com/api/v1/mails"
self.access_key = settings.NCP_ACCESS_KEY
self.secret_key = bytes(settings.NCP_SECRET_KEY, 'UTF-8')
def _make_signature(self, timestamp: str) -> str:
method = "POST"
uri = "/api/v1/mails"
message = f"{method} {uri}\n{timestamp}\n{self.access_key}"
message = bytes(message, 'UTF-8')
signing_key = base64.b64encode(
hmac.new(self.secret_key, message, digestmod=hashlib.sha256).digest()
)
return signing_key.decode('UTF-8')
async def send_verification_email(self, to_email: str, code: str) -> bool:
timestamp = str(int(time.time() * 1000))
headers = {
"Content-Type": "application/json",
"x-ncp-apigw-timestamp": timestamp,
"x-ncp-iam-access-key": self.access_key,
"x-ncp-apigw-signature-v2": self._make_signature(timestamp),
}
body = {
"senderAddress": settings.NCP_SENDER_ADDRESS,
"title": "[서비스명] 이메일 인증번호 안내",
"body": f"""
<div style="padding: 20px; border: 1px solid #e0e0e0;">
<h2>이메일 인증</h2>
<p>인증번호: <strong style="color: #4575F5;">{code}</strong></p>
<p>3분 이내에 입력해주세요.</p>
</div>
""", # 취향껏 바꿔주세요
"recipients": [{"address": to_email, "name": "고객", "type": "R"}],
"individual": True,
"advertising": False
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(self.api_url, headers=headers, json=body)
return response.status_code == 201
except Exception as e:
print(f"메일 전송 실패: {e}")
return False
mail_service = NCPMailService()4️⃣ API 라우터 구현
인증번호 생성 및 검증 로직을 처리하는 API 엔드포인트입니다. 간단한 구현을 위해 우선 파이썬의 Dictionary를 사용하여 메모리에 저장했습니다.
# backend/app/features/user/router.py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr
import random
import string
from backend.app.core.mail import mail_service
router = APIRouter()
verification_store = {} # 인증번호를 저장하는 딕셔너리
class EmailRequest(BaseModel):
email: EmailStr
class VerifyCodeRequest(BaseModel):
email: EmailStr
code: str
@router.post("/request-verification")
async def request_email_verification(data: EmailRequest):
code = ''.join(random.choices(string.digits, k=6))
verification_store[data.email] = code
success = await mail_service.send_verification_email(data.email, code)
if not success:
raise HTTPException(status_code=500, detail="메일 발송 실패")
return {"message": "인증번호 발송 성공"}
@router.post("/verify-code")
async def verify_code(data: VerifyCodeRequest):
saved_code = verification_store.get(data.email)
if not saved_code or saved_code != data.code:
raise HTTPException(status_code=400, detail="인증번호 불일치")
del verification_store[data.email]
return {"message": "인증 성공"}5️⃣ Redis로 인증번호 관리하기 (심화)
2-4 까지 완료하셨다면 기본적인 이메일 인증은 모두 구현 완료됩니다.
다만, 위의 verification_store = {} 방식은 서버가 재시작되면 모든 인증번호 데이터가 사라지며, Gunicorn 등으로 여러 워커 프로세스를 띄울 경우 메모리가 공유되지 않아 인증에 실패할 수 있습니다. (쿠버네티스를 쓴다면 발생 가능한 문제입니다!) 이를 해결하기 위해 영구 저장용 Redis 볼륨 사용을 아래와 같이 작성할 수 있어요.
먼저 Redis 라이브러리를 설치 후,
pip install redis라우터 코드를 다음과 같이 수정합니다.
import redis.asyncio as redis
# 실제 환경에서는 Redis URL을 config에서 로드하는 것이 좋습니다
redis_client = redis.from_url("redis://localhost:6379")
@router.post("/request-verification")
async def request_email_verification(data: EmailRequest):
code = ''.join(random.choices(string.digits, k=6))
# Redis에 저장: Key=이메일, Value=코드, TTL=180초(3분)
await redis_client.setex(name=data.email, time=180, value=code)
success = await mail_service.send_verification_email(data.email, code)
if not success:
raise HTTPException(status_code=500, detail="메일 발송 실패")
return {"message": "인증번호 발송 성공"}
@router.post("/verify-code")
async def verify_code(data: VerifyCodeRequest):
# Redis에서 조회
saved_code = await redis_client.get(data.email)
# Redis는 bytes 형태로 반환하므로 디코딩 필요
if not saved_code or saved_code.decode('utf-8') != data.code:
raise HTTPException(status_code=400, detail="인증번호 불일치")
# 인증 성공 시 데이터 삭제
await redis_client.delete(data.email)
return {"message": "인증 성공"}3. 프론트엔드 연동
2에서 생성한 API를 호출하여 인증 프로세스를 진행합니다.
// .../sign-up/page.tsx
const handleRequestVerification = async () => {
// ... 이메일 유효성 검사 ...
const response = await fetch(`${API_URL}/api/v1/users/request-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
// ... 성공 시 타이머 시작 처리 ...
};
const handleVerifyCode = async () => {
const response = await fetch(`${API_URL}/api/v1/users/verify-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code: emailCode }),
});
// ... 성공 시 가입 버튼 활성화 처리 ...
};마무리
이 과정을 통해 FastAPI 환경에서도 네이버 클라우드의 메일 서비스를 안정적으로 연동할 수 있었습니다. 특히 Cloud Outbound Mailer를 한 번 설정해놓게 되면 이메일 인증 외에도 서비스에 필요한 다른 이메일 전송도 가능하기 때문에, 많은 도움 되셨길 바랍니다.