에러 처리와 로깅 — 문제를 빨리 발견하고 해결하기¶
에러는 피할 수 없다¶
어떤 코드도 항상 완벽하게 동작하지 않습니다.
예측 가능한 에러:
- 없는 ID로 조회 → 404
- 중복 이메일로 가입 → 400
- 잘못된 토큰 → 401
예측하기 어려운 에러:
- DB 연결 실패
- 외부 서비스 타임아웃
- 메모리 부족
- 네트워크 오류
좋은 서비스는 에러를 우아하게 처리합니다.
사용자에게는 명확한 메시지를, 개발자에게는 충분한 정보를 제공합니다.
Python 예외 처리¶
# 기본 try/except
try:
result = 10 / 0
except ZeroDivisionError:
print("0으로 나눌 수 없습니다")
# 여러 예외
try:
conn = sqlite3.connect("app.db")
rows = conn.execute("SELECT * FROM todos").fetchall()
except sqlite3.OperationalError as e:
print(f"DB 오류: {e}")
except Exception as e:
print(f"알 수 없는 오류: {e}")
finally:
conn.close() # 항상 실행
커스텀 예외¶
class NotFoundError(Exception):
def __init__(self, resource: str, id: int):
self.message = f"{resource} #{id}를 찾을 수 없습니다"
super().__init__(self.message)
class UnauthorizedError(Exception):
pass
# 사용
def get_todo(todo_id: int):
row = db.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
if row is None:
raise NotFoundError("Todo", todo_id)
return row
FastAPI에서 에러 처리¶
HTTPException — 직접 에러 반환¶
from fastapi import HTTPException
@app.get("/todos/{todo_id}")
def get_todo(todo_id: int):
row = db.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
if row is None:
raise HTTPException(
status_code=404,
detail=f"Todo #{todo_id}를 찾을 수 없습니다"
)
return dict(row)
전역 예외 핸들러 — 공통 에러 처리¶
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
# 예상치 못한 모든 에러를 잡아서 500으로 응답
logger.error(f"처리되지 않은 오류: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail, "status": exc.status_code}
)
로깅 (Logging)¶
print()로 디버깅하면 운영 환경에서 어떤 일이 일어났는지 알기 어렵습니다.
로깅은 언제, 어디서, 무슨 일이 있었는지 기록합니다.
로그 레벨¶
DEBUG 가장 상세한 정보 — 개발 중에만
INFO 일반 동작 기록 — "로그인 성공", "항목 생성"
WARNING 이상 징후지만 아직 오류는 아님
ERROR 오류 발생 — 기능 동작 실패
CRITICAL 시스템이 멈출 수 있는 심각한 오류
Python 기본 로깅¶
import logging
# 로거 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger = logging.getLogger(__name__)
# 사용
logger.debug("디버그 정보")
logger.info("항목 생성됨: id=5")
logger.warning("비정상적인 요청 패턴 감지")
logger.error("DB 연결 실패", exc_info=True) # 스택 트레이스 포함
FastAPI에서 로깅 적용¶
# main.py
import logging
from fastapi import FastAPI, Request
import time
logger = logging.getLogger(__name__)
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = time.time() - start
logger.info(
f"{request.method} {request.url.path} "
f"→ {response.status_code} "
f"({duration:.3f}s)"
)
return response
@app.post("/auth/login")
def login(body: UserLogin):
logger.info(f"로그인 시도: {body.email}")
row = get_user_by_email(body.email)
if not row or not verify_password(body.password, row["password"]):
logger.warning(f"로그인 실패: {body.email}")
raise HTTPException(status_code=401, detail="인증 실패")
logger.info(f"로그인 성공: {body.email} (id={row['id']})")
return {"access_token": create_access_token(row["id"])}
출력 예시:
2026-05-23 10:15:32 [INFO] main: 로그인 시도: user@ex.com
2026-05-23 10:15:32 [INFO] main: 로그인 성공: user@ex.com (id=3)
2026-05-23 10:15:32 [INFO] main: POST /auth/login → 200 (0.045s)
로그를 파일로 저장¶
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.FileHandler("app.log"), # 파일에 저장
logging.StreamHandler() # 터미널에도 출력
]
)
에러 메시지 — 사용자 vs 개발자¶
사용자에게 보내는 메시지와 내부 로그는 달라야 합니다.
@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int, user = Depends(get_current_user)):
conn = get_db()
try:
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="항목을 찾을 수 없습니다")
if row["user_id"] != user["id"]:
logger.warning(
f"권한 없는 삭제 시도: user_id={user['id']}, todo_id={todo_id}"
)
raise HTTPException(status_code=403, detail="권한이 없습니다")
conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
conn.commit()
logger.info(f"항목 삭제: todo_id={todo_id}, user_id={user['id']}")
return {"deleted": todo_id}
except HTTPException:
raise # HTTPException은 그대로 전달
except Exception as e:
logger.error(f"삭제 중 오류: todo_id={todo_id}", exc_info=True)
raise HTTPException(status_code=500, detail="서버 오류가 발생했습니다")
finally:
conn.close()
사용자에게 DB 오류 상세 내용이나 스택 트레이스를 보여주지 마세요.
공격자가 시스템 구조를 파악하는 데 이용할 수 있습니다.
입력 검증 — 에러를 미리 막기¶
에러를 처리하는 것보다 에러가 생기지 않게 미리 검증하는 것이 낫습니다.
from pydantic import BaseModel, Field, field_validator
class UserCreate(BaseModel):
email: str = Field(min_length=5, max_length=100)
password: str = Field(min_length=8)
name: str = Field(min_length=1, max_length=50)
@field_validator("email")
@classmethod
def email_must_contain_at(cls, v):
if "@" not in v:
raise ValueError("올바른 이메일 형식이 아닙니다")
return v.lower() # 소문자로 정규화
실습 미션¶
미션 1: 에러 메시지 개선¶
현재 API의 에러 응답을 점검하고:
1. 404 응답에 어떤 리소스를 찾을 수 없는지 명시하세요.
2. 400 응답에 어떤 필드가 잘못되었는지 명시하세요.
3. 500 응답에 내부 오류 내용이 노출되지 않도록 하세요.
미션 2: 로깅 추가¶
main.py에 다음 로그를 추가하세요:
1. 모든 요청에 메서드, 경로, 상태 코드, 소요 시간 기록 (미들웨어)
2. 로그인 시도 및 성공/실패 기록
3. 항목 생성, 수정, 삭제 기록
터미널 출력으로 확인하세요.
미션 3: 전역 예외 핸들러¶
예상치 못한 오류를 처리하는 전역 핸들러를 추가하고:
1. 오류 내용을 로그에 기록하세요.
2. 사용자에게는 일반적인 500 메시지만 반환하세요.
3. 의도적으로 예외를 발생시켜 핸들러가 동작하는지 확인하세요.
미션 4 (심화): 로그 파일 저장¶
로그를 app.log 파일에 저장하고:
1. 오래된 로그가 자동으로 정리되도록 RotatingFileHandler를 사용하세요.
2. 로그 레벨을 환경 변수로 조절할 수 있게 만드세요.
(개발: DEBUG, 운영: INFO)
핵심 요약¶
| 개념 | 설명 |
|---|---|
try/except |
예외가 발생해도 프로그램이 계속 실행되게 처리 |
HTTPException |
FastAPI에서 HTTP 에러 응답 반환 |
| 전역 핸들러 | 잡히지 않은 예외를 일괄 처리 |
| 로그 레벨 | DEBUG < INFO < WARNING < ERROR < CRITICAL |
| 미들웨어 | 모든 요청/응답에 공통 처리 추가 |
| 입력 검증 | Pydantic으로 잘못된 입력을 미리 차단 |
사용자에게는 친절한 메시지,
로그에는 디버깅에 필요한 모든 정보를 남기세요.