앱이 죽었어요: _TypeError (Null is not a subtype of String)
1. "어제까진 잘 됐는데 오늘 갑자기 앱이 죽어요."
서버 개발자가 "API 수정했어요"라고 말한 뒤, 제 앱은 빨간 화면(Red Screen of Death)을 띄우며 장렬히 전사했습니다.
type 'Null' is not a subtype of type 'String' in type cast
이 에러 문구, Flutter 개발자라면 꿈에서도 봤을 겁니다. 범인은 99% 확률로 JSON 파싱 과정에서 일어난 Null 문제입니다.
"분명히 String 온다고 했잖아요!" 라고 외쳐봐야 소용 없습니다. 방어 코드를 짜지 않은 제 잘못이니까요.
2. 원리 이해 - Dart의 강력한(혹은 까다로운) 타입 시스템
Dart 2.12 이후 Null Safety가 도입되면서, 변수는 기본적으로 null을 허용하지 않습니다.
String name; // 절대 null일 수 없음
String? bio; // null일 수도 있음
그런데 JSON은 런타임에 들어오는 데이터입니다.
Map<String, dynamic>에서 값을 꺼낼 때, Dart 컴파일러는 그 값이 String인지 null인지 알 방법이 없습니다.
그래서 우리가 강제로 as String이라고 캐스팅을 하거나, 모델 클래스에 쑤셔 넣을 때 런타임 에러가 터지는 것입니다.
// json['name']이 null이면 여기서 바로 앱이 터집니다.
final name = json['name'] as String;
3. 해결책 1 - 모든 필드를 Nullable로 선언하라 (가장 안전)
서버 문서를 100% 믿지 마세요. "이 필드는 무조건 있어요(Required)"라고 적혀 있어도, 서버 버그로 null이 올 수 있습니다.
클라이언트는 어떤 쓰레기 데이터가 와도 죽지는 말아야(Crash-free) 합니다.
// ❌ 위험한 코드
class User {
final String name; // null 오면 앱 죽음
User({required this.name});
factory User.fromJson(Map<String, dynamic> json) {
return User(name: json['name']);
}
}
// ✅ 안전한 코드
class User {
final String name;
User({required this.name});
factory User.fromJson(Map<String, dynamic> json) {
// null이 오면 빈 문자열이나 'Unknown'으로 대체
return User(name: json['name'] ?? 'Unknown');
}
}
?? 연산자(Null-aware operator)는 JSON 파싱의 구세주입니다.
만약 값이 정말 중요하지 않다면 String? name으로 선언하는 것도 방법입니다.
4. 해결책 2 - try-catch로 특정 데이터만 버리기
리스트를 받아오는데, 100개 중 1개가 불량 데이터라고 해서 100개를 다 안 보여주는 건 손해입니다.
map을 돌릴 때 에러 처리를 해서, 불량품만 걸러내고(Filter) 나머지는 살리는 게 좋습니다.
List<User> parseUsers(List<dynamic> jsonList) {
return jsonList.map((json) {
try {
return User.fromJson(json);
} catch (e) {
print('불량 데이터 발견: $json, 에러: $e');
return null; // 실패하면 null 반환
}
}).whereType<User>() // null인 것들 제거(필터링)
.toList();
}
이제 서버가 실수로 name: null인 유저를 하나 보내도, 앱은 죽지 않고 그 유저만 빼고 리스트를 보여줍니다.
5. freezed 패키지 사용하기 깊이 들여다보기
손으로 fromJson을 짜는 건 오타와 실수의 온상입니다.
freezed와 json_serializable 패키지를 쓰면, 이런 보일러플레이트 코드를 자동으로 만들어줍니다.
특히 @Default('') 어노테이션을 쓰면 null 처리가 아주 깔끔해집니다.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
factory User({
// null이 들어오면 자동으로 'Unknown'으로 채워짐
@Default('Unknown') String name,
// int가 들어올 자리에 null이면 0으로
@Default(0) int age,
// 리스트도 null이면 빈 리스트로
@Default([]) List<String> hobbies,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
Default 값을 잘 활용하면 Null Check Operator Used on a Null Value 에러와 영원히 작별할 수 있습니다.
BigInt 문제 (ID가 짤린다?) 뜯어보기
서버에서 id를 64비트 정수(Long)로 보내는 경우가 있습니다. (예: 트위터 ID 18239012830123...)
자바스크립트나 Dart의 int(웹 컴파일 시)는 53비트 정밀도(Number)까지만 안전합니다.
그 이상의 숫자가 들어오면 마지막 자리가 000으로 바뀌거나 숫자가 오염됩니다.
이럴 때는 서버에 "ID를 문자열(String)로 보내주세요"라고 요청하는 게 제일 좋습니다.
만약 어쩔 수 없다면, JSON을 파싱할 때 BigInt로 처리하거나 커스텀 컨버터(JsonConverter)를 써야 합니다.
class StringIntConverter implements JsonConverter<String, dynamic> {
const StringIntConverter();
@override
String fromJson(dynamic json) {
if (json is int) return json.toString();
return json as String;
}
@override
dynamic toJson(String object) => object;
}
@freezed
class Product with _$Product {
factory Product({
@StringIntConverter() required String id,
}) = _Product;
// ...
}
7. Pro Tip: Zod (TypeScript 진영의 교훈)
만약 Flutter가 아니라 React/TypeScript를 쓴다면 Zod가 정답입니다.
TypeScript의 interface는 런타임에 사라지지만, Zod는 런타임에 데이터를 검증(Validation)합니다.
import { z } from "zod";
const UserSchema = z.object({
name: z.string().default("Unknown"), // null이면 Unknown
age: z.number().nullable(), // null 허용
});
// 데이터 파싱 시도
const result = UserSchema.safeParse(apiResponse);
if (!result.success) {
console.error("데이터 형식이 다릅니다:", result.error);
// 에러 처리 로직
} else {
// result.data는 완벽하게 타입이 보장됨
console.log(result.data.name);
}
서버 응답이 스키마(계약)와 다르면, 앱이 터지는 대신 우아하게 에러를 처리할 수 있게 해줍니다.
8. Case Study: 1,000만 원짜리 실수 (소수점의 반란)
실제로 제가 겪었던 일입니다.
커머스 앱을 개발 중이었는데, 서버에서 price 필드를 Int(정수)로 보내주기로 했습니다.
final int price = json['price'];라고 코드를 짰죠.
그런데 블랙 프라이데이 이벤트 때 서버 개발자가 "10% 할인을 적용하면서 가격이 소수점($49.99)으로 바뀌는 버그"를 냈습니다.
JSON으로 {"price": 49.99}가 내려왔고, 제 앱은 int 자리에 double이 왔다고 전체 상품 리스트를 렌더링하지 못하고 하얀 화면만 보여줬습니다.
이때 사용자는 상품을 볼 수 없으니 구매도 할 수 없었습니다. 2시간 동안 매출 0원을 찍었죠.
이 사건 이후로 저는 가격 정보는 무조건 num (int와 double 모두 수용)이나 String으로 받고,
화면에 보여줄 때 포맷팅을 하는 방어적인 습관을 가지게 되었습니다.
교훈: 돈과 관련된 필드는 더욱더 double, String 등으로 유연하게 받아야 합니다.
9. 디버깅 도구
JSON이 너무 복잡해서 모델 만들기 귀찮다면?
- QuickType.io: JSON을 넣으면 Dart/TypeScript 코드로 변환해줍니다. (강추)
- JSON to Dart (Plugin): IDE 플러그인으로 바로 변환합니다.
하지만 자동 생성 코드를 맹신하지 말고, Nullable 여부는 반드시 비즈니스 로직에 맞춰 수동으로 검토해야 합니다.
10. 요약
- 서버를 믿지 마라. 언제든
null이 올 수 있다. String대신String?을 쓰거나,??연산자로 기본값을 채워라.- Parsing 로직 전체를 try-catch 하지 말고, 아이템 단위로 걸러내라.
freezed의@Default를 적극 활용하라.- ID 값은 문자열로 받는 게 제일 안전하다.
앱이 죽는 건 사용자에게 최악의 경험입니다. 데이터가 좀 비어 보이는 게 낫습니다.