2025. 6. 5. 16:08ㆍProgramming/Flutter
Dart는 강타입 언어다. 즉, 컴파일 시점에 타입이 결정되므로 런타임에 타입과 관련된 에러가 발생하는 일은 드물다. 단, Dart에는 Typescript에서 제공하는 Any
와 비슷한 역할을 하는 dynamic
이 존재한다. 최근에는 HTTP 요청을 통해 전송되는 패킷 본문에 보통 JSON 포멧을 많이 사용하는데, 이 JSON 객체 안에 어떤 타입이 들어있는지 정확하게 추론이 불가능하다. 이럴 때 주로 사용되는 것이 바로 dynamic
이며, 특히 백엔드는 약타입 언어를 사용하는 경우 API 명세가 정확하게 지켜지지 않으면 이따금 JSON 내부 타입을 파싱하는 과정에서 예외가 빵빵 터지게되는 것이다.
JSON 응답의 타입은 어떻게 신뢰할 수 있을까?
보통 Dart에서 응답을 받은 데이터를 사용하기 위해서는, 데이터 클래스를 정의하고 이 데이터를 기반으로 로직을 작성하기 마련이다. 물론 응답 본문을 그대로 dynamic
혹은 Map<String, dynamic>
타입으로 사용하는 것도 가능이야 하겠지만, 보통 사람이라면 데이터 클래스가 가져다주는 자동 완성을 비롯한 다양한 이점들을 무시할 수 없을 것이다. 아무튼 다음과 같이 Map<String, dynamic>?
타입의 JSON 객체를 기반으로 User
인스턴스를 생성하는 팩토리 메서드를 작성한다고 가정하자.
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'], // 이게 int일까? String일까?
name: json['name'], // 이것도 String? 아니면 null?
verified: json['verified'], // bool? 아니면 "true"/"false" 문자열?
);
}
User
클래스를 작성할 때 id
, name
, verified
라는 변수의 타입을 지정이야 했겠지만, 실제로 Map<String, dynamic>
타입으로 전달되는 json 값 내부에 id라는 키값으로 저장되어있는 값이 int
인지, String
인지 보장할 수는 없다. 심지어는 Map<String, dynamic>
조차 실제 Map<String, dynamic>
타입이 아니라, dynamic
타입일수도 있다는 점이 가장 공포스럽다.
물론 API 명세가 잘 되어있고 모든 사람이 명세를 잘 따른다면 아무래도 상관없겠지만 사람인 이상 실수할수도 있고, 사람이 늘어난다면 더더욱 실수의 가능성이 높아지며, 특히 백엔드와 클라이언트가 여럿일 경우에는 더더더욱 문제가 발생할 수 있다. 대체로 백엔드는 하나인데 웹과 앱을 따로 개발한다던지, 이런 경우에 주로 문제가 발생한다. 혹은 백엔드에서 Python같은 약타입 언어를 사용하는 중 개발자의 실수로 숫자였던 값에 따옴표가 붙어서 문자열이 되어버린다던지... 아무튼 명세가 제대로 되어있지 않다보니 여러가지 경우가 있었다. 예를 들면 다음과 같다.
id
필드가 어떨 때는123
(숫자), 어떨 때는"123"
(문자열)로 들어옴verified
필드가true
/false
대신1
/0
으로 들어오거나, 아예"true"
/"false"
문자열로 들어옴- 숫자 필드가 소수점이 없을 때는 정수부만 오고, 소수점이 있을 때는 소숫점을 포함해서 오는 바람에
double
값을int
타입 변수에 할당하다가 터짐
Typescript같은 경우 숫자는 대개 Number 타입을 사용하고 Union도 사용할 수 있으므로 크게 문제가 되는 경우는 없지만, Dart는 그런걸 바라기는 힘들다. 버전이 올라가면서 Kotlin, Swift, Typescript 등 다양한 언어에서 도입된 기능들이 하나 둘 추가되고 있긴 하지만, 지금가지 얘기가 없던 걸 보면 Union은 도입되지 않을 모양인가보다.
_TypeError (type 'String' is not a subtype of type 'int')
주로 이런 타입 에러가 발생하게 되는데 파싱 과정에서 에러가 발생하는 경우, 처음에는 어디서 에러가 발생했는지 잘 가늠도 되지 않는다. non-nullable한 값인 줄 알고 있던 값이 null로 오는 경우는 짜증 한 번 나는걸로 끝이지만, '1'은 숫자이고 "1"은 문자열이라는 사실을 몇 십분 혹은 시간 단위로 허비한 뒤에 깨닫게되면 당혹스럽기 그지없다.
사람은 누구나 실수를 한다.
물론 동료를 무조건적으로 불신하자는 얘기는 아니지만, 사람은 누구나 실수를 할 수 있다. 물론 나 자신 역시 예외는 아니다. 어쨌건 Dart가 dynamic
타입을 추론하는 과정에서 문제가 발생한다면, 타입 추론을 보다 엄격하게 하는 함수를 만들어서 수정할 수 있지 않을까? 라는 생각에 다음과 같이 타입별 파싱 함수를 작성하기 시작했다.
int jsonTypeSafeParseToInt({
required Map<String, dynamic>? json,
required String key,
required int defaultValue,
}) {
final dynamic dynamicValue = json?[key];
if (dynamicValue is int) {
return dynamicValue;
} else if (dynamicValue is String) {
return int.tryParse(dynamicValue) ?? defaultValue;
}
return defaultValue;
}
String jsonTypeSafeParseToString({
required Map<String, dynamic>? json,
required String key,
required String defaultValue,
}) {
// 비슷한 로직...
}
처음에는 int
와 String
에 대해서만 생각하고 작성했는데, 생각해보니 만들어야 하는 함수가 너무 많다. int
, String
, double
, bool
, 그리고 각각의 nullable 버전까지 생각하면 정말 끝이 없다. 코드 중복도 심하고, 유지보수도 어렵다. 중복되는 코드가 많으니 제네릭을 사용하면 하나의 함수로 추상화 할 수 있지 않을까 싶었다.
T jsonTypeSafeParse<T>({
required Map<String, dynamic>? json,
required String key,
required T defaultValue,
}) {
final dynamic dynamicValue = json?[key];
if (dynamicValue == null) {
return defaultValue;
}
// 이미 원하는 타입이면 그대로 반환
if (dynamicValue is T) {
return dynamicValue;
}
// 타입 변환 로직
if (T == int || T.toString() == 'int?') {
if (dynamicValue is String) {
final parsedValue = int.tryParse(dynamicValue);
return (parsedValue ?? defaultValue) as T;
} else if (dynamicValue is double) {
return (dynamicValue.toInt()) as T;
}
}
else if (T == String || T.toString() == 'String?') {
return dynamicValue.toString() as T;
}
else if (T == double || T.toString() == 'double?') {
if (dynamicValue is String) {
final parsedValue = double.tryParse(dynamicValue);
return (parsedValue ?? defaultValue) as T;
} else if (dynamicValue is int) {
return dynamicValue.toDouble() as T;
}
}
else if (T == bool || T.toString() == 'bool?') {
if (dynamicValue is String) {
final s = dynamicValue.toLowerCase();
if (s == 'true' || s == '1') return true as T;
if (s == 'false' || s == '0') return false as T;
} else if (dynamicValue is int) {
if (dynamicValue == 1) return true as T;
if (dynamicValue == 0) return false as T;
}
}
return defaultValue;
}
구현 자체는 간단하다. nullable 타입은 T == String
처럼 직접 비교하기 어려우므로, T.toString()
을 통해 타입을 문자열로 출력한 뒤 비교한다. 이를 통해서 주어진 T
값과 인자로 전달된 키 값을 사용해, Map<String, dynamic>
의 값을 타입에 맞게끔 파싱하는 처리를 해준다. 기본적인 동작 과정은 아래와 같다.
- null 처리: JSON에 해당 키가 없거나 값이 null이면 기본값 반환
- 타입 일치: 이미 원하는 타입(
T
)이면 그대로 반환 - 타입 변환: 다른 타입이지만 변환 가능하면 변환해서 반환
- 실패 시 기본값: 모든 시도가 실패하면 기본값 반환
이제 JSON 파싱 코드가 이렇게 바뀌었다:
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: jsonTypeSafeParse<int>(
json: json,
key: 'id',
defaultValue: 0,
),
name: jsonTypeSafeParse<String?>(
json: json,
key: 'name',
defaultValue: null,
),
verified: jsonTypeSafeParse<bool>(
json: json,
key: 'verified',
defaultValue: false,
),
);
}
이제 백엔드에서 id
를 "123"
으로 보내든 123
으로 보내든 상관없이 파싱 가능하며, 잘못된 값을 전달했을 시에는 defaultValue를 할당한다. 물론 이에 따른 사이드 이펙트가 발생할 가능성도 있으나, 중간에 의도하지 않은 값이 끼어있어 응답 본문을 파싱하다 런타임 에러로 오동작하는 것보다야 낫다.
결론: 사람의 실수는 방어적인 코드로 어느정도 줄일 수 있다.
이 작업을 하면서 깨달은 것은 강타입 언어를 사용한다고 해서, 타입 에러로부터 완벽하게 자유로울수는 없다는 점이다. 특히 외부 API와 통신할때는 더더욱. jsonTypeSafeParse<T>
함수를 도입한 후, 런타임 에러는 현저히 줄어들었다. 물론 코드가 조금 더 장황해지긴 했지만, 이따금씩 API 본문에 이상한 데이터가 끼어있어서 오동작하는 경우가 발생하는 것보다는 낫다. 그리고 무엇보다, 이제는 백엔드에서 어떤 타입으로 데이터를 보내오든, 타입 불일치 때문에 스트레스받지 않아도 된다.