콘텐츠로 이동

에러 처리와 로깅 — 문제를 빨리 발견하고 해결하기

에러는 피할 수 없다

어떤 코드도 항상 완벽하게 동작하지 않습니다.

예측 가능한 에러:
  - 없는 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: 로그인 시도: [email protected]
2026-05-23 10:15:32 [INFO] main: 로그인 성공: [email protected] (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으로 잘못된 입력을 미리 차단

사용자에게는 친절한 메시지,
로그에는 디버깅에 필요한 모든 정보를 남기세요.