DRF Serializer 검증 흐름과 to_internal_value 동작 원리 분석

서론

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님께서 생성하여 주시었다.
DRF 시리얼라이저는 데이터를 안전하게 처리하기 위해 두 단계의 검증 과정을 거칩니다.

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. 입력값(예: [1, 2])을 받아 F.objects.all() 쿼리셋에서 pk=1, pk=2인 객체를 가져옴
  2. 성공적으로 가져온 객체 목록(예: [<F:1>, <F:2>])을 반환
  3. 최종적으로 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 등을 분리한 뒤 딕셔너리 형태로 반환하고 있다.  즉,

  1. BSerializer(data=b_data)
  2. .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)로 데이터를 전달하기만 하고, 검증 로직을 수행하지 않았기에 이러한 오류가 발생하였음을 알 수 있다.

is_valid() → run_validation() → to_internal_value() → validate_{field}() → validate())


로 이루어지는 DRF의 검증 흐름을 제대로 이해하고 활용하면, 이런 문제를 미연에 방지할 수 있다.


참고자료

django-rest-framework/rest_framework/serializers.py at main · encode/django-rest-framework
Web APIs for Django. 🎸. Contribute to encode/django-rest-framework development by creating an account on GitHub.
Serializers - Django REST framework
Django, API, REST, Serializers
Django, DRF Serializers - serializer 파헤치기, 왜 serializer? response가 만들어지기 까지
drf에서 DTO 그 이상의 역할을 하는 serializer, Serializers 를 왜써야 할까? 사용 목적과 이유를 확인하고 drf core와 serializer의 핵심 core를 한 번 파헤쳐 보자.
(DRF) Serializer Validation
DRF에서 유효성 검사를 처리하는 대부분의 경우 단순히 기본 필드 유효성 검사에 의존하거나, 클래스에 대한 명시적 유효성 검사 방법을 이용합니다.