DRF Exception에 대한 고찰 및 활용

imasimdi·2023년 12월 22일

Django Rest Framework

목록 보기
2/5
post-thumbnail

들어가면서

이전에 슬기로운 파이썬 트릭 책을 읽고 예외를 어떻게 하면 슬기롭게 처리할지 습득했었다.
leehjhjhj/reading-books/python-tricks-the-book
my_execption.py

이 내용을 바탕으로, 이번 프로젝트에서 다양한 나만의 예외를 만들어서 사용해보았다.

커스텀 Exception을 왜 사용하는가?

다양한 에러코드 반환을 위해

내가 생각하는 가장 큰 이유는 어떤 오류 발생 시, 500 에러 를 막아주기 위해서이다.
서버에서는 오류가 발생하면 500이나 400이나 해당 에러에 대한 로그를 살펴보면 그만이지만 에러의 종류에 따라서 다양한 라우팅을 진행하는 클라이언트 입장에서는 에러에 대한 status code가 상당히 중요하다.
따라서 try: except: 구문으로 하나로 퉁쳐서 동일한 에러를 뱉는 것 보다는 특정 상황에 알맞는 status code를 클라이언트에게 제공하는 것이 좋다.

로깅을 위해

에러가 발생했을 때 이름만으로 어떤 에러인지 예상을 할 수 있게 된다. 가령 채팅방을 삭제할 수 있는 권한이 없을 때

class NoRightToDeleteChatError(APIException):
    status_code = status.HTTP_403_FORBIDDEN
    default_detail = '채팅창을 삭제할 권한이 없습니다.'

로그를 확인하면 NoRightToDeleteChatError 가 발생했을 것이고, 네이밍 만으로도 어떤 부분에서 일어났는지 예상이 가능하게 된다.

재사용성

또한 한번 에러를 커스텀해두면 여러 종류의 비지니스 로직에서 같은 에러에 대해서 불필요하게 코드를 다시 칠 필요가 없다. 앞선 예시로 다시 들어보면 만약에 로그인 하지 않았을 때, 로그인 했어도 내가 생성한 채팅방이 아닐 때 해당 에러가 발생한다고 가정하자. 만약 저러한 커스텀 예외가 없을 경우

if user.is_anonymous:
	raise Exception("채팅창을 삭제할 권한이 없습니다.")
...
if chat.mady_by != user:
	raise Exception("채팅창을 삭제할 권한이 없습니다.")

이렇게 직접 오류 내용을 하드코딩 해야한다. 만약 어떤 요구 사항에 의해 오류의 내용을 바꿔야 되거나, status code를 변경해야 한다면 일일이 모든 코드를 수정해줘야 한다. 하지만 하나의 커스텀 예외를 가지고 있으면 해당 예외 코드만 수정해주면 될 뿐더러, 클라이언트에게 보여질 문구가 서비스 계층에서 보이지 않게 되어서 큰 장점이 있다.

상속 관계

DRF에서 Exception 끼리의 상속 관계는 다음과 같다.

APIException -> Exception -> BaseException

그리고 DRF에서 기본으로 제공하는 예외들 (AuthenticationFailed, PermissionDenied 등등)은 ValidationError를 제외하고는 모두 APIException을 상속한다. 그 이유는 APIException의 내부 구조를 보면 알 수 있다.

class APIException(Exception):
    """
    Base class for REST framework exceptions.
    Subclasses should provide `.status_code` and `.default_detail` properties.
    """
    status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
    default_detail = _('A server error occurred.')
    default_code = 'error'

    def __init__(self, detail=None, code=None):
        if detail is None:
            detail = self.default_detail
        if code is None:
            code = self.default_code

        self.detail = _get_error_details(detail, code)

    def __str__(self):
        return str(self.detail)

    def get_codes(self):
        """
        Return only the code part of the error details.

        Eg. {"name": ["required"]}
        """
        return _get_codes(self.detail)

    def get_full_details(self):
        """
        Return both the message & code parts of the error details.

        Eg. {"name": [{"message": "This field is required.", "code": "required"}]}
        """
        return _get_full_details(self.detail)

기본은 500 에러를 반환하지만, 이를 오버라이딩 하면 status code도 직접 정할 수 있고, 에러에 대한 detail 까지 직접 정할 수 있다. 논외로 저 코드 정말 잘짜여저 있는 것 같다.

왜 ValidationError만 Exception을 상속 받았을까?

우선 가장 큰 이유는 용도와 범위 때문이다. 이 에러는 데이터 유효성 검사 실패시 발생하는 오류를 표현하기 위해서고, 해당 오류는 특정 API 호출에 의한 에러 뿐만이 아니라 일반적인 데이터 처리 과정 에도 호출되는 에러이기 때문이다.
예를 들어서 ValidationErrors는 Serializer의 직렬화 과정에서 데이터 유효성을 검증할 때도 쓰인다. 보통의 예외인 경우 회원가입시 한 필드에 오류가 발생하면 status code와 내용을 보여주고 끝이지만 ValidationError는 어떤 필드들이 문제인가에 대해서 한 번 에 알려준다.

def validate_password(password):
    password_reg = r"^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{6,13}$"
    password_regex = re.compile(password_reg)

    if not password_regex.match(password):
        raise ValidationError("영문, 숫자, 특수문자를 조합해 6자 이상, 13자 이하 입력해주세요.")
    
def validate_email(email):
    if Member.objects.filter(email=email).exists():
        raise ValidationError("이미 가입된 회원이에요!")
    
    email_reg = r"^[a-zA-Z0-9_-]{6,13}@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
    email_regex = re.compile(email_reg)

    if not email_regex.match(email):
        raise ValidationError("아이디는 6자 이상 13자 이하로 가능하고, 특수 문자는 _와 -만 사용 가능해요.")
    
def validate_nickname(nickname):
    if Member.objects.filter(nickname=nickname).exists():
        raise ValidationError("닉네임이 이미 존재해요! 다른 걸로 부탁해요.")

이러한 회원가입 validater들이 존재하고

class SignupRequestSerializer(serializers.ModelSerializer):
    email = serializers.EmailField(validators=[validate_email])
    password = serializers.CharField(validators=[validate_password])
    nickname = serializers.CharField(validators=[validate_nickname])

해당 validator를 serialzier에 적용시킨다면 다음과 같은 response를 얻을 수 있다.

{
    "email": [
        "아이디는 6자 이상 13자 이하로 가능하고, 특수 문자는 _와 -만 사용 가능해요."
    ],
    "password": [
        "영문, 숫자, 특수문자를 조합해 6자 이상, 13자 이하 입력해주세요."
    ],
    "nickname": [
        "닉네임이 이미 존재해요! 다른 걸로 부탁해요."
    ]
}

이렇게 되면 밑과 같은 화면의 구현이 더욱 쉬워지게 된다. 클라이언트에서 별도로 추가할 로직이 없기 때문이다.

만약 닉네임만 수정해야 된다면 닉네임만 validator 에러가 발생할 것이고, 두개면 두개, 세개면 세가지의 필드가 response body로 날라간다. 아주 편리하다.

나의 커스텀 Exception 활용 예시

만약 채팅방에 현재 인원이 꽉 차서 못 들어가는 상화잉 발생했을 때 해당 예외를 터뜨려준다.

class OverMaxCountError(APIException):
    status_code = status.HTTP_400_BAD_REQUEST
    default_detail = '채팅방이 꽉 찼습니다.'

적용 코드는 이렇다.

def join_chat(self, user_data: Member, chat_id: str):
        chat = self._chat_repository.find_chat_by_id(chat_id)
        max_capacity = chat.max_capacity
        redis_conn = get_redis_connection(db_select=1)
        headcount = redis_conn.scard(chat_id)
        if headcount + 1 >= max_capacity:
            raise OverMaxCountError

현재 채팅방의 접속 인원은 redis에 value 값으로 저장되어있다. 따라서 들어가고자 하는 채팅의 정원수보다 현재 접속 인원 + 1 한 값이 크다면, 해당 에러를 raise를 통해서 발생해준다.
이렇게 하면 서비스 단에서 직접 에러 메시지를 노출할 필요도 없고, 이름만으로도 어떤 에러인지 직관적으로 알 수 있게된다. 더욱이 계층이 분리되어있는 아키텍처에서는 이렇게 예외를 터뜨려주면 하위 계층의 에러를 상위 계층에서 처리할 필요가 없게된다.

이것이 그 예이다.

class ChatRepository:
    def find_all_chat_rooms_with_members(self):
        return get_list_or_404(Chat.objects.select_related('made_by').order_by('-created_at'))

해당 get_list_or_404는 커스텀 예외와는 상관 없는 부분이지만, 데이터 계층에서 객체를 찾을 때 404 에러를 발생시켜주면, 서비스 계층에서 try 구문을 통한 예외 처리를 할 필요가 없어져서 계층간 역할을 침범시키지 않게한다.

따라서, APIException의 오버라이딩이나 get_object_or_404, get_list_or_404의 활용은 계층 분리도 확실하게 할 수 있다.

만약에 Status Code가 중복될 경우는 어떻게 할까?

status code는 한정적이기 때문에, 클라이언트 측에서 같은 status code라도 어떤 에러인지 구분을 해야 할 때가 있다. 그럴때 사용하는 것이 'default_code' 이다.
그러나 이 default code를 response에 추가하고 싶으면 custom exception handler가 필요하다.

from rest_framework.views import exception_handler

def custom_exception_handler(exc, context):
    # Call REST framework's default exception handler first,
    # to get the standard error response.
    response = exception_handler(exc, context)

    # Now add the HTTP status code to the response.
    if response is not None:
        response.data['code'] = exc.default_code

    return response

이후에 settings.py에서 해당 핸들러를 기본으로 지정한다.

REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'utils.exceptions.handler.custom_exception_handler'
}

이후에 response를 살펴보면?

{
    "detail": "로그인이 필요합니다.",
    "code": "required_login_error"
}

이렇게 코드가 잘 나온다! 또한 status code까지 반환해줄 수 있다.

if response is not None:
        response.data['code'] = exc.default_code
        response.data['status_code'] = response.status_code

결과

{
    "detail": "로그인이 필요합니다.",
    "code": "required_login_error",
    "status_code": 401
}

이렇게 Custom Exception Handler를 이용해서 클라이언트가 원하는 response도 마음 것 해줄 수 있다. 앞서 작성한 장점들이 많으니 다른 사람들도 꼭 프로젝트에 적용시켰으면 좋겠다.

📚참고자료

https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling

profile
터키어 하는 개발자(호소인)이에요

0개의 댓글