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>이 되어요.
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라는 새 디렉토리를 만들어요. 이제 프로젝트 루트에 clicommand_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처럼)으로 하나씩 접근할 수도 있지만, fromJson factory 생성자는 패턴 매칭을 사용해 구조를 검증하고, 타입을 확인하고, 값을 추출하는 것을 단 하나의 선언적 표현식으로 해요.
  • 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 생성자는 단일 인스턴스만 반환하므로, ArticleList<Article>을 반환하는 listFromJson이라는 static 메서드를 사용해요.
  • 객체 패턴 구조 분해: for 루프가 final MapEntry(:value)를 사용해 수동 프로퍼티 접근 없이 각 엔트리의 값을 직접 추출해요.
  • toJson: Article 인스턴스를 JSON map으로 다시 변환해요. Dart 관례에서 toJson()은 객체를 JSON 문자열로 직렬화할 때 dart:convertjsonEncode()에 넘길 수 있는 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:convertjsonDecode()가 JSON 문자열을 Dart 컬렉션(MapList)으로 파싱하는 방법, 그리고 fromJson factory 생성자를 가진 타입이 정해진 모델이 raw map보다 선호되는 이유를 살펴봤어요.

  • pub 워크스페이스 설정하기

    다중 패키지 프로젝트의 의존성 관리를 단순화하려고 새 pub 워크스페이스를 만들었어요. 루트 pubspec.yaml 파일에 패키지를 나열하는 workspace: 섹션을 만들고, 각 서브 패키지에 resolution: workspace를 추가했어요.

  • JSON을 위한 데이터 모델 클래스 만들기

    Wikipedia API 응답을 나타내는 Summary, TitlesSet, Article, SearchResults 클래스를 만들었어요. 이 타입 모델들은 API 데이터를 다룰 때 컴파일 시점 안전성과 IDE 지원을 제공해요.

  • fromJson factory 생성자에서 패턴 매칭 사용하기

    switch 표현식과 if case 문으로 Dart의 패턴 매칭을 사용해 fromJson factory 생성자를 구현했어요. 이 구조는 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.titlemap['title']보다 명확해요.
  • raw map은 클래스 프로퍼티보다 접근이 느리다 — 아니에요. 성능이 주된 이유가 아니에요. Dart에서 map 접근은 꽤 빨라요. 개발자 경험을 생각해 보세요.
  • jsonDecode는 클래스 없이는 중첩 JSON 객체를 파싱할 수 없다 — 아니에요. jsonDecode는 중첩 객체를 잘 처리해요. 중첩 map이 돼요. 클래스는 파싱과는 다른 목적을 가져요.
  • Dart는 모든 JSON 데이터를 사용 전에 클래스로 변환해야 한다 — 아니에요. Dart는 이것을 요구하지 않아요. 코드 전체에서 raw map을 직접 쓸 수 있어요.

다음 레슨

다음 레슨에서는 package:test 라이브러리로 Dart 코드를 테스트하는 법을 배워요. JSON 역직렬화 로직이 올바르게 동작하는지 검증하는 테스트를 작성해요.

더 알아보기

  • dart:convert — JSON 인코딩·디코딩을 위한 표준 라이브러리예요.
  • 패턴 — Dart의 패턴 매칭 기능을 자세히 살펴보세요.
  • pub 워크스페이스 — 다중 패키지 워크스페이스 설정법을 알아보세요.