JSON 데이터 다루기
JSON 데이터 다루기
dart:convert, jsonDecode, 패턴 매칭을 사용해 Wikipedia API의 JSON 데이터를 다루는 법을 포함해, Dart에서 JSON 역직렬화하는 방법을 배워 봐요.
출처: 원문
본문
이 챕터에서는 Dart에서 JSON(JavaScript Object Notation) 데이터를 다루는 법을 배워요. Wikipedia API 응답을 나타내는 데이터 모델을 만들고, dart:convert로 JSON 텍스트를 Dart 컬렉션으로 디코딩하며, 패턴 매칭으로 데이터를 추출하고 검증하는 방법을 살펴볼 거예요.
이번 장에서 할 일
- Dart에서 JSON 처리 배우기
- 다중 패키지 워크스페이스 설정하기
- JSON 데이터를 위한 데이터 모델 클래스 만들기
- fromJson 생성자에서 패턴 매칭 사용하기
사전 준비
이 챕터를 시작하기 전에 다음을 확인하세요:
- 8장을 마쳤고
dartpedia프로젝트로 작업할 수 있는 Dart 개발 환경이 갖춰져 있을 것. - 클래스와 데이터 타입을 포함한 기본 Dart 문법을 이해하고 있을 것.
How Dart handles JSON
JSON은 객체, 배열, 숫자, 문자열 같은 구조화된 데이터를 표현하는 텍스트 기반 형식이에요. 웹 API와 상호작용할 때 응답은 JSON 문자열로 도착해요.
Dart에서 JSON 문자열을 강력한 타입의 데이터 모델로 변환하는 데는 두 단계가 있어요:
- JSON 문자열을 Dart 컬렉션으로 디코딩한다.
dart:convert라이브러리가 raw JSON 문자열을 표준 Dart 컬렉션으로 파싱하는jsonDecode()함수를 제공해요:- JSON 객체(
{...})는Map<String, dynamic>이 되어요. - JSON 배열(
[...])은List<dynamic>이 되어요.
- JSON 객체(
import 'dart:convert';
const String jsonString = '{"title": "Dart", "pageid": 12345}';
// jsonDecode parses the string, cast to a Map<String, Object?>
final Map<String, Object?> jsonMap =
jsonDecode(jsonString) as Map<String, Object?>;
- 디코딩된 컬렉션을 사용자 지정 모델 객체로 변환한다.
Map에서 값을 직접 읽을 수는 있지만(jsonMap['title']처럼), 애플리케이션 전체에서 raw map을 쓰면 타입 안전성이 없고 오타가 나기 쉬우며 IDE 자동완성도 제공되지 않아요.
이 문제를 해결하기 위해 Dart 애플리케이션은 관례적으로 fromJson이라고 부르는 factory 생성자를 가진 데이터 모델 클래스를 정의해, 디코딩된 map에서 타입이 정해진 객체를 만듭니다:
class ArticleSummary {
final String title;
final int pageid;
ArticleSummary({required this.title, required this.pageid});
factory ArticleSummary.fromJson(Map<String, Object?> json) {
return ArticleSummary(
title: json['title'] as String,
pageid: json['pageid'] as int,
);
}
}
Dart는 fromJson 생성자에서 패턴 매칭도 지원해서, JSON map의 형태를 검증하고 값을 추출하는 일을 단 한 번의 간결한 단계로 할 수 있어요.
Tasks
다음 작업들은 다중 패키지 워크스페이스를 설정하고 Wikipedia API 응답을 위한 데이터 모델 클래스를 만들어요.
Task 1: Wikipedia 패키지 만들기
먼저 데이터 모델을 담을 새 Dart 패키지를 만들어요.
-
프로젝트의 루트 디렉토리(
/dartpedia)로 이동하세요. -
터미널에서 다음 명령을 실행하세요:
dart create wikipedia
이 명령은 Dart 패키지의 기본 구조를 가진 wikipedia라는 새 디렉토리를 만들어요. 이제 프로젝트 루트에 cli와 command_runner 옆에 wikipedia라는 새 폴더가 보일 거예요.
Task 2: Dart 워크스페이스 구성하기
Dart 워크스페이스는 단일 프로젝트 안에서 여러 관련 패키지를 관리할 수 있게 해 줘요. 의존성 관리와 로컬 개발을 단순화해요. 이제 세 번째 패키지를 추가했으니, 프로젝트가 Dart 워크스페이스를 사용하도록 구성하기 좋은 시점이에요.
- 루트
pubspec.yaml파일을 만드세요. 프로젝트의 루트 디렉토리(/dartpedia)로 이동해 다음 내용으로pubspec.yaml이라는 새 파일을 만드세요:
name: _
publish_to: none
environment:
sdk: ^3.8.1 # IMPORTANT: Adjust this to match your Dart SDK version or a compatible range
workspace:
- cli
- command_runner
- wikipedia
-
서브 패키지에 워크스페이스 resolution을 추가하세요. 각 서브 패키지(
cli,command_runner,wikipedia)에 대해 각각의pubspec.yaml파일을 열고resolution: workspace를 추가하세요. 이렇게 하면 Dart가 워크스페이스 안에서 의존성을 해석하도록 지시해요.cli/pubspec.yaml:
# ... (existing content) ...
name: cli
description: A sample command-line application.
version: 1.0.0
resolution: workspace # Add this line
# ... (existing content) ...
command_runner/pubspec.yaml:
# ... (existing content) ...
name: command_runner
description: A starting point for Dart libraries or applications.
version: 1.0.0
resolution: workspace # Add this line
# ... (existing content) ...
wikipedia/pubspec.yaml:
# ... (existing content) ...
name: wikipedia
description: A sample command-line application.
version: 1.0.0
resolution: workspace # Add this line
# ... (existing content) ...
- 워크스페이스 의존성을 해석하세요. 프로젝트 루트에서
dart pub get을 실행해 워크스페이스의 모든 패키지에서 의존성을 해석하세요:
dart pub get
Task 3: Summary 클래스 만들기
Wikipedia API는 기사 요약을 담은 JSON 객체를 반환해요. page summary 엔드포인트의 전형적인 응답은 이렇게 생겼어요:
{
"titles": {
"canonical": "Dart_(programming_language)",
"normalized": "Dart (programming language)",
"display": "Dart (programming language)"
},
"pageid": 37194605,
"extract": "Dart is a client-optimized language for fast apps...",
"extract_html": "<p><b>Dart</b> is a client-optimized language...</p>",
"lang": "en",
"dir": "ltr",
"content_urls": {
"desktop": {
"page": "https://en.wikipedia.org/wiki/Dart_(programming_language)"
},
"mobile": {
"page": "https://en.m.wikipedia.org/wiki/Dart_(programming_language)"
}
},
"description": "Programming language"
}
이 요약을 나타내는 Dart 클래스를 만들어요.
wikipedia/lib/src/model디렉토리를 만드세요.
mkdir -p wikipedia/lib/src/model
-
wikipedia/lib/src/model/summary.dart파일을 만드세요. -
wikipedia/lib/src/model/summary.dart에 다음 코드를 추가하세요:
wikipedia/lib/src/model/summary.dart
import 'title_set.dart';
class Summary {
/// Returns a new [Summary] instance.
Summary({
required this.titles,
required this.pageid,
required this.extract,
required this.extractHtml,
required this.lang,
required this.dir,
this.url,
this.description,
});
///
TitlesSet titles;
/// The page ID
int pageid;
/// First several sentences of an article in plain text
String extract;
/// First several sentences of an article in simple HTML format
String extractHtml;
/// Url to the article on Wikipedia
String? url;
/// The page language code
String lang;
/// The page language direction code
String dir;
/// Wikidata description for the page
String? description;
/// Creates a [Summary] instance from a JSON map.
factory Summary.fromJson(Map<String, Object?> json) {
return switch (json) {
{
'titles': final Map<String, Object?> titles,
'pageid': final int pageid,
'extract': final String extract,
'extract_html': final String extractHtml,
'lang': final String lang,
'dir': final String dir,
'content_urls': {
'desktop': {'page': final String url},
'mobile': {'page': String _},
},
'description': final String description,
} =>
Summary(
titles: TitlesSet.fromJson(titles),
pageid: pageid,
extract: extract,
extractHtml: extractHtml,
lang: lang,
dir: dir,
url: url,
description: description,
),
{
'titles': final Map<String, Object?> titles,
'pageid': final int pageid,
'extract': final String extract,
'extract_html': final String extractHtml,
'lang': final String lang,
'dir': final String dir,
'content_urls': {
'desktop': {'page': final String url},
'mobile': {'page': String _},
},
} =>
Summary(
titles: TitlesSet.fromJson(titles),
pageid: pageid,
extract: extract,
extractHtml: extractHtml,
lang: lang,
dir: dir,
url: url,
),
_ => throw FormatException('Could not deserialize Summary, json=$json'),
};
}
@override
String toString() =>
'Summary['
'titles=$titles, '
'pageid=$pageid, '
'extract=$extract, '
'extractHtml=$extractHtml, '
'lang=$lang, '
'dir=$dir, '
'description=$description'
']';
}
이 코드는 Wikipedia summary 엔드포인트가 반환하는 필드를 나타내는 Summary 클래스를 정의해요:
fromJson패턴 매칭: map 키를 수동 캐스팅(json['pageid'] as int처럼)으로 하나씩 접근할 수도 있지만,fromJsonfactory 생성자는 패턴 매칭을 사용해 구조를 검증하고, 타입을 확인하고, 값을 추출하는 것을 단 하나의 선언적 표현식으로 해요.switch표현식: Wikipedia의 선택적description필드를 처리하는 두 가지 케이스를 제공해요. 하나는 있을 때 추출하고, 하나는 누락됐을 때 매칭되는 대체 케이스예요.toString: 디버깅을 위해Summary객체의 읽을 수 있는 문자열 표현을 제공해요.
참고: Task 4에서
TitlesSet을 만들기 전까지 에디터가import 'title_set.dart'와TitlesSet을 해석되지 않은 참조로 표시할 수 있어요.
Task 4: TitleSet 클래스 만들기
Summary 클래스는 제목 정보를 나타내는 TitlesSet 클래스를 사용해요. 이제 그 클래스를 만들어 볼게요.
-
wikipedia/lib/src/model/title_set.dart파일을 만드세요. -
wikipedia/lib/src/model/title_set.dart에 다음 코드를 추가하세요:
wikipedia/lib/src/model/title_set.dart
class TitlesSet {
/// Returns a new [TitlesSet] instance.
TitlesSet({
required this.canonical,
required this.normalized,
required this.display,
});
/// the DB key (non-prefixed), e.g. may have _ instead of spaces,
/// best for making request URIs, still requires Percent-encoding
String canonical;
/// the normalized title (https://www.mediawiki.org/wiki/API:Query#Example_2:_Title_normalization),
/// e.g. may have spaces instead of _
String normalized;
/// the title as it should be displayed to the user
String display;
/// Creates a [TitlesSet] instance from a JSON map.
factory TitlesSet.fromJson(Map<String, Object?> json) {
if (json case {
'canonical': final String canonical,
'normalized': final String normalized,
'display': final String display,
}) {
return TitlesSet(
canonical: canonical,
normalized: normalized,
display: display,
);
}
throw FormatException('Could not deserialize TitleSet, json=$json');
}
@override
String toString() =>
'TitlesSet['
'canonical=$canonical, '
'normalized=$normalized, '
'display=$display'
']';
}
이 코드는 Wikipedia API가 반환하는 제목 변형들을 담는 TitlesSet 클래스를 정의해요. 선택적 필드에 switch 표현식을 쓰는 Summary와 달리, TitlesSet은 고정된 구조를 가지며 if case 문으로 세 필드를 한 번에 검증해요. JSON map이 패턴과 일치하지 않으면 FormatException을 던져요.
Task 5: Article 클래스 만들기
Wikipedia API는 검색 결과에서 기사 목록도 반환해요. 기사를 나타내는 Dart 클래스를 만들어요.
-
wikipedia/lib/src/model/article.dart파일을 만드세요. -
wikipedia/lib/src/model/article.dart에 다음 코드를 추가하세요:
wikipedia/lib/src/model/article.dart
class Article {
Article({required this.title, required this.extract});
final String title;
final String extract;
static List<Article> listFromJson(Map<String, Object?> json) {
final List<Article> articles = <Article>[];
if (json case {'query': {'pages': final Map<String, Object?> pages}}) {
for (final MapEntry<String, Object?>(:Object? value) in pages.entries) {
if (value case {
'title': final String title,
'extract': final String extract,
}) {
articles.add(Article(title: title, extract: extract));
}
}
return articles;
}
throw FormatException('Could not deserialize Article, json=$json');
}
Map<String, Object?> toJson() => <String, Object?>{
'title': title,
'extract': extract,
};
@override
String toString() {
return 'Article{title: $title, extract: $extract}';
}
}
이 코드는 기사의 제목과 요약을 나타내는 Article 클래스를 정의해요:
listFromJson: 단일 인스턴스를 만드는 이전 모델들과 달리, Wikipedia 검색 엔드포인트는 여러 기사를 map으로 반환해요. Dart 생성자는 단일 인스턴스만 반환하므로,Article은List<Article>을 반환하는listFromJson이라는static메서드를 사용해요.- 객체 패턴 구조 분해:
for루프가final MapEntry(:value)를 사용해 수동 프로퍼티 접근 없이 각 엔트리의 값을 직접 추출해요. toJson:Article인스턴스를 JSON map으로 다시 변환해요. Dart 관례에서toJson()은 객체를 JSON 문자열로 직렬화할 때dart:convert의jsonEncode()에 넘길 수 있는Map<String, Object?>을 반환해요.
Task 6: SearchResults 클래스 만들기
마지막으로 Wikipedia API의 검색 결과를 나타내는 클래스를 만들어요. Wikipedia 검색 엔드포인트는 검색어, 기사 제목, 설명(무시됨), URL을 담은 배열을 반환해요:
[
"dart",
["Dart (programming language)", "Dart"],
["", ""],
[
"https://en.wikipedia.org/wiki/Dart_(programming_language)",
"https://en.wikipedia.org/wiki/Dart"
]
]
-
wikipedia/lib/src/model/search_results.dart파일을 만드세요. -
wikipedia/lib/src/model/search_results.dart에 다음 코드를 추가하세요:
wikipedia/lib/src/model/search_results.dart
class SearchResult {
SearchResult({required this.title, required this.url});
final String title;
final String url;
}
class SearchResults {
SearchResults(this.results, {this.searchTerm});
final List<SearchResult> results;
final String? searchTerm;
/// Creates a [SearchResults] instance from a JSON list.
factory SearchResults.fromJson(List<Object?> json) {
final List<SearchResult> results = <SearchResult>[];
if (json case [
String searchTerm,
Iterable articleTitles,
Iterable _,
Iterable urls,
]) {
final List titlesList = articleTitles.toList();
final List urlList = urls.toList();
for (int i = 0; i < articleTitles.length; i++) {
results.add(SearchResult(title: titlesList[i], url: urlList[i]));
}
return SearchResults(results, searchTerm: searchTerm);
}
throw FormatException('Could not deserialize SearchResults, json=$json');
}
@override
String toString() {
final StringBuffer pretty = StringBuffer();
for (final SearchResult result in results) {
pretty.write('${result.url} \n');
}
return '\nSearchResults for $searchTerm: \n$pretty';
}
}
이 코드는 두 클래스를 정의해요: 개별 기사의 제목과 URL을 담는 SearchResult, 그리고 검색어와 함께 결과 목록을 담는 SearchResults.
List<Object?>를 받는fromJson: 이 생성자는 Wikipedia 검색 API가 반환하는 최상위 JSON 배열과 일치하도록Map<String, Object?>가 아니라List<Object?>를 받아요.- 리스트 패턴 매칭:
if case문이 리스트 패턴[...]을 사용해 배열을 위치별로 매칭하고 각 섹션을 추출해요. - 와일드카드 패턴(
_):Iterable _패턴이 애플리케이션이 필요로 하지 않는 설명 배열을 매칭해 버려요.
이제 Wikipedia API 응답을 나타내는 타입이 정해진 데이터 모델을 갖췄어요. 다음 장에서는 package:test로 데이터가 어떻게 역직렬화되는지 테스트하고, package:http로 API에서 실시간 JSON 데이터를 가져올 거예요.
Review
무엇을 해냈나요
이번 레슨에서 만들고 배운 내용을 정리해 볼게요.
-
Dart에서 JSON 처리 배우기
dart:convert와jsonDecode()가 JSON 문자열을 Dart 컬렉션(Map과List)으로 파싱하는 방법, 그리고fromJsonfactory 생성자를 가진 타입이 정해진 모델이 raw map보다 선호되는 이유를 살펴봤어요. -
pub 워크스페이스 설정하기
다중 패키지 프로젝트의 의존성 관리를 단순화하려고 새 pub 워크스페이스를 만들었어요. 루트
pubspec.yaml파일에 패키지를 나열하는workspace:섹션을 만들고, 각 서브 패키지에resolution: workspace를 추가했어요. -
JSON을 위한 데이터 모델 클래스 만들기
Wikipedia API 응답을 나타내는
Summary,TitlesSet,Article,SearchResults클래스를 만들었어요. 이 타입 모델들은 API 데이터를 다룰 때 컴파일 시점 안전성과 IDE 지원을 제공해요. -
fromJson factory 생성자에서 패턴 매칭 사용하기
switch표현식과if case문으로 Dart의 패턴 매칭을 사용해fromJsonfactory 생성자를 구현했어요. 이 구조는 JSON 형태를 검증하고 값을 간결하고 읽기 쉬운 표현식으로 추출해요.
Quiz
이해를 확인해 봐요
JSON이란 무엇인가요?
- 사람이 읽고 쓰기 쉽고 기계가 파싱하고 생성하기 쉬운 경량 데이터 교환 형식 — 맞아요! JSON(JavaScript Object Notation)은 구조화된 데이터를 표현하는 텍스트 기반 형식이에요. 특히 웹 API에서 애플리케이션 간 데이터 교환에 널리 쓰여요.
- 웹 개발을 위해 특별히 설계된 프로그래밍 언어 — 아니에요. JSON은 프로그래밍 언어가 아니에요. JSON으로 로직을 작성하거나 코드를 실행할 수 없어요.
- JavaScript 애플리케이션에서만 쓰는 데이터베이스 형식 — 아니에요. JSON은 데이터베이스 형식도 아니고 JavaScript 전용도 아니에요. JavaScript 문법에서 시작했지만 많은 언어와 플랫폼에서 쓰여요.
- 데이터 직렬화를 처리하는 Dart 전용 라이브러리 — 아니에요. JSON은 Dart 전용도, 라이브러리도 아니에요. Dart보다 먼저 존재했고 어디서나 쓰여요.
jsonDecode가 {"name": "Dart"} 같은 JSON 객체를 파싱하면 어떤 Dart 타입을 반환하나요?
- JSON 객체를 나타내는
Map<String, dynamic>— 맞아요! JSON 객체는Map<String, dynamic>이 되어요. 키는 문자열이고 값은 JSON과 호환되는 어떤 타입이든 될 수 있어요. - JSON 텍스트를 담은
String— 아니에요.jsonDecode는 JSON 문자열을 Dart 객체로 파싱해요. 문자열 표현을 map과 list를 포함한 구조화된 데이터로 변환하지, 문자열로 유지하지 않아요. dart:convert의 사용자 지정JsonObject클래스 — 아니에요. Dart에는JsonObject클래스가 없어요.jsonDecode는 표준 Dart 컬렉션 타입을 반환해요.- 키-값 쌍을 담은
List<String>— 아니에요. 리스트는 키-값 관계를 잃을 거예요. JSON 객체는 이름이 있는 프로퍼티를 가지므로 리스트와는 다른 데이터 구조가 필요해요.
raw Map을 그냥 쓰지 않고 fromJson factory 생성자를 가진 Summary 클래스를 만드는 이유는 무엇인가요?
- 타입 안전성, IDE 자동완성, 예상 데이터 구조를 문서화하는 더 명확한 코드 — 맞아요! 클래스는 컴파일 시점 타입 검사, 프로퍼티 자동완성, 자체 문서화 코드를 제공해요.
summary.title은map['title']보다 명확해요. - raw map은 클래스 프로퍼티보다 접근이 느리다 — 아니에요. 성능이 주된 이유가 아니에요. Dart에서 map 접근은 꽤 빨라요. 개발자 경험을 생각해 보세요.
jsonDecode는 클래스 없이는 중첩 JSON 객체를 파싱할 수 없다 — 아니에요.jsonDecode는 중첩 객체를 잘 처리해요. 중첩 map이 돼요. 클래스는 파싱과는 다른 목적을 가져요.- Dart는 모든 JSON 데이터를 사용 전에 클래스로 변환해야 한다 — 아니에요. Dart는 이것을 요구하지 않아요. 코드 전체에서 raw map을 직접 쓸 수 있어요.
다음 레슨
다음 레슨에서는 package:test 라이브러리로 Dart 코드를 테스트하는 법을 배워요. JSON 역직렬화 로직이 올바르게 동작하는지 검증하는 테스트를 작성해요.
더 알아보기
- dart:convert — JSON 인코딩·디코딩을 위한 표준 라이브러리예요.
- 패턴 — Dart의 패턴 매칭 기능을 자세히 살펴보세요.
- pub 워크스페이스 — 다중 패키지 워크스페이스 설정법을 알아보세요.