반응형

 

디자인 패턴은 소프트웨어 디자인의 일반적인 문제를 해결하는 데 도움이 되는 유용한 템플릿입니다.

앱 아키텍처의 경우 구조적 디자인 패턴은 앱의 다양한 부분이 구성되는 방식을 결정하는 데 도움이 될 수 있습니다.

이러한 맥락에서 우리는 리포지토리 패턴을 사용하여 백엔드 API와 같은 다양한 소스의 데이터 개체 에 액세스하고 이를 앱의 도메인 계층 (비즈니스 논리가 있는 곳) 에 대해 유형이 안전한 엔터티 로 사용할 수 있도록 할 수 있습니다.

이 기사에서는 저장소 패턴에 대해 자세히 알아봅니다.

  • 그것이 무엇이며 언제 사용해야 하는가?
  • 몇 가지 실용적인 예
  • 구체적 또는 추상 클래스와 그 장단점을 사용한 구현 세부 사항
  • 리포지토리로 코드를 테스트하는 방법

그리고 완전한 소스 코드가 포함된 날씨 앱 예시도 공유하겠습니다.

준비가 된뛰어들어보자!

저장소 디자인 패턴은 무엇입니까?

이를 이해하기 위해 다음 아키텍처 다이어그램을 고려해 보겠습니다.

컨트롤러, 서비스, 저장소를 사용하는 Flutter 앱 아키텍처

이러한 맥락에서 저장소는 데이터 영역 에서 발견됩니다그리고 그들의 임무는 다음과 같습니다:

  • 데이터 계층 에 있는 데이터 소스의 구현 세부정보에서 도메인 모델(또는 엔터티 )을 분리합니다 .
  • 데이터 전송 객체를 도메인 계층 에서 이해할 수 있는 검증된 엔터티로 변환
  • (선택적으로) 데이터 캐싱 과 같은 작업을 수행합니다 .

위의 다이어그램은 앱을 설계하는 여러 가지 가능한 방법 중 하나를 보여줍니다. MVC, MVVM 또는 Clean Architecture와 같은 다른 아키텍처를 따르는 경우 상황이 다르게 보이지만 동일한 개념이 적용됩니다.

또한 위젯이 비즈니스 로직이나 네트워킹 코드와 아무 관련이 없는 프레젠테이션 레이어 에 어떻게 속하는지 확인하세요.

위젯이 REST API 또는 원격 데이터베이스의 키-값 쌍을 사용하여 직접 작동하는 경우 잘못된 작업을 수행하고 있는 것입니다 . 비즈니스 로직을 UI 코드와 혼합하지 마십시오 . 이렇게 하면 코드를 테스트하고 디버그하고 추론하기가 훨씬 더 어려워집니다.

언제 저장소 패턴을 사용합니까?

리포지토리 패턴은 앱의 나머지 부분과 격리하려는 구조화되지 않은 데이터 (: JSON)를 반환하는 다양한 엔드포인트가 포함된 복잡한 데이터 계층이 앱에 있는 경우 매우 유용합니다.

더 광범위하게 말하면 저장소 패턴이 가장 적절하다고 생각하는 몇 가지 사용 사례는 다음과 같습니다.

  • REST API와 대화하기
  • 로컬 또는 원격 데이터베이스(: Sembast, Hive, Firestore )와 통신
  • 기기별 API(: 권한, 카메라, 위치 등)와 통신

이 접근 방식의 가장 큰 이점 중 하나 는 사용하는 타사 API에 주요 변경 사항이 있는 경우 저장소 코드만 업데이트하면 된다는 것입니다 .

그리고 그것만으로도 저장소의 가치는 100%가 됩니다💯

그럼 어떻게 사용하는지 살펴보겠습니다🚀

실제 저장소 패턴

예를 들어, 저는 OpenWeatherMap API 에서 날씨 데이터를 가져오는 간단한 Flutter 소스 코드는 다음과 같습니다)을 구축했습니다 .

API 문서를 읽으면 JSON 형식의 응답 데이터에 대한 몇 가지 예와 함께 API를 호출하는 방법을 알아볼 수 있습니다.

그리고 리포지토리 패턴은 모든 네트워킹 및 JSON 직렬화 코드를 추상화하는 데 적합합니다.

예를 들어, 저장소의 인터페이스를 정의하는 추상 클래스는 다음과 같습니다 .

 

abstract class WeatherRepository {
  Future<Weather> getWeather({required String city});
}

 

위 의 WeatherRepository방법에는 하나의 방법만 있지만 더 많은 방법이 있을 수 있습니다(예를 들어 모든 CRUD 작업을 지원하려는 경우).

중요한 것은 저장소를 통해 특정 도시의 날씨를 검색하는 방법에 대한 계약을 정의 할 수 있다는 것입니다.

그리고 http 또는 dio 와 같은 네트워킹 클라이언트를 사용하여 필요한 API 호출을 수행하는 구체적인 클래스를 구현 해야 합니다 .

 

WeatherRepository

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

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});

  // custom class defining all the API details
  final OpenWeatherMapAPI api;

  // client for making calls to the API
  final http.Client client;

  // implements the method in the abstract class
  Future<Weather> getWeather({required String city}) {
    // TODO: send request, parse response, return Weather object or throw error
  }
}

 

이러한 모든 구현 세부 사항은 데이터 영역 의 문제 이며 앱의 나머지 부분은 이에 대해 신경쓰거나 알 필요도 없습니다.

JSON 데이터 구문 분석

물론 API 응답 데이터를 구문 분석하기 위한 JSON 직렬화 코드와 함께 Weather모델 클래스(또는 엔터티 )도 정의해야 합니다.

class Weather {
  // TODO: declare all the properties we need
  factory Weather.fromJson(Map<String, dynamic> json) {
    // TODO: parse JSON and return validated Weather object
  }
}

JSON 응답에는 다양한 필드가 포함될 수 있지만 UI에서 사용될 필드 만 구문 분석하면 됩니다.

JSON 구문 분석 코드를 직접 작성하거나 Freezed 와 같은 코드 생성 패키지를 사용할 수 있습니다 . JSON 직렬화에 대해 자세히 알아보려면 Dart의 JSON 구문 분석에 대한 필수 가이드를 참조하세요 .

앱에서 저장소 초기화

저장소를 정의한 후에는 이를 초기화하고 앱의 나머지 부분에서 액세스할 수 있도록 하는 방법이 필요합니다.

이를 수행하기 위한 구문은 선택한 DI/상태 관리 솔루션에 따라 달라집니다.

get_it 사용한 예는 다음과 같습니다 .

import 'package:get_it/get_it.dart';

GetIt.instance.registerLazySingleton<WeatherRepository>(
  () => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client(),
);

 

Riverpod 패키지 의 공급자를 사용하는 또 다른 방법은 다음과 같습니다 .

import 'package:flutter_riverpod/flutter_riverpod.dart';

final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
  return HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client());
});

 

flutter_bloc 패키지를 사용하는 경우 이에 상응하는 내용은 다음과 같습니다 .

import 'package:flutter_bloc/flutter_bloc.dart';

RepositoryProvider<WeatherRepository>(
  create: (_) => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client()),
  child: MyApp(),
))

 

결론은 동일합니다. 저장소를 초기화한 후에는 앱의 다른 곳(위젯, 블록, 컨트롤러 등)에서 해당 저장소에 액세스할 수 있습니다.

추상적이거나 구체적인 수업?

리포지토리를 생성할 때 흔히 묻는 질문 중 하나는 다음과 같습니다정말로 추상 클래스가 필요한가요 ? 아니면 구체적인 클래스를 생성하고 모든 의식을 없앨 수 있습니까?

두 클래스에 걸쳐 점점 더 많은 메서드를 추가하는 것은 매우 지루해질 수 있으므로 이는 매우 유효한 문제입니다.

abstract class WeatherRepository {
  Future<Weather> getWeather({required String city});
  Future<Forecast> getHourlyForecast({required String city});
  Future<Forecast> getDailyForecast({required String city});
  // and so on
}

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});
  // custom class defining all the API details
  final OpenWeatherMapAPI api;
  // client for making calls to the API
  final http.Client client;

  Future<Weather> getWeather({required String city}) { ... }
  Future<Forecast> getHourlyForecast({required String city}) { ... }
  Future<Forecast> getDailyForecast({required String city}) { ... }
  // and so on
}

 

소프트웨어 설계에서 종종 그렇듯이 대답은 '상황에 따라 다릅니다 '입니다 .

그럼 각 접근 방식의 장단점을 살펴보겠습니다.

추상 클래스 사용

  • 장점 : 복잡하지 않고 한 곳에서 저장소의 인터페이스를 볼 수 있다는 점이 좋습니다.
  • 장점 : 저장소를 완전히 다른 구현(:  DioWeatherRepository아닌 HttpWeatherRepository)으로 교체하고 초기화 코드에서 한 줄만 변경할 수 있습니다. 앱의 나머지 부분은 에 대해서만 알고 있기 때문입니다 WeatherRepository.
  • 단점 : VSCode "참조로 점프"하여 구체적인 클래스의 구현이 아닌 추상 클래스의 메서드 정의로 이동하면 약간 혼란스러워집니다.
  • 단점 : 상용구 코드가 더 많아졌습니다.

구체적인 클래스만 사용

  • 장점 : 상용구 코드가 적습니다.
  • 장점 : "참조로 이동"은 저장소 메소드가 하나의 클래스에서만 발견되므로 작동합니다.
  • 단점 : 저장소 이름을 변경하면 다른 구현으로 교체하려면 더 많은 변경이 필요합니다(VSCode를 사용하면 전체 프로젝트에서 이름을 바꾸는 것이 쉽지만).

어떤 접근 방식을 사용할지 결정할 때 코드에 대한 테스트를 작성하는 방법도 알아내야 합니다.

저장소를 사용하여 테스트 작성

테스트 중 일반적인 요구 사항 중 하나는 테스트가 더 빠르고 안정적으로 실행되도록 네트워킹 코드를 모의 또는 "가짜" 코드로 교체하는 것입니다.

그러나 추상 클래스는 여기서 우리에게 어떤 이점도 주지 않습니다. 왜냐하면 Dart에서는 모든 클래스가 암시적 인터페이스를 갖기 때문입니다 .

이는 우리가 다음을 수행할 수 있음을 의미합니다.

// note: in Dart we can always implement a concrete class
class FakeWeatherRepository implements HttpWeatherRepository {
  // just a fake implementation that returns a value immediately
  Future<Weather> getWeather({required String city}) {
    return Future.value(Weather(...));
  }
}

 

테스트에서 리포지토리를 모의하려는 경우 추상 클래스를 만들 필요가 없습니다 .

실제로 목테일 과 같은 패키지는 이를 활용하여 다음과 같이 사용할 수 있습니다.

import 'package:mocktail/mocktail.dart';

class MockWeatherRepository extends Mock implements HttpWeatherRepository {}

final mockWeatherRepository = MockWeatherRepository();
when(() => mockWeatherRepository.getWeather('London'))
          .thenAnswer((_) => Future.value(Weather(...)));

 

데이터 소스 모의

테스트를 작성할 때 위에서 했던 것처럼 리포지토리를 모의하고 미리 준비된 응답을 반환할 수 있습니다.

하지만 또 다른 옵션이 있는데, 그것은 기본 데이터 소스를 모의하는 것입니다 .

가 어떻게 정의되었는지 기억해 봅시다 

 

HttpWeatherRepository:

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

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});

  // custom class defining all the API details
  final OpenWeatherMapAPI api;

  // client for making calls to the API
  final http.Client client;

  // implements the method in the abstract class
  Future<Weather> getWeather({required String city}) {
    // TODO: send request, parse response, return Weather object or throw error
  }
}

 

이 경우 생성자 http.Client에 전달된 객체를 모의하도록 선택할 수 있습니다 HttpWeatherRepository. 다음은 이를 수행하는 방법을 보여주는 테스트 예시입니다.

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

class MockHttpClient extends Mock implements http.Client {}

void main() {
  test('repository with mocked http client', () async {

    // setup
    final mockHttpClient = MockHttpClient();
    final api = OpenWeatherMapAPI();
    final weatherRepository =
        HttpWeatherRepository(api: api, client: mockHttpClient);

    when(() => mockHttpClient.get(api.weather('London')))
        .thenAnswer((_) => Future.value(/* some valid http.Response */));

    // run
    final weather = await weatherRepository.getWeather(city: 'London');

    // verify
    expect(weather, Weather(...));
  });
}

 

결국테스트하려는 항목에 따라 저장소 자체를 모의 할지 아니면 기본 데이터 소스를 모의할지 선택할 수 있습니다.

리포지토리를 테스트하는 방법을 알아냈으니 이제 추상 클래스에 대한 초기 질문으로 돌아가겠습니다.

저장소에는 추상 클래스가 필요하지 않을 있습니다.

일반적으로 동일한 인터페이스를 준수하는 구현이 많이 필요한 경우 추상 클래스를 만드는 것이 합리적입니다.

예를 들어,  StatelessWidget는 모두 하위 클래스로 분류 되기 때문에 Flutter SDK 추상 클래스StatefulWidget 입니다 .

그러나 리포지토리로 작업할 때는 특정 리포지토리에 대해 하나의 구현만 필요할 수 있습니다.

주어진 저장소에 대해 하나의 구현만 필요할 가능성이 있으며 이를 하나의 구체적인 클래스로 정의할 수 있습니다.

최소 공통 분모

인터페이스 뒤에 모든 것을 배치하면 서로 다른 기능을 가진 API 사이에서 가장 낮은 공통 분모를 선택하게 될 수도 있습니다.

하나의 API 또는 백엔드가 스트림 기반 API 로 모델링할 수 있는 실시간 업데이트를 지원할 수도 있습니다 .

그러나 웹소켓 없이 순수 REST를 사용하는 경우에는 요청을 보내고 단일 응답만 받을 수 있으며 이는 미래 기반 API로 가장 잘 모델링됩니다.

이를 처리하는 것은 매우 쉽습니다. 스트림 기반 API를 사용하고 REST를 사용하는 경우 하나의 값으로 스트림을 반환하기만 하면 됩니다.


그러나 때로는 더 광범위한 API 차이가 있습니다.

예를 들어 Firestore 트랜잭션과 일괄 쓰기를 지원합니다이러한 종류의 API 일반 인터페이스 뒤에서 쉽게 추상화되지 않는 방식으로 내부적으로 빌더 패턴을 사용합니다.

그리고 다른 백엔드로 마이그레이션하는 경우 새 API가 상당히 달라질 가능성이 있습니다현재 API를 미래에 대비하는 것은 종종 비실용적이며 비생산적입니다 .

리포지토리는 수평으로 확장됩니다.

애플리케이션이 성장함에 따라 특정 저장소에 점점 더 많은 메소드를 추가하게 될 수도 있습니다.

이는 백엔드에 대규모 API 노출 영역이 있거나 앱이 다양한 데이터 소스에 연결되는 경우 발생할 가능성이 높습니다.

이 시나리오에서는 관련 메서드를 함께 유지하면서 여러 리포지토리를 만드는 것을 고려하세요예를 들어 전자상거래 앱을 구축하는 경우 제품 목록, 장바구니, 주문 관리, 인증, 체크아웃 등을 위한 별도의 저장소를 가질 수 있습니다.

단순하게 유지하세요

늘 그렇듯이, 일을 단순하게 유지하는 것은 항상 좋은 생각입니다따라서 API에 대해 지나치게 생각하지 마십시오.

사용해야 하는 API 이후에 저장소의 인터페이스를 모델링하고 하루에 호출할 수 있습니다필요한 경우 나중에 언제든지 리팩터링할 수 있습니다👍

결론

이 글을 통해 꼭 전해드리고 싶은 한 가지가 있다면 다음과 같습니다.

데이터 계층의 모든 구현 세부정보(: JSON 직렬화)를 숨기려면 저장소 패턴을 사용하세요결과적으로 앱의 나머지 부분(도메인 및 프레젠테이션 계층) 유형이 안전한 모델 클래스/엔티티를 직접 처리할 수 있습니다또한 귀하의 코드베이스는 귀하가 의존하는 패키지의 주요 변경 사항에 대한 탄력성이 더욱 높아질 것입니다.

오히려 이 개요를 통해 앱 아키텍처와 명확한 경계가 있는 별도의 프레젠테이션 , 애플리케이션 , 도메인  데이터 계층을 갖는 것의 중요성에 대해 더 명확하게 생각하는 데 도움이 되기를 바랍니다.

 

 

내용 출처 :

https://codewithandrea.com/articles/flutter-repository-pattern/

 

Flutter App Architecture: The Repository Pattern

An in-depth overview of the repository pattern in Flutter: what it is, when to use it, and various implementation strategies along with their tradeoffs.

codewithandrea.com

https://github.com/bizz84/open_weather_example_flutter

 

GitHub - bizz84/open_weather_example_flutter: Flutter Weather App Example using the OpenWeatherMap API

Flutter Weather App Example using the OpenWeatherMap API - bizz84/open_weather_example_flutter

github.com

 

반응형

+ Recent posts