GitHub 검색
이번 튜토리얼에서는 Flutter와 AngularDart에서 GitHub 검색 앱을 만들어 두 프로젝트 간에 데이터 레이어와 비즈니스 로직 레이어를 어떻게 공유할 수 있는지 보여줍니다.


주요 주제
섹션 제목: “주요 주제”- BlocProvider, 자식 위젯에 bloc을 제공하는 Flutter 위젯.
- BlocBuilder, 새로운 상태에 따라 위젯을 빌드하는 Flutter 위젯.
- Bloc 대신 Cubit 사용하기. 차이점은 무엇인가요?
- Equatable을 사용하여 불필요한 리빌드 방지.
bloc_concurrency와 함께 커스텀EventTransformer사용.http패키지를 사용한 네트워크 요청.
Common GitHub Search 라이브러리
섹션 제목: “Common GitHub Search 라이브러리”Common GitHub Search 라이브러리는 AngularDart와 Flutter 간에 공유될 모델, 데이터 프로바이더, 리포지토리, 그리고 bloc을 포함합니다.
먼저 애플리케이션을 위한 새 디렉토리를 만듭니다.
mkdir -p github_search/common_github_search필요한 의존성이 담긴 pubspec.yaml을 생성해야 합니다.
name: common_github_searchdescription: Shared Code between AngularDart and Flutterversion: 1.0.0+1publish_to: none
environment: sdk: ">=3.10.0 <4.0.0"
dependencies: bloc: ^9.0.0 equatable: ^2.0.0 http: ^1.0.0 stream_transform: ^2.0.0dev_dependencies: bloc_lint: ^0.3.0마지막으로 의존성을 설치합니다.
dart pub get프로젝트 설정이 완료되었습니다! 이제 common_github_search 패키지를 만들어
봅시다.
Github 클라이언트
섹션 제목: “Github 클라이언트”GithubClient는 GitHub API에서 원시
데이터를 제공합니다.
github_client.dart를 생성합니다.
import 'dart:async';import 'dart:convert';
import 'package:common_github_search/common_github_search.dart';import 'package:http/http.dart' as http;
class GithubClient { GithubClient({ http.Client? httpClient, this.baseUrl = 'https://api.github.com/search/repositories?q=', }) : _httpClient = httpClient ?? http.Client();
final String baseUrl; final http.Client _httpClient;
Future<SearchResult> search(String term) async { final response = await _httpClient.get(Uri.parse('$baseUrl$term')); final results = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 200) { return SearchResult.fromJson(results); } else { throw SearchResultError.fromJson(results); } }
void close() { _httpClient.close(); }}다음으로 SearchResult와 SearchResultError 모델을 정의해야 합니다.
Search Result 모델
섹션 제목: “Search Result 모델”사용자의 쿼리를 기반으로 한 SearchResultItems 리스트를 나타내는
search_result.dart를 생성합니다:
import 'package:common_github_search/common_github_search.dart';
class SearchResult { const SearchResult({required this.items});
factory SearchResult.fromJson(Map<String, dynamic> json) { final items = (json['items'] as List<dynamic>) .map( (dynamic item) => SearchResultItem.fromJson(item as Map<String, dynamic>), ) .toList(); return SearchResult(items: items); }
final List<SearchResultItem> items;}Search Result Item 모델
섹션 제목: “Search Result Item 모델”다음으로 search_result_item.dart를 생성합니다.
import 'package:common_github_search/common_github_search.dart';
class SearchResultItem { const SearchResultItem({ required this.fullName, required this.htmlUrl, required this.owner, });
factory SearchResultItem.fromJson(Map<String, dynamic> json) { return SearchResultItem( fullName: json['full_name'] as String, htmlUrl: json['html_url'] as String, owner: GithubUser.fromJson(json['owner'] as Map<String, dynamic>), ); }
final String fullName; final String htmlUrl; final GithubUser owner;}GitHub User 모델
섹션 제목: “GitHub User 모델”다음으로 github_user.dart를 생성합니다.
class GithubUser { const GithubUser({ required this.login, required this.avatarUrl, });
factory GithubUser.fromJson(Map<String, dynamic> json) { return GithubUser( login: json['login'] as String, avatarUrl: json['avatar_url'] as String, ); }
final String login; final String avatarUrl;}여기까지 SearchResult와 그 의존성 구현을 완료했습니다. 이제
SearchResultError로 넘어갑니다.
Search Result Error 모델
섹션 제목: “Search Result Error 모델”search_result_error.dart를 생성합니다.
class SearchResultError implements Exception { SearchResultError({required this.message});
factory SearchResultError.fromJson(Map<String, dynamic> json) { return SearchResultError( message: json['message'] as String, ); }
final String message;}GithubClient가 완성되었으니 다음으로 성능 최적화를 위해
메모이제이션을 담당할
GithubCache로 넘어갑니다.
GitHub Cache
섹션 제목: “GitHub Cache”GithubCache는 모든 이전 쿼리를 기억하여 GitHub API에 불필요한 네트워크 요청을
피하는 역할을 합니다. 이를 통해 애플리케이션의 성능도 향상됩니다.
github_cache.dart를 생성합니다.
import 'package:common_github_search/common_github_search.dart';
class GithubCache { final _cache = <String, SearchResult>{};
SearchResult? get(String term) => _cache[term];
void set(String term, SearchResult result) => _cache[term] = result;
bool contains(String term) => _cache.containsKey(term);
void remove(String term) => _cache.remove(term);
void close() { _cache.clear(); }}이제 GithubRepository를 만들 준비가 되었습니다!
GitHub Repository
섹션 제목: “GitHub Repository”Github Repository는 데이터 레이어(GithubClient)와 비즈니스 로직 레이어(Bloc)
사이에 추상화를 만드는 역할을 합니다. 여기서 GithubCache도 사용하게 됩니다.
github_repository.dart를 생성합니다.
import 'dart:async';
import 'package:common_github_search/common_github_search.dart';
class GithubRepository { GithubRepository({GithubCache? cache, GithubClient? client}) : _cache = cache ?? GithubCache(), _client = client ?? GithubClient();
final GithubCache _cache; final GithubClient _client;
Future<SearchResult> search(String term) async { final cachedResult = _cache.get(term); if (cachedResult != null) { return cachedResult; } final result = await _client.search(term); _cache.set(term, result); return result; }
void dispose() { _cache.close(); _client.close(); }}여기까지 데이터 프로바이더 레이어와 리포지토리 레이어를 완성했으니 이제 비즈니스 로직 레이어로 넘어갑니다.
GitHub Search 이벤트
섹션 제목: “GitHub Search 이벤트”Bloc은 사용자가 리포지토리 이름을 입력하면 알림을 받게 되며, 이를 TextChanged
GithubSearchEvent로 나타냅니다.
github_search_event.dart를 생성합니다.
import 'package:equatable/equatable.dart';
sealed class GithubSearchEvent extends Equatable { const GithubSearchEvent();}
final class TextChanged extends GithubSearchEvent { const TextChanged({required this.text});
final String text;
@override List<Object> get props => [text];
@override String toString() => 'TextChanged { text: $text }';}Github Search 상태
섹션 제목: “Github Search 상태”프레젠테이션 레이어가 적절하게 표시되려면 여러 정보가 필요합니다:
-
SearchStateEmpty- 사용자가 아무 입력도 하지 않았음을 프레젠테이션 레이어에 알립니다. -
SearchStateLoading- 로딩 인디케이터를 표시해야 함을 프레젠테이션 레이어에 알립니다. -
SearchStateSuccess- 표시할 데이터가 있음을 프레젠테이션 레이어에 알립니다.items- 표시될List<SearchResultItem>입니다.
-
SearchStateError- 리포지토리를 가져오는 중 에러가 발생했음을 프레젠테이션 레이어에 알립니다.error- 발생한 정확한 에러입니다.
이제 github_search_state.dart를 생성하고 다음과 같이 구현합니다.
import 'package:common_github_search/common_github_search.dart';import 'package:equatable/equatable.dart';
sealed class GithubSearchState extends Equatable { const GithubSearchState();
@override List<Object> get props => [];}
final class SearchStateEmpty extends GithubSearchState {}
final class SearchStateLoading extends GithubSearchState {}
final class SearchStateSuccess extends GithubSearchState { const SearchStateSuccess(this.items);
final List<SearchResultItem> items;
@override List<Object> get props => [items];
@override String toString() => 'SearchStateSuccess { items: ${items.length} }';}
final class SearchStateError extends GithubSearchState { const SearchStateError(this.error);
final String error;
@override List<Object> get props => [error];}이벤트와 상태가 구현되었으니 이제 GithubSearchBloc을 만들 수 있습니다.
GitHub Search Bloc
섹션 제목: “GitHub Search Bloc”github_search_bloc.dart를 생성합니다:
import 'package:bloc/bloc.dart';import 'package:common_github_search/common_github_search.dart';import 'package:stream_transform/stream_transform.dart';
const _duration = Duration(milliseconds: 300);
EventTransformer<Event> debounce<Event>(Duration duration) { return (events, mapper) => events.debounce(duration).switchMap(mapper);}
class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> { GithubSearchBloc({required GithubRepository githubRepository}) : _githubRepository = githubRepository, super(SearchStateEmpty()) { on<TextChanged>(_onTextChanged, transformer: debounce(_duration)); }
final GithubRepository _githubRepository;
Future<void> _onTextChanged( TextChanged event, Emitter<GithubSearchState> emit, ) async { final searchTerm = event.text;
if (searchTerm.isEmpty) return emit(SearchStateEmpty());
emit(SearchStateLoading());
try { final results = await _githubRepository.search(searchTerm); emit(SearchStateSuccess(results.items)); } catch (error) { emit( error is SearchResultError ? SearchStateError(error.message) : const SearchStateError('something went wrong'), ); } }}common_github_search 패키지가 완성되었습니다. 최종 결과물은
여기에서
확인할 수 있습니다.
다음으로 Flutter 구현을 진행합니다.
Flutter GitHub Search
섹션 제목: “Flutter GitHub Search”Flutter Github Search는 common_github_search의 모델, 데이터 프로바이더,
리포지토리, bloc을 재사용하여 Github Search를 구현하는 Flutter
애플리케이션입니다.
github_search 디렉토리 내에서 common_github_search와 같은 레벨에 새 Flutter
프로젝트를 생성합니다.
flutter create flutter_github_search다음으로 필요한 모든 의존성을 포함하도록 pubspec.yaml을 업데이트합니다.
name: flutter_github_searchdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.10.0 <4.0.0"
dependencies: bloc: ^9.0.0 common_github_search: path: ../common_github_search flutter: sdk: flutter flutter_bloc: ^9.0.1 url_launcher: ^6.0.0
flutter: uses-material-design: truedev_dependencies: bloc_lint: ^0.3.0이제 의존성을 설치합니다.
flutter pub get프로젝트 설정이 완료되었습니다. common_github_search 패키지가 데이터 레이어와
비즈니스 로직 레이어를 포함하고 있으므로 프레젠테이션 레이어만 구현하면 됩니다.
Search Form
섹션 제목: “Search Form”_SearchBar와 _SearchBody 위젯이 있는 폼을 만들어야 합니다.
_SearchBar는 사용자 입력을 받는 역할을 합니다._SearchBody는 검색 결과, 로딩 인디케이터, 에러를 표시하는 역할을 합니다.
search_form.dart를 생성합니다.
SearchForm은 _SearchBar와 _SearchBody 위젯을 렌더링하는
StatelessWidget입니다.
_SearchBar도 StatefulWidget인데, 사용자가 입력한 내용을 추적하기 위해 자체
TextEditingController를 유지해야 하기 때문입니다.
_SearchBody는 검색 결과, 에러, 로딩 인디케이터를 표시하는
StatelessWidget입니다. GithubSearchBloc의 소비자가 됩니다.
상태가 SearchStateSuccess이면 다음에 구현할 _SearchResults를 렌더링합니다.
_SearchResults는 List<SearchResultItem>을 받아 _SearchResultItems 리스트로
표시하는 StatelessWidget입니다.
_SearchResultItem은 단일 검색 결과의 정보를 렌더링하는
StatelessWidget입니다. 사용자 상호작용을 처리하고 탭 시 리포지토리 URL로
이동하는 역할도 합니다.
import 'package:common_github_search/common_github_search.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:url_launcher/url_launcher.dart';
class SearchForm extends StatelessWidget { const SearchForm({super.key});
@override Widget build(BuildContext context) { return Column( children: <Widget>[ _SearchBar(), _SearchBody(), ], ); }}
class _SearchBar extends StatefulWidget { @override State<_SearchBar> createState() => _SearchBarState();}
class _SearchBarState extends State<_SearchBar> { final _textController = TextEditingController(); late GithubSearchBloc _githubSearchBloc;
@override void initState() { super.initState(); _githubSearchBloc = context.read<GithubSearchBloc>(); }
@override void dispose() { _textController.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return TextField( controller: _textController, autocorrect: false, onChanged: (text) { _githubSearchBloc.add( TextChanged(text: text), ); }, decoration: InputDecoration( prefixIcon: const Icon(Icons.search), suffixIcon: GestureDetector( onTap: _onClearTapped, child: const Icon(Icons.clear), ), border: InputBorder.none, hintText: 'Enter a search term', ), ); }
void _onClearTapped() { _textController.text = ''; _githubSearchBloc.add(const TextChanged(text: '')); }}
class _SearchBody extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<GithubSearchBloc, GithubSearchState>( builder: (context, state) { return switch (state) { SearchStateEmpty() => const Text('Please enter a term to begin'), SearchStateLoading() => const CircularProgressIndicator.adaptive(), SearchStateError() => Text(state.error), SearchStateSuccess() => state.items.isEmpty ? const Text('No Results') : Expanded(child: _SearchResults(items: state.items)), }; }, ); }}
class _SearchResults extends StatelessWidget { const _SearchResults({required this.items});
final List<SearchResultItem> items;
@override Widget build(BuildContext context) { return ListView.builder( itemCount: items.length, itemBuilder: (BuildContext context, int index) { return _SearchResultItem(item: items[index]); }, ); }}
class _SearchResultItem extends StatelessWidget { const _SearchResultItem({required this.item});
final SearchResultItem item;
@override Widget build(BuildContext context) { return ListTile( leading: CircleAvatar( child: Image.network(item.owner.avatarUrl), ), title: Text(item.fullName), onTap: () => launchUrl(Uri.parse(item.htmlUrl)), ); }}통합하기
섹션 제목: “통합하기”이제 main.dart에서 메인 앱을 구현하기만 하면 됩니다.
import 'package:common_github_search/common_github_search.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_github_search/search_form.dart';
void main() => runApp(const App());
class App extends StatelessWidget { const App({super.key});
@override Widget build(BuildContext context) { return RepositoryProvider( create: (_) => GithubRepository(), dispose: (repository) => repository.dispose(), child: MaterialApp( title: 'GitHub Search', home: Scaffold( appBar: AppBar(title: const Text('GitHub Search')), body: BlocProvider( create: (context) => GithubSearchBloc( githubRepository: context.read<GithubRepository>(), ), child: const SearchForm(), ), ), ), ); }}이것이 전부입니다! bloc과 flutter_bloc 패키지를 사용하여 Flutter에서 GitHub 검색 앱을 성공적으로 구현했고, 프레젠테이션 레이어와 비즈니스 로직을 성공적으로 분리했습니다.
전체 소스 코드는 여기에서 확인할 수 있습니다.
마지막으로 AngularDart GitHub Search 앱을 만들어 봅시다.
AngularDart GitHub Search
섹션 제목: “AngularDart GitHub Search”AngularDart GitHub Search는 common_github_search의 모델, 데이터 프로바이더,
리포지토리, bloc을 재사용하여 Github Search를 구현하는 AngularDart
애플리케이션입니다.
common_github_search와 같은 레벨의 github_search 디렉토리에 새 AngularDart
프로젝트를 생성합니다.
stagehand web-angular그런 다음 pubspec.yaml의 내용을 다음으로 교체합니다:
name: angular_github_searchdescription: A web app that uses AngularDart Components
environment: sdk: ">=3.10.0 <4.0.0"
dependencies: angular_bloc: ^10.0.0-dev.5 bloc: ^9.0.0 common_github_search: path: ../common_github_search ngdart: ^8.0.0-dev.4
dev_dependencies: build_daemon: ^4.0.0 build_runner: ^2.0.0 build_web_compilers: ^4.0.0Search Form
섹션 제목: “Search Form”Flutter 앱과 마찬가지로 SearchBar와 SearchBody 컴포넌트가 있는
SearchForm을 만들어야 합니다.
SearchForm 컴포넌트는 GithubSearchBloc을 생성하고 닫아야 하므로 OnInit과
OnDestroy를 구현합니다.
SearchBar는 사용자 입력을 받는 역할을 합니다.SearchBody는 검색 결과, 로딩 인디케이터, 에러를 표시하는 역할을 합니다.
search_form_component.dart를 생성합니다.
import 'package:angular_bloc/angular_bloc.dart';import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-form', templateUrl: 'search_form_component.html', directives: [ SearchBarComponent, SearchBodyComponent, ], pipes: [BlocPipe],)class SearchFormComponent implements OnInit, OnDestroy { @Input() late GithubRepository githubRepository;
late GithubSearchBloc githubSearchBloc;
@override void ngOnInit() { githubSearchBloc = GithubSearchBloc( githubRepository: githubRepository, ); }
@override void ngOnDestroy() { githubSearchBloc.close(); }}템플릿(search_form_component.html)은 다음과 같습니다:
<div> <h1>GitHub Search</h1> <search-bar [githubSearchBloc]="githubSearchBloc"></search-bar> <search-body [state]="$pipe.bloc(githubSearchBloc)"></search-body></div>다음으로 SearchBar 컴포넌트를 구현합니다.
Search Bar
섹션 제목: “Search Bar”SearchBar는 사용자 입력을 받고 GithubSearchBloc에 텍스트 변경을 알리는
컴포넌트입니다.
search_bar_component.dart를 생성합니다.
import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-bar', templateUrl: 'search_bar_component.html',)class SearchBarComponent { @Input() late GithubSearchBloc githubSearchBloc;
void onTextChanged(String text) { githubSearchBloc.add(TextChanged(text: text)); }}다음으로 search_bar_component.html을 생성합니다.
<label for="term" class="clip">Enter a search term</label><input id="term" placeholder="Enter a search term" class="input-reset outline-transparent glow o-50 bg-near-black near-white w-100 pv2 border-box b--white-50 br-0 bl-0 bt-0 bb-ridge mb3" autofocus (keyup)="onTextChanged($event.target.value)"/>SearchBar가 완성되었습니다. 이제 SearchBody로 넘어갑니다.
Search Body
섹션 제목: “Search Body”SearchBody는 검색 결과, 에러, 로딩 인디케이터를 표시하는 컴포넌트입니다.
GithubSearchBloc의 소비자가 됩니다.
search_body_component.dart를 생성합니다.
import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-body', templateUrl: 'search_body_component.html', directives: [ coreDirectives, SearchResultsComponent, ],)class SearchBodyComponent { @Input() late GithubSearchState state;
bool get isEmpty => state is SearchStateEmpty; bool get isLoading => state is SearchStateLoading; bool get isSuccess => state is SearchStateSuccess; bool get isError => state is SearchStateError;
List<SearchResultItem> get items => isSuccess ? (state as SearchStateSuccess).items : [];
String get error => isError ? (state as SearchStateError).error : '';}search_body_component.html을 생성합니다.
<div *ngIf="state != null" class="mw10"> <div *ngIf="isEmpty" class="tc"> <span>🔍</span> <p>Please enter a term to begin</p> </div> <div *ngIf="isLoading"> <div class="sk-chase center"> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> </div> </div> <div *ngIf="isError" class="tc"> <span>‼️</span> <p>{{ error }}</p> </div> <div *ngIf="isSuccess"> <div *ngIf="items.length == 0" class="tc"> <span>⚠️</span> <p>No Results</p> </div> <search-results [items]="items"></search-results> </div></div>상태가 isSuccess이면 SearchResults를 렌더링합니다. 다음에 구현해 봅시다.
Search Results
섹션 제목: “Search Results”SearchResults는 List<SearchResultItem>을 받아 SearchResultItems 리스트로
표시하는 컴포넌트입니다.
search_results_component.dart를 생성합니다.
import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-results', templateUrl: 'search_results_component.html', directives: [coreDirectives, SearchResultItemComponent],)class SearchResultsComponent { @Input() late List<SearchResultItem> items;}다음으로 search_results_component.html을 생성합니다.
<ul class="list pa0 ma0"> <li *ngFor="let item of items" class="pa2 cf"> <search-result-item [item]="item"></search-result-item> </li></ul>이제 SearchResultItem을 구현할 차례입니다.
Search Result Item
섹션 제목: “Search Result Item”SearchResultItem은 단일 검색 결과의 정보를 렌더링하는 컴포넌트입니다. 사용자
상호작용을 처리하고 탭 시 리포지토리 URL로 이동하는 역할도 합니다.
search_result_item_component.dart를 생성합니다.
import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-result-item', templateUrl: 'search_result_item_component.html',)class SearchResultItemComponent { @Input() late SearchResultItem item;}그리고 search_result_item_component.html에 해당 템플릿을 작성합니다.
<div class="fl w-10 h-auto"> <img class="br-100" src="{{ item.owner.avatarUrl }}" /></div><div class="fl w-90 ph3"> <h1 class="f5 ma0">{{ item.fullName }}</h1> <p> <a href="{{ item.htmlUrl }}" class="light-blue" target="_blank">{{ item.htmlUrl }}</a> </p></div>통합하기
섹션 제목: “통합하기”모든 컴포넌트가 준비되었으니 이제 app_component.dart에서 통합합니다.
import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'my-app', template: '<search-form [githubRepository]="githubRepository"></search-form>', directives: [SearchFormComponent],)class AppComponent { final githubRepository = GithubRepository();}이것이 전부입니다! bloc과 angular_bloc 패키지를 사용하여 AngularDart에서
GitHub 검색 앱을 성공적으로 구현했고, 프레젠테이션 레이어와 비즈니스 로직을
성공적으로 분리했습니다.
전체 소스 코드는 여기에서 확인할 수 있습니다.
이번 튜토리얼에서는 Flutter와 AngularDart 앱을 만들면서 두 앱 간에 모든 모델, 데이터 프로바이더, bloc을 공유했습니다.
실제로 두 번 작성해야 했던 것은 프레젠테이션 레이어(UI)뿐이며, 이는 효율성과 개발 속도 면에서 큰 장점입니다. 또한 웹 앱과 모바일 앱이 서로 다른 사용자 경험과 스타일을 갖는 것은 꽤 흔한 일인데, 이 접근 방식은 완전히 다르게 보이는 두 앱이 동일한 데이터와 비즈니스 로직 레이어를 공유하는 것이 얼마나 쉬운지 보여줍니다.
전체 소스 코드는 여기에서 확인할 수 있습니다.