콘텐츠로 이동

스키마 기반 입력 검증과 타입 변환

11/11(수)

한 줄 요약: 서버는 들어온 값을 스키마(어느 칸에 어떤 타입이 와야 하는지 적은 규칙)로 검사한다. 겉 설명서에는 "아무 객체"로 적힌 칸도, 서버 안에서는 옆의 종류 필드를 보고 맞는 규칙을 골라 검사한다. 그 규칙을 알기 때문에 검사 직전에 "true"를 true로 되돌릴 수도 있다.

📍 만난 상황

  • 2026-11-11 16:45 — dhub2-manager의 #3561(Gemma가 만든 계획이 422로 거절되는 문제)을 보던 중이었다. 직전에 "검사 직전에 "true"를 true로 되돌리는 단계를 하나 끼워 넣는다"는 설명을 들었다. 그런데 payload는 "아무 객체"로 오는데, dhub가 어느 칸이 불리언인지 어떻게 아느냐고 물었다. 1번 물음 → 그날 일지
  • 결론: "아무 객체"는 모델에게 보내는 설명서에서만 그렇다. dhub 내부는 resource_type을 보고 안쪽을 검사할 규칙을 이미 정해 두었다.

🧩 쉽게 말하면

우체국 접수 창구를 떠올리면 된다. 창구 안내판에는 "소포를 내세요"라고만 적혀 있다(겉 설명서). 상자 안에 뭐가 들었는지는 안내판에 없다.

하지만 상자 겉에 "서류" / "식품" / "전자제품" 딱지가 붙어 있다(종류 필드). 직원은 딱지를 보고 그 종류의 검사표를 꺼낸다. 식품이면 유통기한, 전자제품이면 배터리를 본다.

검사표가 있으니, "유통기한: 이천이십칠년" 같은 글자도 "2027"로 고쳐 적어 줄 수 있다. 검사표 없이 안내판만 봐서는 할 수 없는 일이다.

📖 제대로 알기

스키마와 검증

  • 스키마(schema: 데이터의 모양과 칸별 타입을 적은 규칙): "nullable은 불리언, precision은 숫자" 같은 내용이다.
  • 검증(validation): 들어온 값이 스키마에 맞는지 확인하는 일. 안 맞으면 거절한다. HTTP에서는 보통 422(Unprocessable Entity: 형식은 맞지만 내용이 규칙에 안 맞음)로 돌려준다.
  • 타입 변환(coercion: 값을 맞는 타입으로 바꿔 주는 일): "2" → 2처럼 뜻이 분명할 때만 한다.

겉 설명서와 내부 규칙은 다를 수 있다

같은 서버가 스키마를 두 곳에서 쓴다. - 겉 설명서: 바깥(여기서는 LLM과 vLLM)에 "이런 모양으로 보내라"고 알려 주는 JSON Schema. - 내부 규칙: 서버가 실제로 검사할 때 쓰는 모델(pydantic 클래스 등).

겉 설명서는 일부러 느슨하게 둘 때가 많다. 내용이 종류마다 다른 칸은 하나로 적을 수 없어서 dict[str, Any](아무 키·아무 값의 객체)로 적는다.

판별자(discriminator)

판별자(discriminator: 여러 모양 중 어느 것인지 알려 주는 필드)는 그 느슨한 칸 옆에 붙은 "종류 딱지"다. 오늘은 resource_type이었다.

resource_type 안쪽 검사 규칙 규칙에 적힌 타입 예
dataset ArrowField 모델 nullable: bool
app 위젯 위젯 설정 카탈로그 stat의 precision: number
dashboard 레이아웃 키 목록 x, y, w, h: 정수

이런 방식을 판별 유니언(discriminated union: 종류 필드로 여러 모델 중 하나를 고르는 구조)이라고 부른다.

⚙️ 어떻게 동작하나

flowchart TD
  A["도착한 계획 항목"] --> B["겉 모델 검사<br/>payload는 아무 객체"]
  B --> C{"resource_type?"}
  C -->|"dataset"| D["데이터셋 규칙 꺼냄"]
  C -->|"app"| E["위젯 규칙 꺼냄"]
  D --> F["새 단계: 문자열 되돌리기"]
  E --> F
  F --> G["안쪽 모델 검사"]
  G -->|"맞음"| H["통과"]
  G -->|"틀림"| I["422 거절"]

겉 검사는 payload를 그냥 통과시킨다. 진짜 타입 검사는 resource_type으로 규칙을 고른 뒤에 일어나고, 새 단계는 그 사이에 끼어든다.

  1. 겉 모델 검사: ResourcePlanItem이 resource_type이 있는지, payload가 객체인지만 본다.
  2. 규칙 고르기: resource_type을 보고 안쪽 모델을 고른다. 오늘 코드에서는 _CREATE_MODELS[...] 표에서 찾았다.
  3. 되돌리기(새 단계): 고른 규칙에서 "불리언 자리", "숫자 자리"를 찾는다. 그 자리에 "true", "2"처럼 뜻이 분명한 문자열이 있으면 진짜 타입으로 바꿔 써 넣는다.
  4. 안쪽 모델 검사: model_validate(body)로 검사한다. 이제 nullable: true라서 통과한다.
  5. 애매한 값은 그대로: "yes"처럼 뜻이 불분명하면 바꾸지 않는다. 4단계에서 거절되게 둔다.

왜 4단계 앞에 넣을까? 검사가 먼저 돌면 이미 거절된 뒤라 되돌릴 기회가 없다. 그래서 겉 검사가 끝난 직후(mode="after"), 안쪽 검사 전에 둔다.

🧪 예시

  • 예시 1 · 일상에 빗대어: 소포 3개가 온다.
  • "식품" 딱지, 유통기한 칸에 "2027-01-01" → 그대로 통과
  • "식품" 딱지, 유통기한 칸에 "이천이십칠년 일월 일일" → 검사표에 "날짜"라고 적혀 있으니 날짜로 고쳐 적고 통과
  • "식품" 딱지, 유통기한 칸에 "곧" → 고칠 수 없으니 그대로 두고 반송

  • 예시 2 · 실제로는 (되돌리기 단계의 핵심만 줄인 모양)

BOOL_TEXT = {"true": True, "false": False}

def coerce_dataset(payload: dict) -> None:
    for field in payload.get("schema", {}).get("fields", []):
        v = field.get("nullable")
        if isinstance(v, str) and v.lower() in BOOL_TEXT:
            field["nullable"] = BOOL_TEXT[v.lower()]

COERCERS = {"dataset": coerce_dataset}

def coerce(item) -> None:
    fn = COERCERS.get(item.resource_type)
    if fn:
        fn(item.payload)

① COERCERS가 판별자 표다. resource_type으로 되돌리기 함수를 고른다. ② coerce_dataset은 데이터셋 규칙을 아는 함수다. schema.fields[].nullable이 불리언 자리라는 걸 안다. ③ 문자열이면서 "true"/"false"일 때만 바꾼다. 이미 true(Claude가 보낸 값)나 "yes"는 손대지 않는다.

들어온 값과 결과는 이렇다.

도착한 값 되돌린 뒤 검사 결과
"nullable": "true" true 통과
"nullable": true 그대로 통과
"nullable": "yes" 그대로 422 거절

⚖️ 비교 · 흔한 오해

헷갈린 점: "payload가 아무 객체로 오면 서버도 안쪽 타입을 모른다" 이렇게 생각하기 쉬운 이유가 있다. 오늘 문제의 원인이 "vLLM이 설명서에서 payload 안쪽 타입을 못 봐서 문자열로 남겼다"였다. 그래서 설명서 = 서버가 아는 전부라고 느끼기 쉽다.

하지만 설명서는 바깥에 보여 주는 요약본이다. 서버는 그보다 자세한 내부 규칙을 갖고 있다. 결정적인 증거는 422 거절 자체다. "불리언이어야 하는데 문자열"이라고 말할 수 있다는 건, 그 칸이 불리언이라는 걸 이미 안다는 뜻이다. 새 단계는 새 정보를 알아내는 게 아니라, 검사에 쓰는 정보를 검사 직전에 한 번 더 쓰는 것이다.

구분 겉 설명서 내부 규칙
누가 보나 LLM, vLLM, 다른 클라이언트 서버 자신
payload 표현 아무 객체 종류별 모델
바꾸면 모든 모델 요청이 길어짐 서버만 바뀜

다른 흔한 오해 - "검증 라이브러리는 원래 문자열을 알아서 바꿔 준다" → 설정마다 다르다. pydantic은 기본(lax) 모드에서 "true"를 True로 바꿔 주지만 strict 모드에서는 거절한다. 오늘 dhub 검증은 거절하는 쪽이었다. - "변환은 넓게 할수록 친절하다" → "yes", "1", "on"까지 받으면 잘못된 값이 조용히 통과한다. 뜻이 분명한 값만 바꾼다.

🛠️ 실무에서는

  • 언제 쓰나: 보내는 쪽(LLM, 오래된 클라이언트)을 고칠 수 없고, 틀리는 모양이 일정할 때.
  • 피할 때: 보내는 쪽을 쉽게 고칠 수 있을 때. 받는 쪽 변환은 문제를 가리기도 한다.
  • 체크리스트
  • 변환은 검사 직전 한 곳에만 둔다. 여러 곳에 흩어지면 규칙이 엇갈린다.
  • 변환 대상 위치는 검사 규칙에서 가져온다. 손으로 목록을 따로 만들면 규칙이 바뀔 때 어긋난다.
  • 애매한 값은 바꾸지 않고 거절되게 둔다.
  • 변환이 일어났다는 걸 로그에 남기면, 어느 모델이 자주 틀리는지 볼 수 있다.
  • 문제가 생기면 먼저 볼 것: 422 응답의 오류 위치(loc)와 들어온 원본 값. 타입만 틀린 건지, 칸 이름부터 틀린 건지 구분한다.

❓ 자주 묻는 질문

  • Q. 그냥 겉 설명서에 안쪽 타입을 다 적으면 안 되나? A. 가능하다(오늘의 A안). 다만 vLLM이 그 정보를 써 줄 때만 통하고, 모든 모델에 보내는 설명서가 길어진다. 그래서 받는 쪽 변환(B안)을 주로 권했다.
  • Q. Claude가 보낸 올바른 값도 건드리나? A. 아니다. 이미 불리언이면 그대로 둔다. 문자열일 때만 본다.
  • Q. 위젯처럼 종류가 수십 개면? A. 종류별 규칙(카탈로그)에서 타입을 읽어 한 함수로 처리한다. 종류마다 함수를 새로 쓰지 않는다.

🔭 더 깊이 보기

📚 참고 자료

  • Unions — Discriminated Unions (Pydantic 공식 문서, 영문) — 종류 필드로 모델을 고르는 법 · 링크
  • Conversion Table (Pydantic 공식 문서, 영문) — lax·strict 모드에서 어떤 값이 어떻게 바뀌는지 · 링크
  • Understanding JSON Schema (JSON Schema 공식 안내서, 영문) — 겉 설명서를 쓰는 규칙 · 링크

🗓️ 이력

  • 2026-11-11: payload가 "아무 객체"인데 dhub가 어느 칸이 불리언인지 어떻게 아는지 물음 → 일지

🕸️ 연결된 주제