인터넷에서 데이터 가져오기

인터넷에서 데이터 가져오기

http 패키지를 사용해 인터넷을 통해 데이터를 가져오는 방법을 배우는 튜토리얼이에요. HTTP 요청, URI, JSON의 기초부터 실제로 데이터를 가져와 디코딩하고 활용하는 전체 흐름을 다룰게요.

출처: Fetch data from the internet

본문

이 튜토리얼에서 배울 내용이에요.

  • HTTP 요청과 URI가 무엇이며 무엇에 쓰이는지의 기초.
  • package:http를 사용한 HTTP 요청 만들기.
  • dart:convert로 JSON 문자열을 Dart 객체로 디코딩하기.
  • JSON 객체를 클래스 기반 구조로 변환하기.

대부분의 애플리케이션은 인터넷에서의 통신이나 데이터 검색을 어느 정도 필요로 해요. 많은 앱은 클라이언트에서 서버로 전송되어 URI(Uniform Resource Identifier)로 식별되는 리소스를 위해 특정 동작을 수행하는 HTTP 요청을 통해 이 작업을 해요.

HTTP로 전달되는 데이터는 기술적으로 어떤 형태든 될 수 있지만, JSON(JavaScript Object Notation)은 사람이 읽기 쉽고 언어에 독립적이라는 특성 덕분에 인기 있는 선택이에요. Dart SDK와 생태계도 앱 요구 사항을 가장 잘 충족시키는 여러 옵션으로 JSON을 폭넓게 지원해요.

이 튜토리얼에서는 HTTP 요청, URI, JSON에 대해 더 배우고, 그다음 package:httpdart:convert 라이브러리의 Dart JSON 지원을 사용해 HTTP 서버에서 가져온 JSON 형식 데이터를 가져와 디코딩하고 활용하는 방법을 배울 거예요.

배경 개념

다음 섹션은 서버에서 데이터를 가져오는 튜토리얼에서 사용되는 기술과 개념에 대한 추가적인 배경과 정보를 제공해요. 튜토리얼 내용으로 바로 건너뛰려면 필요한 의존성 가져오기를 참고하세요.

JSON

JSON(JavaScript Object Notation)은 애플리케이션 개발과 클라이언트-서버 통신 전반에 널리 퍼진 데이터 교환 형식이에요. 텍스트 기반이라 가볍지만 사람이 읽고 쓰기 쉬워요. JSON을 사용하면 목록과 맵 같은 다양한 데이터 타입과 단순한 데이터 구조를 문자열로 직렬화하고 표현할 수 있어요.

대부분의 언어에는 여러 구현이 있고 파서가 매우 빨라졌으니 상호운용성이나 성능을 걱정할 필요가 없어요. JSON 형식에 대한 자세한 내용은 Introducing JSON을, Dart에서 JSON을 다루는 방법은 Using JSON 가이드를 참고하세요.

모바일용 플랫폼 특정 구현을 가진 다른 두 패키지도 존재해요.

  • cronet_http는 Android Cronet HTTP 클라이언트에 대한 접근을 제공해요.
  • cupertino_http는 Apple의 Foundation URL Loading System에 대한 접근을 제공해요.

기능에 대해 더 알아보려면 패키지 문서를 확인하세요.

HTTP 요청

HTTP(Hypertext Transfer Protocol)는 원래 웹 클라이언트와 웹 서버 사이에서 문서를 전송하도록 설계된 무상태(stateless) 프로토콜이에요. 브라우저가 웹 서버에서 페이지 내용을 가져오기 위해 HTTP GET 요청을 사용하므로, 당신은 이 페이지를 로드하면서 이미 이 프로토콜과 상호작용했어요. 도입 이후 HTTP 프로토콜과 그 다양한 버전의 사용은 웹 밖의 애플리케이션으로도 확장되어, 기본적으로 클라이언트에서 서버로의 통신이 필요한 곳 어디에서나 쓰여요.

클라이언트가 서버와 통신하기 위해 보내는 HTTP 요청은 여러 구성 요소로 이루어져 있어요. package:http 같은 HTTP 라이브러리를 사용해 다음 종류의 통신을 지정할 수 있어요.

  • 데이터 검색을 위한 GET이나 새 데이터 제출을 위한 POST 같은 원하는 동작을 정의하는 HTTP 메서드.
  • URI를 통한 리소스의 위치.
  • 사용 중인 HTTP 버전.
  • 서버에 추가 정보를 제공하는 헤더.
  • 요청이 데이터를 검색만 하는 게 아니라 서버로 보낼 수 있게 하는 선택적 본문(body).

HTTP 프로토콜에 대해 더 알아보려면 mdn web docs의 An overview of HTTP를 확인하세요.

URI와 URL

HTTP 요청을 만들려면 리소스에 대한 URI를 제공해야 해요. URI는 리소스를 고유하게 식별하는 문자열이에요. URL(Uniform Resource Locator)은 리소스의 위치도 제공하는 특정 종류의 URI예요. 웹의 리소스에 대한 URL은 세 가지 정보를 포함해요. 현재 이 페이지의 URL은 다음으로 구성되어 있어요.

  • 사용되는 프로토콜을 결정하는 스킴(scheme): https
  • 서버의 권한 또는 호스트 이름: dart.dev
  • 리소스의 경로: /server/fetch-data.html

현재 페이지에서 사용하지 않는 다른 선택적 매개변수도 있어요.

  • 추가 동작을 커스터마이징하는 매개변수: ?key1=value1&key2=value2
  • 서버로 전송되지 않고 리소스의 특정 위치를 가리키는 앵커(anchor): #uris

URL에 대해 더 알아보려면 mdn web docs의 What is a URL?를 참고하세요.

필요한 의존성 가져오기

package:http 라이브러리는 선택적인 세밀한 제어와 함께 조합 가능한 HTTP 요청을 만드는 크로스 플랫폼 솔루션을 제공해요.

참고: HTTP 요청을 하기 위해 dart:iodart:html을 직접 사용하는 것은 피하세요. 그 라이브러리들은 플랫폼 의존적이며 단일 구현에 묶여 있어요.

package:http에 대한 의존성을 추가하려면 저장소 최상단에서 다음 dart pub add 명령을 실행하세요.

$ dart pub add http

코드에서 package:http를 사용하려면 가져오고 선택적으로 라이브러리 접두사를 지정하세요.

import 'package:http/http.dart' as http;

package:http에 대한 더 자세한 내용은 pub.dev 사이트의 페이지와 API 문서를 참고하세요.

URL 만들기

앞서 언급했듯 HTTP 요청을 하려면 먼저 요청하는 리소스나 접근하는 엔드포인트를 식별하는 URL이 필요해요.

Dart에서 URL은 Uri 객체로 표현돼요. Uri를 만드는 방법은 여러 가지가 있지만, 유연성 덕분에 Uri.parse로 문자열을 파싱해 만드는 것이 흔한 해결책이에요.

다음 스니펫은 이 사이트에 호스팅된 package:http에 대한 목업 JSON 형식 정보를 가리키는 Uri 객체를 만드는 두 가지 방법을 보여줘요.

// 스킴을 포함한 전체 URI를 파싱
Uri.parse('https://dart.dev/f/packages/http.json');

// https 스킴을 가진 URI를 구체적으로 생성
Uri.https('dart.dev', '/f/packages/http.json');

URI를 만들고 상호작용하는 다른 방법은 URI 문서를 참고하세요.

네트워크 요청하기

요청한 리소스의 문자열 표현을 빠르게 가져오기만 하면 된다면 package:http의 최상위 read 함수를 사용할 수 있어요. 이 함수는 Future<String>을 반환하거나 요청이 성공하지 않으면 ClientException을 던져요. 다음 예시는 read를 사용해 package:http에 대한 목업 JSON 형식 정보를 문자열로 가져온 뒤 출력해요.

참고: read를 포함한 package:http의 많은 함수는 네트워크에 접근하고 시간이 걸리는 작업을 수행하므로 비동기로 진행되고 Future를 반환해요. 아직 future를 접해보지 못했다면 비동기 프로그래밍 튜토리얼에서 future와 async, await 키워드에 대해 배울 수 있어요.

void main() async {
  final httpPackageUrl = Uri.https('dart.dev', '/f/packages/http.json');
  final httpPackageInfo = await http.read(httpPackageUrl);
  print(httpPackageInfo);
}

이것은 다음 JSON 형식 출력을 만들어 내며, 브라우저에서 /f/packages/http.json에서도 볼 수 있어요.

{
  "name": "http",
  "latestVersion": "1.1.2",
  "description": "A composable, multi-platform, Future-based API for HTTP requests.",
  "publisher": "dart.dev",
  "repository": "https://github.com/dart-lang/http"
}

데이터의 구조(이 경우 맵)에 주목하세요. 나중에 JSON을 디코딩할 때 필요할 거예요.

응답에서 상태 코드나 헤더 같은 다른 정보가 필요하다면 Response가 있는 Future를 반환하는 최상위 get 함수를 대신 사용할 수 있어요.

다음 스니펫은 요청이 성공하지 않았을 때(상태 코드 200으로 표시) 일찍 종료하기 위해 get을 사용해 전체 응답을 가져와요.

void main() async {
  final httpPackageUrl = Uri.https('dart.dev', '/f/packages/http.json');
  final httpPackageResponse = await http.get(httpPackageUrl);
  if (httpPackageResponse.statusCode != 200) {
    print('Failed to retrieve the http package!');
    return;
  }
  print(httpPackageResponse.body);
}

200 외에도 다른 상태 코드가 많으며 앱이 이를 다르게 처리하고 싶을 수 있어요. 다양한 상태 코드가 무엇을 의미하는지 알아보려면 mdn web docs의 HTTP response status codes를 참고하세요.

일부 서버 요청은 인증이나 사용자 에이전트 정보 같은 더 많은 정보를 요구해요. 이 경우 HTTP 헤더를 포함해야 할 수 있어요. headers라는 선택적 명명 매개변수에 키-값 쌍의 Map<String, String>을 전달해 헤더를 지정할 수 있어요.

await http.get(
  Uri.https('dart.dev', '/f/packages/http.json'),
  headers: {'User-Agent': '<product name>/<product-version>'},
);

여러 요청하기

같은 서버에 여러 요청을 한다면 최상위 함수와 비슷한 메서드를 가진 Client를 통해 영구 연결을 유지할 수 있어요. 끝났을 때 close 메서드로 정리하기만 하면 돼요.

void main() async {
  final httpPackageUrl = Uri.https('dart.dev', '/f/packages/http.json');
  final client = http.Client();
  try {
    final httpPackageInfo = await client.read(httpPackageUrl);
    print(httpPackageInfo);
  } finally {
    client.close();
  }
}

클라이언트가 실패한 요청을 재시도하도록 하려면 package:http/retry.dart를 가져오고 생성한 ClientRetryClient로 감싸세요.

import 'package:http/http.dart' as http;
import 'package:http/retry.dart';

void main() async {
  final httpPackageUrl = Uri.https('dart.dev', '/f/packages/http.json');
  final client = RetryClient(http.Client());
  try {
    final httpPackageInfo = await client.read(httpPackageUrl);
    print(httpPackageInfo);
  } finally {
    client.close();
  }
}

RetryClient는 재시도 횟수와 각 요청 사이의 대기 시간에 대한 기본 동작을 가지지만, RetryClient()RetryClient.withDelays() 생성자에 대한 매개변수를 통해 동작을 수정할 수 있어요.

package:http는 훨씬 더 많은 기능과 커스터마이징을 제공하니 pub.dev 사이트의 페이지와 API 문서를 꼭 확인하세요.

가져온 데이터 디코딩하기

네트워크 요청을 하고 반환된 데이터를 문자열로 가져왔지만, 문자열에서 특정 정보 부분에 접근하는 것은 어려울 수 있어요.

데이터가 이미 JSON 형식이므로 dart:convert 라이브러리의 내장 json.decode 함수를 사용해 원시 문자열을 Dart 객체를 사용한 JSON 표현으로 변환할 수 있어요. 이 경우 JSON 데이터는 맵 구조로 표현되며 JSON에서는 맵 키가 항상 문자열이므로 json.decode의 결과를 Map<String, dynamic>으로 캐스트할 수 있어요.

import 'dart:convert';

import 'package:http/http.dart' as http;

void main() async {
  final httpPackageUrl = Uri.https('dart.dev', '/f/packages/http.json');
  final httpPackageInfo = await http.read(httpPackageUrl);
  final httpPackageJson = json.decode(httpPackageInfo) as Map<String, dynamic>;
  print(httpPackageJson);
}

데이터를 저장할 구조화된 클래스 만들기

디코딩된 JSON에 더 많은 구조를 제공해 작업을 쉽게 하려면 데이터의 스키마에 따라 특정 타입을 사용해 가져온 데이터를 저장할 수 있는 클래스를 만드세요.

다음 스니펫은 요청한 목업 JSON 파일에서 반환된 패키지 정보를 저장할 수 있는 클래스 기반 표현을 보여줘요. 이 구조는 repository를 제외한 모든 필드가 필수이며 매번 제공된다고 가정해요.

class PackageInfo {
  final String name;
  final String latestVersion;
  final String description;
  final String publisher;
  final Uri? repository;

  PackageInfo({
    required this.name,
    required this.latestVersion,
    required this.description,
    required this.publisher,
    this.repository,
  });
}

데이터를 클래스로 인코딩하기

데이터를 저장할 클래스가 생겼으니, 디코딩된 JSON을 PackageInfo 객체로 변환하는 메커니즘을 추가해야 해요.

이전 JSON 형식과 일치하는 fromJson 메서드를 직접 작성하고, 필요에 따라 타입을 캐스트하며 선택적 repository 필드를 처리해 디코딩된 JSON을 변환하세요.

class PackageInfo {
  // ···

  factory PackageInfo.fromJson(Map<String, dynamic> json) {
    final repository = json['repository'] as String?;

    return PackageInfo(
      name: json['name'] as String,
      latestVersion: json['latestVersion'] as String,
      description: json['description'] as String,
      publisher: json['publisher'] as String,
      repository: repository != null ? Uri.tryParse(repository) : null,
    );
  }
}

이전 예시 같은 직접 작성한 메서드는 비교적 단순한 JSON 구조에 충분한 경우가 많지만, 더 유연한 옵션도 있어요. 변환 로직의 자동 생성까지 포함해 JSON 직렬화와 역직렬화에 대해 더 알아보려면 Using JSON 가이드를 참고하세요.

응답을 구조화된 클래스의 객체로 변환하기

이제 데이터를 저장할 클래스와 디코딩된 JSON 객체를 그 타입의 객체로 변환하는 방법이 생겼어요. 다음으로 모든 것을 묶는 함수를 작성할 수 있어요.

  1. 전달받은 패키지 이름을 기반으로 URI를 만드세요.
  2. http.get을 사용해 해당 패키지의 데이터를 가져오세요.
  3. 요청이 성공하지 않았다면 Exception 또는 가급적 자신만의 커스텀 Exception 하위 클래스를 던지세요.
  4. 요청이 성공했다면 json.decode를 사용해 응답 본문을 JSON 문자열로 디코딩하세요.
  5. 만든 PackageInfo.fromJson 팩토리 생성자를 사용해 디코딩된 JSON 문자열을 PackageInfo 객체로 변환하세요.
Future<PackageInfo> getPackage(String packageName) async {
  final packageUrl = Uri.https('dart.dev', '/f/packages/$packageName.json');
  final packageResponse = await http.get(packageUrl);

  // 요청이 성공하지 않았다면 예외를 던진다
  if (packageResponse.statusCode != 200) {
    throw PackageRetrievalException(
      packageName: packageName,
      statusCode: packageResponse.statusCode,
    );
  }

  final packageJson = json.decode(packageResponse.body) as Map<String, dynamic>;

  return PackageInfo.fromJson(packageJson);
}

class PackageRetrievalException implements Exception {
  final String packageName;
  final int? statusCode;

  PackageRetrievalException({required this.packageName, this.statusCode});
}

변환된 데이터 활용하기

이제 데이터를 가져와 더 쉽게 접근할 수 있는 형식으로 변환했으니 원하는 대로 사용할 수 있어요. 몇 가지 가능성으로는 정보를 CLI로 출력하거나 웹 또는 Flutter 앱에 표시하는 것이 있어요.

다음은 httppath 패키지에 대한 목업 정보를 요청하고, 디코딩한 뒤 표시하는 완전하고 실행 가능한 예시예요.

import 'dart:convert';

import 'package:http/http.dart' as http;

void main() async {
  await printPackageInformation('http');
  print('');
  await printPackageInformation('path');
}

Future<void> printPackageInformation(String packageName) async {
  final PackageInfo packageInfo;

  try {
    packageInfo = await getPackage(packageName);
  } on PackageRetrievalException catch (e) {
    print(e);
    return;
  }

  print('Information about the $packageName package:');
  print('Latest version: ${packageInfo.latestVersion}');
  print('Description: ${packageInfo.description}');
  print('Publisher: ${packageInfo.publisher}');

  final repository = packageInfo.repository;
  if (repository != null) {
    print('Repository: $repository');
  }
}

Future<PackageInfo> getPackage(String packageName) async {
  final packageUrl = Uri.https('dart.dev', '/f/packages/$packageName.json');
  final packageResponse = await http.get(packageUrl);

  // 요청이 성공하지 않았다면 예외를 던진다
  if (packageResponse.statusCode != 200) {
    throw PackageRetrievalException(
      packageName: packageName,
      statusCode: packageResponse.statusCode,
    );
  }

  final packageJson = json.decode(packageResponse.body) as Map<String, dynamic>;

  return PackageInfo.fromJson(packageJson);
}

class PackageInfo {
  final String name;
  final String latestVersion;
  final String description;
  final String publisher;
  final Uri? repository;

  PackageInfo({
    required this.name,
    required this.latestVersion,
    required this.description,
    required this.publisher,
    this.repository,
  });

  factory PackageInfo.fromJson(Map<String, dynamic> json) {
    final repository = json['repository'] as String?;

    return PackageInfo(
      name: json['name'] as String,
      latestVersion: json['latestVersion'] as String,
      description: json['description'] as String,
      publisher: json['publisher'] as String,
      repository: repository != null ? Uri.tryParse(repository) : null,
    );
  }
}

class PackageRetrievalException implements Exception {
  final String packageName;
  final int? statusCode;

  PackageRetrievalException({required this.packageName, this.statusCode});

  @override
  String toString() {
    final buf = StringBuffer();
    buf.write('Failed to retrieve package:$packageName information');

    if (statusCode != null) {
      buf.write(' with a status code of $statusCode');
    }

    buf.write('!');
    return buf.toString();
  }
}

Flutter 참고: 데이터를 가져와 Flutter에 표시하는 내용을 다루는 또 다른 예시는 Flutter 요리책의 Fetching data from the internet을 참고하세요.

다음 단계

이제 인터넷에서 데이터를 가져오고, 파싱하고, 사용했으니 Dart의 동시성(Concurrency)에 대해 더 배우는 것을 고려해 보세요. 데이터가 크고 복잡하다면 검색과 디코딩을 백그라운드 워커로 다른 isolate로 옮겨 인터페이스가 응답하지 않게 되는 것을 방지할 수 있어요.

더 알아보기