서론
DRF(Django REST Framework)를 사용하여 개발을 할 때, 시리얼라이저를 커스텀하는 것은 빈번한 일이다.
나 또한 마찬가지로 개발을 진행하던 중 DRF 시리얼라이저를 커스텀하였는데, 예상과 다른 동작이 일어났다.
입력값은 엑셀 파일에서 단일 행으로 제공되고, 이를 여러 모델로 정규화하여 나누는 과정에서 문제가 발생하였다.
보다 구체적으로 설명하면, A 시리얼라이저는 B, C, D, E라는 네 개의 모델 시리얼라이저를 포함하며, B 모델은 F 모델을 외래키로 참조하고 있는 상황이다. 이러한 구조 때문에 A 시리얼라이저의 입력값은 프리픽스를 활용해 각 모델로 분류하여 처리하도록 구현하였다.
class BSerializer(serializers.ModelSerializer):
f_ids = serializers.PrimaryKeyRelatedField(
queryset=F.objects.all(), many=True, write_only=True, source="fs"
)
class Meta:
model = B
fields = "__all__"
...
class ACreateSerializer(serializers.Serializer):
b = BSerializer()
c = CSerializer()
d = DSerializer()
e = ESerializer()
def to_internal_value(self, data):
b_data = {}
c_data = {}
d_data = {}
e_data = {}
for key, value in data.items():
if key.startswith("b_"):
b_data[key[len("b_") :]] = value
elif key.startswith("c_"):
c_data[key[len("c_") :]] = value
elif key.startswith("d_"):
d_data[key[len("d_") :]] = value
elif key.startswith("e_"):
e_data[key[len("e_") :]] = value
return {
"b": b_data,
"c": c_data,
"d": d_data,
"e": e_data,
}그러나 인스턴스 매핑(PrimaryKeyRelatedField)을 통해 DB 객체가 매핑될 것으로 기대했음에도,
정작 입력값 그대로가 반환되어 버리는 문제가 발생했다.
{
"b": { "f_ids": [1, 2], ... },
"c": { ... },
"d": { ... },
"e": { ... }
}이러한 문제를 해결하기 위해 DRF 시리얼라이저의 검증 로직을 깊이 있게 살펴보고자 한다.
본론
최근 지브리풍 이미지 생성으로 인해 GPU가 녹아내리고 계신 ChatGPT님께 자문을 구하였다.
본 글의 대표 이미지도 전지전능하신 ChatGPT님께서 생성하여 주시었다.
1. 필드 레벨 검증
- 타입 변환 및 기본 검증: 각 필드의 to_internal_value 메서드가 입력 데이터를 기본 Python 타입으로 변환하고, 필수 여부나 길이 제한 같은 기본 검증을 수행합니다.
- 커스텀 검증: validate_<필드명> 메서드를 정의해 해당 필드의 추가적인 검증을 할 수 있습니다.
2. 객체 레벨 검증
- 모든 필드 검증 후, validate 메서드를 사용하여 여러 필드 간의 관계나 전체 객체의 유효성을 검증합니다.
검증은 is_valid() 메서드를 호출하면 실행되며, 실패 시 오류는 serializer.errors에 저장됩니다.
출처 | OpenAI. (2025). ChatGPT (o1 Version) [Large language model]. https://chat.openai.com
DRF 시리얼라이저의 검증 과정은 is_valid() 메소드를 통해 시작되며, 다음과 같은 흐름으로 진행된다
- is_valid(): 외부 인터페이스로, 데이터의 검증 여부를 결정하는 진입점이다.
- run_validation(): 실제 데이터 타입 변환 및 개별 필드 검증, 객체 수준의 전체적인 검증을 수행한다.
- validate_{field}(): 개별 필드의 커스텀 검증 로직을 정의할 수 있다.
- validate(): 모든 필드 검증 후 객체 단위의 최종 유효성을 확인한다.
이러한 단계별 처리를 이해하면 커스텀 검증 로직 작성 시 발생할 수 있는 문제를 미연에 방지할 수 있다.
is_valid()
DRF에서 시리얼라이저를 사용할 때 가장 먼저 호출하는 메서드가 바로 is_valid()다.
이 메서드는 '외부 인터페이스' 역할을 수행하며, run_validation()을 호출하여 실질적인 검증 과정을 진행한다.
# django-rest-framework/rest_framework/serializers.py line 767
def is_valid(self, *, raise_exception=False):
assert hasattr(self, 'initial_data'), (
'Cannot call `.is_valid()` as no `data=` keyword argument was '
'passed when instantiating the serializer instance.'
)
if not hasattr(self, '_validated_data'):
try:
self._validated_data = self.run_validation(self.initial_data)
except ValidationError as exc:
self._validated_data = {}
self._errors = exc.detail
else:
self._errors = {}
if self._errors and raise_exception:
raise ValidationError(self.errors)
return not bool(self._errors)실제 수행 로직을 간단히 설명해보자면 아래와 같다.
- initial_data가 정의되었는지 확인 (시리얼라이저 생성 시 data 파라미터).
- run_validation()을 통해 실제 검증 로직을 실행.
- 검증 에러가 발생하면 ValidationError를 _errors에 담아둔 뒤, 필요시 예외를 발생시킴.
- 검증에 성공한 데이터는 _validated_data(즉, validated_data)에 저장함.
run_validation()
is_valid() 메서드가 인터페이스라면, run_validation()은 실제 검증의 핵심 로직이 담겨 있다.
- 데이터 타입 변환: 각 필드에 대해
to_internal_value()를 호출하여 입력 데이터를 내부 표현으로 변환. - 필드별 검증: 변환된 값에 대해 개별 필드의 유효성 검사를 진행하며, 필요한 경우
validate_{field}를 호출. - 객체 수준 검증: 모든 필드의 검증 후,
validate()메서드을 수행하여 필드 간의 연관성을 체크.
실제 코드를 살펴보면 아래와 같이 구성되어 있음을 확인할 수 있다.
# django-rest-framework/rest_framework/serializers.py line 434
def run_validation(self, data=empty):
(is_empty_value, data) = self.validate_empty_values(data)
if is_empty_value:
return data
value = self.to_internal_value(data)
try:
self.run_validators(value)
value = self.validate(value)
assert value is not None, '.validate() should return the validated data'
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=as_serializer_error(exc))
return value마찬가지로 아래와 같은 순서를 통해 구체적인 검증 로직을 수행한다
validate_empty_values()로 데이터 공백 여부 등을 먼저 체크.to_internal_value(data)를 호출하여 입력값 → 내부(파이썬) 표현으로 변환.run_validators()를 통한 필드 레벨 검증validate(value)를 통한 객체 레벨 검증- 최종적으로 모든 과정을 통과하면 value 반환.
to_internal_value()
각 필드는 field.to_internal_value()를 호출해 자신의 입력값을 검증·변환한다.
예를 들어 문제가 되었던 PrimaryKeyRelatedField는 다음과 같은 로직을 거친다.
- 입력값(예: [1, 2])을 받아 F.objects.all() 쿼리셋에서 pk=1, pk=2인 객체를 가져옴
- 성공적으로 가져온 객체 목록(예: [<F:1>, <F:2>])을 반환
- 최종적으로 validated_data에 인스턴스 형태로 저장
아래는 실제 PrimaryKeyRelatedField의 소스 일부이다.
# django-rest-framework/rest_framework/relations.py line 238
class PrimaryKeyRelatedField(RelatedField):
default_error_messages = {
'required': _('This field is required.'),
'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'),
'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'),
}
def __init__(self, **kwargs):
self.pk_field = kwargs.pop('pk_field', None)
super().__init__(**kwargs)
def use_pk_only_optimization(self):
return True
def to_internal_value(self, data):
if self.pk_field is not None:
data = self.pk_field.to_internal_value(data)
queryset = self.get_queryset()
try:
if isinstance(data, bool):
raise TypeError
return queryset.get(pk=data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(data).__name__)그래서 왜?
이 시점에서 왜 처음 발단이 되었던 PrimaryKeyRelatedField의 인스턴스 매핑이 이루어지지 않았는지 확인해보자.
상기 코드에서 핵심 문제는, ACreateSerializer의 to_internal_value()를 오버라이드 하면서 하위 시리얼라이저(BSerializer, CSerializer 등)의 검증 과정을 전혀 거치지 않는 구조로 구현되었다는 점이다.
원래 DRF에서 하위 시리얼라이저는 is_valid() → run_validation() → to_internal_value() 단계를 거쳐야 실제 DB 매핑(예: PrimaryKeyRelatedField)을 수행할 수 있다.
하지만 현재 코드에서는, 프리픽스를 이용해 b_data, c_data 등을 분리한 뒤 딕셔너리 형태로 반환하고 있다. 즉,
- BSerializer(data=b_data)
- .is_valid() 또는 .run_validation()
이 과정을 명시적으로 호출해줘야 f_ids = [1, 2]가 [<F:1>, <F:2>]로 매핑되는데,
그 절차가 생략되었기 때문에 원본 배열만 남게 되는 것이다.
이를 기반으로 기존 코드를 수정해보면 아래와 같다.
class BSerializer(serializers.ModelSerializer):
f_ids = serializers.PrimaryKeyRelatedField(
queryset=F.objects.all(), many=True, write_only=True, source="fs"
)
class Meta:
model = B
fields = "__all__"
...
class ACreateSerializer(serializers.Serializer):
b = BSerializer()
c = CSerializer()
d = DSerializer()
e = ESerializer()
def to_internal_value(self, data):
...
data = {
"b": b_data,
"c": c_data,
"d": d_data,
"e": e_data,
}
return super.to_internal_value(data)커스텀 검증 로직
validate_{field}()
DRF의 BaseSerializer.to_internal_value() 메서드 또는 Field.run_validation() 과정에서, 필드별 변환(to_internal_value)이 끝난 뒤, DRF가 validate_{field_name} 메서드를 자동으로 찾아 호출한다.
즉, 만약 시리얼라이저 내부에 다음과 같은 메서드가 정의되어 있으면, 해당 필드(title)이 변환이 완료된 직후에 이 함수가 실행되어 추가 검증을 수행한다.
def validate_title(self, value):
if "금칙어" in value:
raise serializers.ValidationError("제목에 금칙어가 포함되어 있습니다.")
return value여기서 주의해야 할 점은, validate_{field}() 메서드는 추가 검증을 위한 메서드라는 것이다. 즉, DRF가 기본 제공하는 검증(필드 타입 변환, max_length 등)을 모두 통과환 뒤에야 실행된다는 것이다.
예를 들어, max_length=100 제한을 어기는 경우는 DRF 기본 로직에서 이미 에러를 낸다. 즉, validate_title() 메서드에 진입하기도 전에 에러가 발생한다는 것이다. 또한 항상 validate_{field}(self, value): 형태를 맞춰야 하며, 반환값으로 value를 반드시 리턴해야 한다.
validate()
앞서 언급한 validate_{field}는 단일 필드에 대해서만 검증한다. 때문에 여러 필드 간의 관계를 검증하거나, 복합적인 로직이 필요하다면 validate()를 활용해야 한다.
def validate(self, attrs):
# 객체 레벨 검증 예시
if attrs["start_date"] > attrs["end_date"]:
raise serializers.ValidationError("시작일은 종료일보다 이전이어야 합니다.")
return attrs마찬가지로 validate() 메서드는 DRF 검증 > validate_{field}()를 통한 필드 레벨 검증 이후에 수행된다.
결론
정리하자면 A 시리얼라이저에서 하위 시리얼라이저(B, C, D, E)로 데이터를 전달하기만 하고, 검증 로직을 수행하지 않았기에 이러한 오류가 발생하였음을 알 수 있다.
로 이루어지는 DRF의 검증 흐름을 제대로 이해하고 활용하면, 이런 문제를 미연에 방지할 수 있다.
참고자료



