디자인 패턴은 소프트웨어 디자인의 일반적인 문제를 해결하는 데 도움이 되는 유용한 템플릿입니다.
앱 아키텍처의 경우 구조적 디자인 패턴은 앱의 다양한 부분이 구성되는 방식을 결정하는 데 도움이 될 수 있습니다.
이러한 맥락에서 우리는 리포지토리 패턴을 사용하여 백엔드 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
}
}
이러한 모든 구현 세부 사항은 데이터 영역 의 문제 이며 앱의 나머지 부분은 이에 대해 신경쓰거나 알 필요도 없습니다.
물론 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
'[====== Development ======] > Flutter' 카테고리의 다른 글
showModalBottomSheet 안의 TextField 가 키보드에 가려지지 않게 하는 방법 (0) | 2024.06.12 |
---|---|
[Flutter] Json Serialization (0) | 2024.03.03 |
[Flutter] MVVM 아키텍처 (0) | 2024.03.02 |
[Flutter] Visual Studio Code 설정 (0) | 2024.03.01 |
Dart 버전 업그레이드 및 Pub 버전 업그레이드 후 Firebase 관련 문제로 빌드 오류 발생시 대응 방법 (1) | 2024.02.11 |