HttpError 및 커스텀 응답#

이 글은 Django Ninja 시리즈 튜토리얼의 21번째 글이에요.
소프트웨어 개발에서 오류 처리는 무시할 수 없는——하지만 종종 무시되는——부분이에요.
과장 없이 말해서, 오류 처리는 「잘해도 칭찬받지 못하지만, 못하면 시스템이 엉망이 되는」 이슈라고 할 수 있죠.
괜찮아요. 우리는 그래도 스스로 할 수 있는 최선을 다해야 하니까요.
Django Ninja는 Pydantic을 사용하여 데이터 검증을 수행하며, 실패 시 기본적으로 「422 Unprocessable Entity」를 응답해요.
하지만, 우리는 때때로 현실의 비즈니스 요구 사항이나 팀의 개발 습관에 맞게 「400 Bad Request」나 다른 상태 코드를 응답해야 할 때가 있어요.
어쨌든, 이유를 불문하고 우리는 오류 메시지, 형식, 그리고 응답의 상태 코드를 커스터마이징하고 싶지, Django Ninja의 기본 422 응답을 사용하고 싶지는 않아요——솔직히 말해서 이 규격화된 응답은 다양한 상황에 호환되어야 하기 때문에 정보가 너무 많고 구조도 조금 복잡하거든요.
이 글에서는 Django Ninja에 내장된 HttpError를 사용하여 오류 처리와 응답을 커스터마이징하는 방법을 소개할게요.
모든 코드 변경 사항은 이 PR을 참고해 주세요.
GitHub 예제 프로젝트#
Django Ninja의 자동 오류 처리#
이전 글에서 언급했듯이 Schema의 검증 메서드 내에서 ValueError 오류를 던지면, Django Ninja가 이를 자동으로 잡아내고 응답할 거예요.
사실 ValueError뿐만 아니라, Django Ninja는 다음 몇 가지 오류도 대신 처리해 줘요:
pydantic.ValidationError: Pydantic에서 오는 검증 오류예요. 이것이 왜 Schema 필드에 문제가 있을 때 우리가 바로 422 응답을 받는지에 대한 이유죠.- 이 외에도 Django Ninja에는
ninja.errors.ValidationError가 내장되어 있으며, 이러한 오류들도 동일하게 422를 반환해요. ninja.errors.HttpError: 이것이 이 글의 핵심이며 아래에서 소개할 거예요.
이것들은 모두 Django Ninja가 자동으로 잡아내는 오류들이지만, 모든 오류에 대해 규격화된 422 응답을 주는 것은 아니에요——세 번째 오류가 그 예시죠.
새로운 요구 사항: 검증 실패 시, 400 응답 사용하기#
마찬가지로 「사용자 추가」 API를 예로 들어, 새로운 요구 사항을 구현할 거예요: 비밀번호 확인이 일치하지 않을 때, 422가 아닌 「400 Bad Request」를 응답해야 해요.
어떻게 하는 것이 가장 간단할까요?
답은: Django Ninja의 HttpError를 사용하는 것이에요.
다음은 Schema 코드의 변경 사항인데, 단 두 줄만 수정했어요!
...
from ninja.errors import HttpError # 첫 번째 줄
class CreateUserRequest(Schema):
...
@model_validator(mode='after')
def check_passwords_match(self) -> Self:
if self.password != self.confirm_password:
raise HttpError(400, '비밀번호와 확인 비밀번호는 동일해야 합니다.') # 두 번째 줄
return self
맞아요, 이렇게 간단해요!
단지 검증 메서드에서 던지는 오류를 ValueError에서 HttpError로 바꿔주기만 하면 돼요.
주의할 점은 HttpError 인스턴스를 초기화할 때 두 개의 매개변수가 필요하다는 거예요. 첫 번째는 HTTP 상태 코드이고, 두 번째가 오류 메시지예요.
응답 내용#
동일한 검증 실패에 대해 응답이 어떻게 다른지 살펴볼게요:
우리가 익숙한 형식으로 변했어요——오류 메시지만 포함되어 있죠.
이전 글에서 우리가 ValueError를 던졌을 때의 응답과 비교해 보세요:
// 422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"payload",
"confirm_password"
],
"msg": "Value error, 비밀번호와 확인 비밀번호는 동일해야 합니다.",
"ctx": {
"error": "비밀번호와 확인 비밀번호는 동일해야 합니다."
}
}
]
}
차이가 많이 나죠?
검증 메서드에서 HttpError를 사용하는 것의 부적절함#
Schema의 검증 메서드 내에서 HttpError를 직접 던지는 것은 편리한 방법이에요. 응답 처리를 간소화할 수 있으니까요.
오류를 추가로 잡아내거나 응답 형식을 수동으로 지정할 필요가 없어요. 검증에 실패하면 API가 우리가 정의한 상태 코드와 오류 메시지를 직접 응답해주니 간단하고 편리하죠.
그러나 이렇게 하는 것은 사실 적절하지 않아요. 테스트 용이성을 떨어뜨리고 응답의 유연성을 제한하는 등의 문제가 있죠. 하지만 그중에서도 가장 핵심적인 것은 우리가 이전 글에서 언급했던——「관심사 분리」예요.
「관심사 분리」 위반#
이 방식은 「관심사 분리」 원칙을 위반해요.
검증 로직의 책임은 데이터의 정확성을 검사하는 것이고, 응답은 뷰 함수가 담당해야 해요.
응답 로직을 검증 과정에 섞어 넣으면, 본래 독립적이어야 할 검증과 응답 두 부분이 서로 결합되어버려 책임이 혼란스러워지고 코드 유지 관리에 불리해져요.
따라서 검증 메서드 내에서 HttpError를 사용하면 요구 사항을 편리하게 구현할 수 있는 것처럼 보이지만, 아키텍처 설계의 관점에서 보면 응답 처리를 뷰 함수 내에 두는 것이 훨씬 더 합리적인 선택이에요.
걱정 마세요. 다음 글에서는 다른 방법을 사용해 볼 거니까요. 하지만 이 글의 주인공은 여전히 HttpError예요.
HttpError의 전형적인 시나리오: 뷰 함수에서 사용하기#
Schema에서 HttpError를 사용하는 것과 비교했을 때, 이를 뷰 함수 안에 넣고 실행하는 것이 바른 길이에요.
다음은 하나의 고전적인 시나리오예요.
데이터 검증 로직은 가능한 한 Schema 내에 두어야 하지만, 모든 검증이 Schema에 맡기기에 적합한 것은 아니에요.
예를 들어, 사용자의 email 필드는 중복될 수 없는 「고유성(유일성)」을 가지고 있어요. 그래서 우리는 먼저 사용자가 입력한 email이 DB에 있는 데이터와 중복되는지 확인하고, 그렇다면 409 Conflict를 바로 응답하고 싶어요.
이것은 의심할 여지 없이 일종의 검증이지만, 「데이터베이스 조회」를 수반해요.
이러한 데이터베이스 조회를 수반하는 검증은 Schema 내부보다는 뷰 함수에서 수행하는 것이 더 적합해요. 데이터베이스 조회는 비교적 무거운 동적 작업에 속하며, Schema의 정적 데이터 검사와는 본질적인 차이가 있기 때문이에요.
따라서 우리는 이러한 종류의 요구 사항을 처리하기 위해 뷰 함수 내에서 HttpError를 더 자주 사용해요.
추가된 코드는 다음과 같아요:
@router.post(...)
def create_user(..., payload: CreateUserRequest):
"""
사용자 추가
"""
if User.objects.filter(email=payload.email).exists():
raise HttpError(409, '사용자 email이 이미 존재합니다.')
...
위의 코드는 「사전 검증」이고, 아래의 코드 작성법은 결과 면에서 이와 유사해요:
try:
user.save()
except IntegrityError: # Django ORM 고유성 오류
raise HttpError(409, '사용자 email이 이미 존재합니다.')
단지 하나는 사전에 검증하여 오류를 발생시키는 것이고, 다른 하나는 사후에 오류를 잡아내는 (그리고 다시 발생시키는) 것이에요.
검증 실패 응답:
확실히 괜찮네요!
왜 그냥 409 응답을 직접 return 하면 안 될까요?#
똑똑한 여러분이라면 이렇게 생각할 수 있어요:
어, 그럼 차라리 오류 메시지가 포함된 Python 딕셔너리를 직접 return 하면 안 될까? 왜 굳이 뷰 함수 내에서
raise HttpError를 해야 하지?
이 생각에 대한 대략적인 코드는 다음과 같아요:
@router.post('/users/', response={201: dict, 409: dict}, ...)
def (...) -> tuple[int, dict]:
"""
사용자 추가
"""
if User.objects.filter(email=payload.email).exists():
return 409, {"detail": "사용자 email이 이미 존재합니다."}
...
이렇게 하는 게 더 직관적이지 않나요?
이것은 좋은 질문이에요.
핵심 포인트 분석#
먼저, 이 코드 안에 어떤 핵심 포인트들이 있는지 살펴볼게요:
response={201: dict, 409: dict}: 13번째 글에서 언급했던 「다중 상태 코드 응답」이 여기서 빛을 발하네요!raise대신return을 사용했어요.- 오류 메시지 형식을 검증하고 싶다면 Schema를 정의할 수 있어요. 이 예시는 간소화된 버전일 뿐이에요.
확실히 좋아 보이고 직관과도 잘 맞아요. 사실 제가 예전에 Django REST framework를 작성할 때는 모두 이런 식으로 썼었어요.
하지만, Django Ninja에서 이런 방식을 사용하다가 「페이지네이션(Pagination) 데코레이터」를 사용할 때가 되면 큰 벽에 부딪히게 될 거예요.
아직 때가 아니니, 이후의 〈25편: 페이지네이션 (하) 커스텀 페이지네이션 클래스〉에서 이 문제에 대해 더 명확하게 설명해 드릴게요.
어쨌든 현 단계에서는 이와 유사한 상황에서 여전히 raise HttpError를 하는 것이 더 적절하다는 것만 알아두시면 돼요.
소결#
이 글에서는 Django Ninja에 내장된 HttpError를 사용하여 기본 422를 피하고 오류 응답을 커스터마이징하는 방법을 배웠어요.
그리고 왜 HttpError가 Schema 안에서 사용하기에 적합하지 않은지(비록 우리가 일시적으로 그렇게 하긴 했지만😅)와 뷰 함수 안에 넣어야 하는 이유를 설명했어요.
다음 글에서는 Schema에서 발생하는 오류를 개선하고, 전역 오류 처리 메커니즘을 탐구할 거예요. 또한 Django Ninja가 제공하는 exception_handler 데코레이터를 사용하여 API의 오류 처리 능력을 더욱 향상시켜 볼게요.