Dart JSON 파싱 오류: 숫자 변환·필수 키·null 검증 순서

API의 수량이 어떤 응답에서는 3, 다른 응답에서는 3.0이고 메모는 null이라면, 일단 캐스팅부터 추가하기보다 필드가 허용하는 값의 계약을 정하세요. JSON 문법을 읽는 일, 모델 타입으로 바꾸는 일, 업무 범위를 검사하는 일은 각각 실패할 수 있습니다.

이 글은 서버 응답 문자열 하나를 Dart 모델 하나로 바꾸는 경계에 집중합니다. 네트워크 연결이나 화면 상태관리 대신 숫자 정규화, 필수 키 누락, nullable 필드와 실패 위치를 구분합니다. var·dynamic·Object?의 기본 차이는 기존 타입 가이드에서 이어 볼 수 있습니다.

먼저 JSON 문법 오류와 모델 계약 오류를 나누세요

jsonDecode의 반환 타입은 dynamic이며, 호출만으로 StockItem 같은 앱 모델이 만들어지지는 않습니다. JsonDecoder 설명에 따르면 숫자·문자열·불리언·null·리스트·문자열 키 맵으로 해석되고, JSON 문법이 잘못되면 FormatException이 발생합니다. 따라서 유효한 JSON 배열이나 null도 객체 하나를 요구하는 모델에는 맞지 않을 수 있습니다.

실패 위치 입력 예 확인할 계약
JSON 구문 {"quantity":} 응답이 잘렸는지, JSON 본문이 맞는지
루트 모양 [] 또는 null 객체 하나인지, 목록·래퍼 객체인지
필드 존재 note 키가 없음 필수 nullable인지 선택 필드인지
필드 타입 "quantity":"3" 숫자와 숫자 문자열을 구분하는지
필드 범위 "quantity":3.2 정수 의미·최소·최대 조건

TypeError나 화면의 빈칸만 수집하면 어느 단계가 잘못됐는지 알기 어렵습니다. 전송 단계의 상태 코드와 본문 형식을 먼저 확인한 뒤, 모델 경계에는 필드 경로와 거절 사유를 남기는 편이 재현하기 쉽습니다.

예제의 계약: 숫자로 바꾸기 전에 허용 범위를 정합니다

아래 조건은 설명을 위해 정한 재고 항목 계약이며 모든 API의 표준은 아닙니다. 특히 수량은 원문에 소수점이 있었는지가 아니라 유한한 정수 값인지로 판단해 3과 3.0을 모두 허용합니다. 소수점 표기 자체를 금지하는 API라면 이 파서의 계약과 다릅니다.

필드 허용 거절 또는 보존 이유
id 필수, 공백만이 아닌 문자열 숫자로 변환하지 않아 0007을 보존
quantity 필수, 0~10000의 유한한 정수 값 3.0 허용, 3.2·문자열·null 거절
weightKg 필수, 0 초과 1000 이하의 유한한 숫자 검사 후 double로 정규화, 금액 계산용 아님
note 키는 필수, 값은 문자열 또는 null 키 누락과 명시적 null의 의미를 구분

알 수 없는 추가 키는 이 예제에서 무시합니다. 서버가 필드를 추가해도 기존 필드만 읽는 정책이며, 오타를 포함한 모든 추가 키를 거절해야 한다면 허용 키 집합 검사까지 별도로 넣으세요. 입력 크기 제한·중복 JSON 키 탐지·모든 오류를 한 번에 수집하는 기능은 포함하지 않았습니다.

필수 키·숫자·null을 확인한 뒤 모델을 만드세요

아래는 외부 패키지 없이 구성한 설명용 Dart 코드입니다. 이 글을 작성하며 Dart analyzer·VM·Flutter·실기기에서 실행하지 않았습니다. 프로젝트 SDK에서 분석하고 아래 입력표로 동작을 확인한 뒤 적용하세요.

import 'dart:convert';

class PayloadException implements Exception {
  const PayloadException(this.path, this.code);
  final String path;
  final String code;

  @override
  String toString() => 'PayloadException($path, $code)';
}

class StockItem {
  const StockItem(this.id, this.quantity, this.weightKg, this.note);
  final String id;
  final int quantity;
  final double weightKg;
  final String? note;
}

Object? requiredField(Map<String, Object?> map, String key) {
  if (!map.containsKey(key)) {
    throw PayloadException(key, 'missing');
  }
  return map[key];
}

num finiteNumber(Object? value, String path) {
  if (value is! num) throw PayloadException(path, 'number-required');
  if (!value.isFinite) throw PayloadException(path, 'non-finite');
  return value;
}

StockItem parseStockItem(String source) {
  final Object? decoded;
  try {
    decoded = jsonDecode(source);
  } on FormatException {
    throw const PayloadException(r'$', 'json-syntax');
  }
  if (decoded is! Map<String, Object?>) {
    throw const PayloadException(r'$', 'object-required');
  }

  final id = requiredField(decoded, 'id');
  if (id is! String || id.trim().isEmpty) {
    throw const PayloadException('id', 'nonempty-string-required');
  }

  final quantity = finiteNumber(requiredField(decoded, 'quantity'), 'quantity');
  if (quantity < 0 || quantity > 10000 || quantity % 1 != 0) {
    throw const PayloadException('quantity', 'whole-number-0-to-10000');
  }

  final weight = finiteNumber(requiredField(decoded, 'weightKg'), 'weightKg');
  if (weight <= 0 || weight > 1000) {
    throw const PayloadException('weightKg', 'range-0-exclusive-to-1000');
  }

  final note = requiredField(decoded, 'note');
  if (note != null && note is! String) {
    throw const PayloadException('note', 'string-or-null-required');
  }

  return StockItem(
    id,
    quantity.toInt(),
    weight.toDouble(),
    note is String ? note : null,
  );
}

void main() {
  final item = parseStockItem(
    '{"id":"0007","quantity":3.0,"weightKg":2,"note":null}',
  );
  print('${item.id} / ${item.quantity} / ${item.weightKg} / ${item.note}');
}

Map의 [] 조회는 누락된 키에도 null을 반환하므로, requiredField는 containsKey를 먼저 확인합니다. note가 없는 응답을 조용히 null로 바꾸지 않는 이유입니다. 반면 명시적 null은 계약에 맞으므로 모델에 보존합니다.

예제는 첫 실패에서 중단합니다. quantity가 잘못됐으면 그 뒤 weightKg와 note까지 검사한 결과로 해석하면 안 됩니다. 목록으로 확장할 때는 항목별 경로를 items[2].quantity처럼 전달하고, 하나라도 실패하면 전체를 거절할지 유효 항목만 남길지 제품 계약을 먼저 정하세요. 무조건 기본값 0을 넣어 계속 진행하면 오류 데이터와 실제 수량 0이 같아집니다.

as double과 toInt는 검증의 대체물이 아닙니다

as double은 타입을 단언하는 캐스트이며 임의의 int·문자열을 원하는 숫자로 변환하는 도구가 아닙니다. 숫자를 허용하는 계약이면 먼저 num인지와 유한성·업무 범위를 확인한 다음 변환하세요. toDouble은 큰 정수를 근사할 수 있으므로 큰 ID나 정확한 금액을 이 경로에 넣지 않습니다.

toInt는 소수 부분을 버립니다. 수량 3.2를 먼저 3으로 만든 뒤 범위를 검사하면 원래 잘못된 입력이 통과합니다. 예제는 유한성·범위·소수 부분 검사를 통과한 0~10000 값에만 toInt를 적용합니다. 숫자 문자열을 허용하려면 별도 명세와 변환 경로를 두고, value.toString()으로 모든 입력을 숫자처럼 만드는 방식은 피하세요.

Dart 숫자 표현 문서는 JavaScript 대상과 네이티브에서 타입 검사·정밀도가 다를 수 있음을 설명합니다. runtimeType이 int라고 찍히는지만 통과 기준으로 삼지 마세요. 위 예제의 작은 수량 범위와 값 기반 검사는 그 차이에 덜 의존하도록 정한 것이며, Web 빌드 통과를 실제 확인했다는 뜻은 아닙니다.

같은 기준 입력에서 한 필드씩 바꿔 확인하세요

기준 입력은 {"id":"0007","quantity":3,"weightKg":2.5,"note":null}입니다. 아래는 실행 결과가 아니라 파서의 의도와 대조할 검증 계획입니다. 첫 실패에서 중단하므로 한 번에 여러 필드를 바꾸지 마세요.

기준에서 바꿀 부분 의도한 판정 중점 확인
변경 없음 수락 id의 선행 0과 note의 null 유지
quantity를 3.0으로 수락 정수 의미를 검사한 뒤 int로 변환
quantity를 0 또는 10000으로 수락 수량 경계 포함
quantity를 3.2·-1·10001로 거절 소수 버림이나 범위 보정으로 통과시키지 않음
quantity를 “3” 또는 null로 거절 number-required 사유
weightKg를 2로 수락 모델에서 double로 사용
weightKg를 0 또는 1000.1로 거절 질량 범위
note를 삭제 거절 missing 사유, null과 구분
note를 빈 문자열로 수락 빈 문자열은 null로 자동 변환하지 않음
note를 false로 거절 string-or-null-required 사유
루트를 []·null로, 또는 구문을 깨뜨림 거절 object-required와 json-syntax 구분

내부 변환 함수를 별도로 시험한다면 NaN·양/음의 Infinity도 거절하는지 확인하세요. 이 값들은 정상 JSON 숫자 표기 예제로 사용하지 않습니다. Flutter Web도 지원하면 같은 입력을 네이티브와 Web에서 각각 실행해 모델 값과 거절 경로를 비교하고 runtimeType 문자열 차이만으로 실패 처리하지 마세요.

실패 기록은 필드 경로와 사유를 중심으로 남기세요

예를 들어 요청 식별번호·스키마 버전·플랫폼·필드 경로·missing 같은 사유 코드를 남기면 응답 전체를 저장하지 않고도 계약 변화를 추적할 수 있습니다. 값의 상세 확인이 꼭 필요하면 승인된 진단 환경에서 비밀값과 개인정보를 제거한 최소 표본만 사용하세요. 디코딩 예외의 원문·토큰·전체 URL을 그대로 공개하지 않습니다.

상세 응답 모델을 검사하는 이 예제와 PATCH 갱신 요청은 구분해야 합니다. PATCH에서 누락은 유지, null은 삭제, 문자열은 변경을 뜻할 수 있습니다. 그 계약에 이 requiredField 함수를 그대로 복사하면 정상 요청을 거절할 수 있습니다. 자동 생성 모델도 생성 코드를 확인하고 동일한 누락·null·범위 입력을 시험해야 합니다.

공식 자료와 확인 범위

2026년 10월 23일 아래 공식 원문을 직접 읽고 디코딩·변환·누락 처리 조건을 확인했습니다. API의 계약 설명과 자체 예제·판단표를 구분했으며, 코드 실행 성공이나 실제 서버 응답을 관찰한 결과는 포함하지 않았습니다.

관련 문제를 이어서 확인하세요