
앱이 죽었어요: _TypeError (Null is not a subtype of String)
서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.
로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

안드로이드는 오는데 iOS는 조용합니다. 혹은 앱이 켜져 있을 때만 옵니다. Background/Terminated 상태 처리, APNs 인증서, 그리고 Notification Channel 설정까지 완벽하게 해결합니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

서버 개발자가 "API 수정했어요"라고 말한 뒤, 제 앱은 빨간 화면(Red Screen of Death)을 띄우며 장렬히 전사했습니다.
type 'Null' is not a subtype of type 'String' in type cast
이 에러 문구, Flutter 개발자라면 꿈에서도 봤을 겁니다. 범인은 99% 확률로 JSON 파싱 과정에서 일어난 Null 문제입니다.
"분명히 String 온다고 했잖아요!" 라고 외쳐봐야 소용 없습니다. 방어 코드를 짜지 않은 제 잘못이니까요.
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;
서버 문서를 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으로 선언하는 것도 방법입니다.
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인 유저를 하나 보내도, 앱은 죽지 않고 그 유저만 빼고 리스트를 보여줍니다.
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 에러와 영원히 작별할 수 있습니다.
서버에서 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;
// ...
}
만약 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);
}
서버 응답이 스키마(계약)와 다르면, 앱이 터지는 대신 우아하게 에러를 처리할 수 있게 해줍니다.
실제로 제가 겪었던 일입니다.
커머스 앱을 개발 중이었는데, 서버에서 price 필드를 Int(정수)로 보내주기로 했습니다.
final int price = json['price'];라고 코드를 짰죠.
그런데 블랙 프라이데이 이벤트 때 서버 개발자가 "10% 할인을 적용하면서 가격이 소수점($49.99)으로 바뀌는 버그"를 냈습니다.
JSON으로 {"price": 49.99}가 내려왔고, 제 앱은 int 자리에 double이 왔다고 전체 상품 리스트를 렌더링하지 못하고 하얀 화면만 보여줬습니다.
이때 사용자는 상품을 볼 수 없으니 구매도 할 수 없었습니다. 2시간 동안 매출 0원을 찍었죠.
이 사건 이후로 저는 가격 정보는 무조건 num (int와 double 모두 수용)이나 String으로 받고,
화면에 보여줄 때 포맷팅을 하는 방어적인 습관을 가지게 되었습니다.
교훈: 돈과 관련된 필드는 더욱더 double, String 등으로 유연하게 받아야 합니다.
JSON이 너무 복잡해서 모델 만들기 귀찮다면?
하지만 자동 생성 코드를 맹신하지 말고, Nullable 여부는 반드시 비즈니스 로직에 맞춰 수동으로 검토해야 합니다.
null이 올 수 있다.String 대신 String?을 쓰거나, ?? 연산자로 기본값을 채워라.
freezed의 @Default를 적극 활용하라.
앱이 죽는 건 사용자에게 최악의 경험입니다. 데이터가 좀 비어 보이는 게 낫습니다.