2026/02/16 Blog - 2

김기훈·2026년 2월 16일

TIL

목록 보기
142/194
post-thumbnail

에러메세지

  • 이전 프로젝트에서는 각 앱별로 exceptions.py를 만들어서 관리했는데 이번 프로젝트에서는 core에서는 관리
    • 메시지 정의

      • messages.py에 에러 종류를 추가
    • 에러 발생

      • 비즈니스 로직에서 raise BaseCustomException(ErrorMessage.LOGIN_FAILED)를 호출
    • 포맷 변환

      • handler.py가 이를 가로채서
      • 클라이언트가 보기 편하게 error_detail과 code가 포함된 JSON으로 변환

에러 메시지 정의

class ErrorMessage(Enum):
    """
    프로젝트 전체 에러 메시지 정의 (상태 코드, 식별 코드, 메시지)
    """
        # --- 공통 에러 ---
    SYSTEM_ERROR = (
        status.HTTP_500_INTERNAL_SERVER_ERROR,
        "server_error",
        "서버 내부 오류가 발생했습니다.",
    )
    INVALID_INPUT = (
        status.HTTP_400_BAD_REQUEST,
        "invalid_input",
        "유효하지 않은 입력값입니다.",
    )
    PERMISSION_DENIED = (
        status.HTTP_403_FORBIDDEN,
        "permission_denied",
        "권한이 없습니다.",
    )
    NOT_FOUND = (
        status.HTTP_404_NOT_FOUND,
        "not_found",
        "요청한 리소스를 찾을 수 없습니다.",
    )
    				...

    @property
    def status_code(self): return self.value[0]
    @property
    def code(self): return self.value[1]
    @property
    def message(self): return self.value[2]

공통 예외 클래스

  • Enum을 받아 에러를 던지는 도구를 만듭니다.
from rest_framework.exceptions import APIException 

class BaseCustomException(APIException): # APIException을 상속받아 커스텀 예외 클래스를 만듬
    def __init__(self, error_enum): # 초기화 시 ErrorMessage Enum 멤버를 인자로 받음
        self.status_code = error_enum.status_code # Enum에서 정의한 HTTP 상태 코드를 설정
        self.default_code = error_enum.code # Enum에서 정의한 에러 식별 코드를 설정
        self.default_detail = error_enum.message # Enum에서 정의한 메시지를 설정
        # 부모 클래스(APIException)의 생성자를 호출하여 에러 상세 내용과 코드를 전달
        super().__init__(detail=self.default_detail, code=self.default_code)

핸들러

logger = logging.getLogger("django") # 장고 로거를 설정

def custom_exception_handler(exc, context): # 모든 API 예외가 거쳐 가는 커스텀 핸들러 함수입니다.
    # 1. DRF 기본 핸들러를 먼저 호출하여 기본적인 처리를 수행
    response = exception_handler(exc, context)

    # 2. 처리되지 않은 에러(예: 500 에러)가 발생한 경우 처리
    if response is None:
	    # 서버 로그에 에러 내용을 기록
        logger.error(f"[System Error] {exc}", exc_info=True) 
        return Response( 
        	# 클라이언트에게 SYSTEM_ERROR 규격에 맞춰 응답
            {
            "error_detail": ErrorMessage.SYSTEM_ERROR.message,
            "code": ErrorMessage.SYSTEM_ERROR.code
            },
            status=ErrorMessage.SYSTEM_ERROR.status_code,
        ) 

    # 3. 모든 에러 응답 포맷을 통일
    custom_data = {
        # 'detail' 키가 있으면 가져오고, 없으면 기본 메시지를 넣음
        "error_detail": response.data.get("detail", "유효하지 않은 요청입니다."), 
        # BaseCustomException인 경우 default_code를, 아니면 일반 "error"를 넣음
        "code": getattr(exc, "default_code", "error") #
    }

    # 4. 유효성 검사(400 ValidationError) 실패 시 상세 에러 정보를 유지
    # response.data에 'detail' 키가 없다는 것은 필드별 에러 정보가 들어있다는 의미
    if response.status_code == 400 and "detail" not in response.data:
        custom_data["errors"] = response.data # 필드별 에러 내용을 'errors' 키 아래에 담음

    response.data = custom_data # 가공된 데이터를 응답 데이터로 교체
    return response

handler 비교

def custom_exception_handler(
    exc: Exception, context: dict[str, Any]
) -> Optional[Response]:
    # 1. 핸들러 호출
    response = exception_handler(exc, context)

    # 2. 시스템 에러 (500)
    if response is None:
        logger.error(f"[System Error] {exc}", exc_info=True)
        return Response(
            {"error_detail": "서버 내부 오류가 발생했습니다.", "code": "server_error"},
            status=status.HTTP_500_INTERNAL_SERVER_ERROR,
        )

    # 3. 에러 메시지 포맷 통일 (Detail -> Error Detail)

    # 유효성 검사 실패 (400)
    if isinstance(exc, ValidationError):
        view = context.get("view")
        # 뷰에 설정된 메시지 or 기본 메시지 가져오기
        message = getattr(
            view, "validation_error_message", "유효하지 않은 데이터입니다."
        )

        response.data = {"error_detail": message, "errors": response.data}

    # 4. 그 외 에러 처리 (데이터가 딕셔너리인 경우)
    if isinstance(response.data, dict):
        # 401 인증 에러 (로그인 안 함)
        if isinstance(exc, (NotAuthenticated, AuthenticationFailed)):
            response.data = {"error_detail": "로그인이 필요한 서비스입니다."}

        # 그 외 모든 에러 (403, 404, 409 등)
        else:
            # 'detail' 키가 있으면 'error_detail'로 이름표 바꿔달기
            if "detail" in response.data:
                response.data = {"error_detail": str(response.data["detail"])}

            # 커스텀 예외에 code가 있다면 추가
            if hasattr(exc, "default_code"):
                response.data["code"] = exc.default_code

    return response

——————————————————————————————————————[비교]—————————————————————————————————————————
def custom_exception_handler(exc, context):
    # 1. DRF 기본 핸들러 호출
    response = exception_handler(exc, context)

    # 2. 처리되지 않은 500 에러 처리
    if response is None:
        logger.error(f"[System Error] {exc}", exc_info=True)
        return Response(
            {"error_detail": ErrorMessage.SYSTEM_ERROR.message, "code": ErrorMessage.SYSTEM_ERROR.code},
            status=ErrorMessage.SYSTEM_ERROR.status_code,
        )

    # 3. 응답 포맷 통일: {"error_detail": "...", "code": "...", "errors": {...}}
    custom_data = {
        "error_detail": response.data.get("detail", "유효하지 않은 요청입니다."),
        "code": getattr(exc, "default_code", "error")
    }

    # 유효성 검사(400) 실패 시 상세 정보 유지
    if response.status_code == 400 and "detail" not in response.data:
        custom_data["errors"] = response.data

    response.data = custom_data
    return response

코드 수정(에러 적용)

service

    LOGIN_FAILED = (
    status.HTTP_401_UNAUTHORIZED, 
    "login_failed",
    "이메일 또는 비밀번호가 일치하지 않습니다."
    )
    USER_INACTIVE = (
    status.HTTP_403_FORBIDDEN,
    "user_inactive",
    "해당 계정은 비활성화 상태입니다."
    )
    
———————————————————————————————————————————[준비물]———————————————————————————————————————————————
class UserService:
    @staticmethod
    def authenticate_user(email, password):

        # 1. Django 기본 인증 함수(authenticate)를 사용
        user = authenticate(email=email, password=password)

        # 2. 인증 실패 시(user가 None일 경우) 예외를 발생시킵니다.
        if not user:
            raise exceptions.AuthenticationFailed(
                "이메일 또는 비밀번호가 일치하지 않습니다."
            )

        # 3. 계정 활성화 여부를 확인
        if not user.is_active:
            raise exceptions.PermissionDenied("해당 계정은 비활성화 상태입니다.")
    
————————————————————————————————————————————[비교]————————————————————————————————————————————————
class UserService:
    @staticmethod
    def authenticate_user(email, password):
        user = authenticate(email=email, password=password)

        if not user:
            raise BaseCustomException(ErrorMessage.LOGIN_FAILED)

        if not user.is_active:
            raise BaseCustomException(ErrorMessage.USER_INACTIVE)

serializer

class SignupSerializer(serializers.ModelSerializer):

	...
    
    def validate_email(self, value):
        """
        이메일 중복 여부를 검증합니다.
        Model의 unique=True가 있지만, 여기서 명시적인 에러 메시지를 주는 것이 UX에 좋습니다.
        """
        # 해당 이메일로 가입된 유저가 있는지 DB에서 확인
        if User.objects.filter(email=value).exists():
            # 이미 존재한다면 유효성 검사 에러(400)를 발생
            raise serializers.ValidationError("이미 존재하는 이메일입니다.")
        # 문제가 없다면 입력받은 이메일 값을 그대로 반환
        return value

——————————————————————————————————————[비교]—————————————————————————————————————————
class SignupSerializer(serializers.ModelSerializer):
	
    ...
    
    def validate_email(self, value):
        if User.objects.filter(email=value).exists(): #
            raise BaseCustomException(ErrorMessage.EMAIL_ALREADY_EXISTS)
        return value

테스트

profile
안녕하세요.

0개의 댓글