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

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

Wikipedia CLI를 위한 견고하고 프로덕션에 바로 쓸 수 있는 API 클라이언트로 애플리케이션을 리팩터링해 봐요.

출처: Fetch data from the internet

본문

3장에서 http 패키지로 간단한 요청을 해 봤어요. 이제 HTTP를 다시 들여다보면서 wikipedia 패키지 안에 견고하고 프로덕션에 바로 쓸 수 있는 API 클라이언트를 만들어 봐요. 쿼리 파라미터를 다루는 방법, JSON 응답을 데이터 모델로 매핑하는 방법, 그리고 이 함수들을 CLI에서 사용할 수 있게 export하는 방법을 배워 볼게요.

  • 쿼리 파라미터가 있는 복잡한 URI를 구성해요.
  • 프로덕션에 바로 쓸 수 있는 HTTP API 클라이언트를 만들어요.
  • 재사용을 위해 함수와 타입을 export해요.

사전 준비 (Prerequisites)

이 장을 시작하기 전에 다음을 확인해 주세요:

  • 10장을 마치고 dartpedia 프로젝트가 있는, 동작하는 Dart 개발 환경을 갖췄는지 확인해요.
  • 기본 네트워킹 개념(API와 HTTP 요청 같은)을 이해하고 있는지 확인해요.
  • JSON 같은 기본 데이터 직렬화 형식을 이해하고 있는지 확인해요.

작업 (Tasks)

이 장에서는 단순한 스크립트를 넘어 제대로 된 API 계층을 구현해요. wikipedia 패키지 안에서 API 클라이언트 로직을 구현해 애플리케이션의 확장성과 유지보수성을 높여 봐요.

작업 1: wikipedia 패키지에 http 의존성 추가하기 (Add the http dependency to the wikipedia package)

HTTP 요청을 하려면 http 패키지를 wikipedia 패키지의 의존성으로 추가해야 해요.

  • 프로젝트 안의 wikipedia/pubspec.yaml 파일을 열어 주세요.

  • dependencies 섹션을 찾아 주세요.

  • dependencies 아래에 http: ^1.3.0(또는 최신 안정 버전)을 추가해 주세요:

dependencies:
  http: ^1.3.0
  • pubspec.yaml 파일을 저장해 주세요.

  • wikipedia 디렉터리에서 터미널에 dart pub get을 실행해 주세요.

작업 2: Wikipedia API 호출 구현하기 (Implement Wikipedia API calls)

다음으로 Wikipedia에서 데이터를 가져오는 API 함수를 만들어 보세요. 세 개의 파일을 만들게 돼요.

  • summary.dart: 문서 요약을 가져오는 함수를 담을 파일이에요.

  • search.dart: 문서를 찾기 위한 검색 쿼리를 처리하는 파일이에요.

  • get_article.dart: 문서의 전체 내용을 가져오는 함수를 담을 파일이에요.

  • wikipedia/lib/src/api 디렉터리를 만들어 주세요.

  • wikipedia/lib/src/api/summary.dart 파일을 만들어 주세요.

  • wikipedia/lib/src/api/summary.dart에 다음 코드를 추가해 주세요:

import 'dart:convert';
import 'dart:io';

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

import '../model/summary.dart';

Future<Summary> getRandomArticleSummary() async {
  final http.Client client = http.Client();
  try {
    final Uri url = Uri.https(
      'en.wikipedia.org',
      '/api/rest_v1/page/random/summary',
    );
    final http.Response response = await client.get(url);
    if (response.statusCode == 200) {
      final Map<String, Object?> jsonData =
          jsonDecode(response.body) as Map<String, Object?>;
      return Summary.fromJson(jsonData);
    } else {
      throw HttpException(
        '[WikipediaApiClient.getRandomArticleSummary] '
        'statusCode=${response.statusCode}, body=${response.body}',
      );
    }
  } on FormatException {
    // todo: log exceptions
    rethrow;
  } finally {
    client.close();
  }
}

Future<Summary> getArticleSummaryByTitle(String articleTitle) async {
  final http.Client client = http.Client();
  try {
    final Uri url = Uri.https(
      'en.wikipedia.org',
      '/api/rest_v1/page/summary/$articleTitle',
    );
    final http.Response response = await client.get(url);
    if (response.statusCode == 200) {
      final Map<String, Object?> jsonData =
          jsonDecode(response.body) as Map<String, Object?>;
      return Summary.fromJson(jsonData);
    } else {
      throw HttpException(
        '[WikipediaApiClient.getArticleSummaryByTitle] '
        'statusCode=${response.statusCode}, body=${response.body}',
      );
    }
  } on FormatException {
    // todo: log exceptions
    rethrow;
  } finally {
    client.close();
  }
}

이 코드는 getRandomArticleSummarygetArticleSummaryByTitle 두 함수를 정의해요. 두 함수 모두 http 패키지를 사용해 Wikipedia API에 GET 요청을 보내고 Summary 객체를 반환해요. getRandomArticleSummary는 무작위 문서의 요약을, getArticleSummaryByTitle는 특정 문서 제목의 요약을 가져와요.

  • 다음으로 wikipedia/lib/src/api/search.dart 파일을 만들어 주세요.

  • wikipedia/lib/src/api/search.dart에 다음 코드를 추가해 주세요:

import 'dart:convert';
import 'dart:io';

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

import '../model/search_results.dart';

Future<SearchResults> search(String searchTerm) async {
  final http.Client client = http.Client();
  try {
    final Uri url = Uri.https(
      'en.wikipedia.org',
      '/w/api.php',
      <String, Object?>{
        'action': 'opensearch',
        'format': 'json',
        'search': searchTerm,
      },
    );
    final http.Response response = await client.get(url);
    if (response.statusCode == 200) {
      final List<Object?> jsonData = jsonDecode(response.body) as List<Object?>;
      return SearchResults.fromJson(jsonData);
    } else {
      throw HttpException(
        '[WikipediaApiClient.search] '
        'statusCode=${response.statusCode}, '
        'body=${response.body}',
      );
    }
  } on FormatException {
    rethrow;
  } finally {
    client.close();
  }
}

이 코드는 search 함수를 정의해요. http 패키지로 Wikipedia API의 opensearch 엔드포인트에 GET 요청을 보내고 SearchResults 객체를 반환해요. opensearch 엔드포인트는 검색어에 따라 Wikipedia 문서를 검색하는 데 사용돼요.

  • wikipedia/lib/src/api/get_article.dart 파일을 만들어 주세요.

  • wikipedia/lib/src/api/get_article.dart에 다음 코드를 추가해 주세요:

import 'dart:convert';
import 'dart:io';

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

import '../model/article.dart';

Future<List<Article>> getArticleByTitle(String title) async {
  final http.Client client = http.Client();
  try {
    final Uri url = Uri.https(
      'en.wikipedia.org',
      '/w/api.php',
      <String, Object?>{
        // order matters - explaintext must come after prop
        'action': 'query',
        'format': 'json',
        'titles': title.trim(),
        'prop': 'extracts',
        'explaintext': '',
      },
    );
    final http.Response response = await client.get(url);
    if (response.statusCode == 200) {
      final Map<String, Object?> jsonData =
          jsonDecode(response.body) as Map<String, Object?>;
      return Article.listFromJson(jsonData);
    } else {
      throw HttpException(
        '[WikipediaApiClient.getArticleByTitle] '
        'statusCode=${response.statusCode}, '
        'body=${response.body}',
      );
    }
  } on FormatException {
    // TODO: log
    rethrow;
  } finally {
    client.close();
  }
}

이 코드는 getArticleByTitle 함수를 정의해요. http 패키지로 Wikipedia API에 GET 요청을 보내고 List<Article> 객체를 반환해요. 이 함수는 문서 제목에 따라 Wikipedia 문서의 내용을 가져와요.

작업 3: API 함수 export하기 (Export the API functions)

이제 API 함수를 만들었으니, cli 패키지에서 사용할 수 있도록 wikipedia 라이브러리에서 export해야 해요. 기존 모델도 함께 export할 거예요.

  • wikipedia/lib/wikipedia.dart 파일을 열어 주세요.

  • 파일에 다음 export 문을 추가해 주세요:

export 'src/api/get_article.dart';
export 'src/api/search.dart';
export 'src/api/summary.dart';
export 'src/model/article.dart';
export 'src/model/search_results.dart';
export 'src/model/summary.dart';
export 'src/model/title_set.dart';

export 문들은 wikipedia 패키지에 의존하는 다른 패키지들이 API 함수와 모델을 사용할 수 있게 해 줘요.

작업 4: 테스트로 확인하기 (Verify with tests)

이제 API 함수를 구현하고 패키지 의존성을 갱신했으니, 이전 장에서 만든 테스트를 실행해 보는 게 좋아요. 변경 사항이 wikipedia 패키지의 기존 기능을 깨뜨리지 않았는지 확인할 수 있어요.

  • 터미널을 열고 wikipedia/test 디렉터리로 이동해 주세요.

  • rm wikipedia_test.dart(macOS나 Linux) 또는 del wikipedia_test.dart(Windows) 명령을 실행해 기본 테스트 파일을 삭제해 주세요. 이 파일은 자동으로 생성됐지만 우리 프로젝트에서는 쓰이지 않아요.

  • 터미널을 열고 wikipedia 디렉터리로 이동해 주세요.

  • dart test 명령을 실행해 주세요.

기존 테스트가 모두 통과한다는 아래와 같은 출력이 보여야 해요:

00:02 +3: All tests passed!
This confirms that the wikipedia package is still working as expected.

이것은 wikipedia 패키지가 여전히 예상대로 작동한다는 것을 확인해 줘요.

복습 (Review)

배운 내용 (What you accomplished)

이번 강의에서 만들고 배운 내용을 정리해 드릴게요.

  • 쿼리 파라미터가 있는 복잡한 URI 구성하기: 쿼리 파라미터 맵과 함께 Uri.https()를 사용해 API URL을 구성했어요. 이 접근 방식은 특수 문자를 올바르게 인코딩하고 복잡한 쿼리 파라미터 조합을 처리해요.
  • 프로덕션에 바로 쓸 수 있는 API 클라이언트 만들기: tryfinally로 HTTP 클라이언트 수명주기를 제대로 관리하고, 원시 JSON 응답을 타입 있는 Dart 객체로 매핑하는 다양한 함수를 구현했어요.
  • 재사용을 위해 함수와 타입 export하기: wikipedia.dart에 export 문을 추가해 API 함수와 데이터 모델 타입을 모두 노출했어요. 이렇게 하면 cli 패키지가 직접 import해서 사용할 수 있는 공개 라이브러리가 돼요.

퀴즈 (Quiz)

이해도를 확인해 봐요 (Check your understanding)

Q1. Uri.https로 쿼리 파라미터가 있는 URL을 어떻게 구성하나요?

  • 세 번째 인자 맵으로 전달해요: Uri.https('api.com', '/search', {'q': 'dart'}) — 맞아요! 세 번째 파라미터는 쿼리 파라미터의 Map<String, dynamic>을 받아요. Dart가 URL 인코딩을 자동으로 처리해 줘요.
  • 경로 문자열에 붙여요: Uri.https('api.com', '/search?q=dart') — 아니에요. 경로 문자열에 쿼리 파라미터를 넣는 건 작동은 하지만 가장 깔끔한 방법은 아니에요.
  • 문자열 연결을 사용해요: Uri.https('api.com', '/search') + '?q=dart' — 아니에요. +Uri 객체에 문자열을 연결할 수 없어요. Uri는 쿼리 파라미터를 추가하는 다른 메커니즘을 제공해요.
  • Uri 객체에 .addQueryParam('q', 'dart')를 호출해요. — 아니에요. Uri 객체는 불변이고 addQueryParam 메서드가 없어요. 쿼리 파라미터는 다른 방식으로 지정해야 해요.

Q2. client.get(url) 호출 후 요청 성공 여부는 어떻게 확인해야 하나요?

  • response.statusCode == 200(또는 다른 성공 코드)인지 확인해요. — 맞아요! HTTP 상태 코드는 성공(200-299) 또는 다양한 실패(400s, 500s)를 나타내요. 응답 본문을 처리하기 전에 항상 확인해야 해요.
  • response.body가 비어 있지 않은지 확인해요. — 아니에요. 빈 본문이 유효할 수 있어요(예: 204 No Content). 본문 내용만으로 요청이 성공했는지 알 수 없어요.
  • 호출을 try/catch로 감싸요. 성공적인 요청은 던지지 않으니까요. — 아니에요. HTTP 오류(404와 500 같은)는 기본적으로 예외를 던지지 않아요. 서버가 오류를 반환해도 요청은 정상적으로 완료돼요.
  • response.isSuccess가 true를 반환하는지 확인해요. — 아니에요. Response에는 isSuccess 속성이 없어요. 응답 객체의 다른 속성을 살펴봐야 해요.

Q3. 강의의 API 함수들은 finally 블록으로 client.close()를 호출해요. 이것이 왜 중요한가요?

  • 예외가 발생해도 네트워크 리소스가 해제되도록 하기 위해서예요. — 맞아요! finally는 try 블록이 성공하든 던지든 실행돼요. 이렇게 하면 클라이언트 연결이 제대로 닫혀 리소스 누수를 막아 줘요.
  • 함수가 반환되기 전에 응답 데이터를 디스크에 저장하기 위해서예요. — 아니에요. close()는 어디에도 데이터를 저장하지 않아요. 데이터 저장은 명시적인 파일 작업이 필요해요.
  • 서버에 '커넥션 닫힘' 메시지를 보내기 위해서예요. — 아니에요. 닫기는 연결에 영향을 주긴 하지만, 주 목적은 서버와 통신하는 게 아니라 클라이언트 쪽 정리(cleanup)예요.
  • 메모리에서 응답을 지우고 가비지 컬렉션을 촉발하기 위해서예요. — 아니에요. Dart의 가비지 컬렉터는 메모리를 자동으로 관리해요. close()는 다른 종류의 리소스를 해제해요.

더 알아보기