콘텐츠로 이동

GitHub 검색

advanced

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

demo

demo

Common GitHub Search 라이브러리는 AngularDart와 Flutter 간에 공유될 모델, 데이터 프로바이더, 리포지토리, 그리고 bloc을 포함합니다.

먼저 애플리케이션을 위한 새 디렉토리를 만듭니다.

Terminal window
mkdir -p github_search/common_github_search

필요한 의존성이 담긴 pubspec.yaml을 생성해야 합니다.

common_github_search/pubspec.yaml
name: common_github_search
description: Shared Code between AngularDart and Flutter
version: 1.0.0+1
publish_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.0
dev_dependencies:
bloc_lint: ^0.3.0

마지막으로 의존성을 설치합니다.

Terminal window
dart pub get

프로젝트 설정이 완료되었습니다! 이제 common_github_search 패키지를 만들어 봅시다.

GithubClientGitHub API에서 원시 데이터를 제공합니다.

github_client.dart를 생성합니다.

common_github_search/lib/src/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();
}
}

다음으로 SearchResultSearchResultError 모델을 정의해야 합니다.

사용자의 쿼리를 기반으로 한 SearchResultItems 리스트를 나타내는 search_result.dart를 생성합니다:

lib/src/models/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.dart를 생성합니다.

lib/src/models/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.dart를 생성합니다.

lib/src/models/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.dart를 생성합니다.

lib/src/models/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로 넘어갑니다.

GithubCache는 모든 이전 쿼리를 기억하여 GitHub API에 불필요한 네트워크 요청을 피하는 역할을 합니다. 이를 통해 애플리케이션의 성능도 향상됩니다.

github_cache.dart를 생성합니다.

lib/src/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는 데이터 레이어(GithubClient)와 비즈니스 로직 레이어(Bloc) 사이에 추상화를 만드는 역할을 합니다. 여기서 GithubCache도 사용하게 됩니다.

github_repository.dart를 생성합니다.

lib/src/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();
}
}

여기까지 데이터 프로바이더 레이어와 리포지토리 레이어를 완성했으니 이제 비즈니스 로직 레이어로 넘어갑니다.

Bloc은 사용자가 리포지토리 이름을 입력하면 알림을 받게 되며, 이를 TextChanged GithubSearchEvent로 나타냅니다.

github_search_event.dart를 생성합니다.

lib/src/github_search_bloc/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 }';
}

프레젠테이션 레이어가 적절하게 표시되려면 여러 정보가 필요합니다:

  • SearchStateEmpty - 사용자가 아무 입력도 하지 않았음을 프레젠테이션 레이어에 알립니다.

  • SearchStateLoading - 로딩 인디케이터를 표시해야 함을 프레젠테이션 레이어에 알립니다.

  • SearchStateSuccess - 표시할 데이터가 있음을 프레젠테이션 레이어에 알립니다.

    • items - 표시될 List<SearchResultItem>입니다.
  • SearchStateError - 리포지토리를 가져오는 중 에러가 발생했음을 프레젠테이션 레이어에 알립니다.

    • error - 발생한 정확한 에러입니다.

이제 github_search_state.dart를 생성하고 다음과 같이 구현합니다.

lib/src/github_search_bloc/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.dart를 생성합니다:

lib/src/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는 common_github_search의 모델, 데이터 프로바이더, 리포지토리, bloc을 재사용하여 Github Search를 구현하는 Flutter 애플리케이션입니다.

github_search 디렉토리 내에서 common_github_search와 같은 레벨에 새 Flutter 프로젝트를 생성합니다.

Terminal window
flutter create flutter_github_search

다음으로 필요한 모든 의존성을 포함하도록 pubspec.yaml을 업데이트합니다.

flutter_github_search/pubspec.yaml
name: flutter_github_search
description: A new Flutter project.
version: 1.0.0+1
publish_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: true
dev_dependencies:
bloc_lint: ^0.3.0

이제 의존성을 설치합니다.

Terminal window
flutter pub get

프로젝트 설정이 완료되었습니다. common_github_search 패키지가 데이터 레이어와 비즈니스 로직 레이어를 포함하고 있으므로 프레젠테이션 레이어만 구현하면 됩니다.

_SearchBar_SearchBody 위젯이 있는 폼을 만들어야 합니다.

  • _SearchBar는 사용자 입력을 받는 역할을 합니다.
  • _SearchBody는 검색 결과, 로딩 인디케이터, 에러를 표시하는 역할을 합니다.

search_form.dart를 생성합니다.

SearchForm_SearchBar_SearchBody 위젯을 렌더링하는 StatelessWidget입니다.

_SearchBarStatefulWidget인데, 사용자가 입력한 내용을 추적하기 위해 자체 TextEditingController를 유지해야 하기 때문입니다.

_SearchBody는 검색 결과, 에러, 로딩 인디케이터를 표시하는 StatelessWidget입니다. GithubSearchBloc의 소비자가 됩니다.

상태가 SearchStateSuccess이면 다음에 구현할 _SearchResults를 렌더링합니다.

_SearchResultsList<SearchResultItem>을 받아 _SearchResultItems 리스트로 표시하는 StatelessWidget입니다.

_SearchResultItem은 단일 검색 결과의 정보를 렌더링하는 StatelessWidget입니다. 사용자 상호작용을 처리하고 탭 시 리포지토리 URL로 이동하는 역할도 합니다.

flutter_github_search/lib/search_form.dart
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에서 메인 앱을 구현하기만 하면 됩니다.

flutter_github_search/lib/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(),
),
),
),
);
}
}

이것이 전부입니다! blocflutter_bloc 패키지를 사용하여 Flutter에서 GitHub 검색 앱을 성공적으로 구현했고, 프레젠테이션 레이어와 비즈니스 로직을 성공적으로 분리했습니다.

전체 소스 코드는 여기에서 확인할 수 있습니다.

마지막으로 AngularDart GitHub Search 앱을 만들어 봅시다.

AngularDart GitHub Search는 common_github_search의 모델, 데이터 프로바이더, 리포지토리, bloc을 재사용하여 Github Search를 구현하는 AngularDart 애플리케이션입니다.

common_github_search와 같은 레벨의 github_search 디렉토리에 새 AngularDart 프로젝트를 생성합니다.

Terminal window
stagehand web-angular

그런 다음 pubspec.yaml의 내용을 다음으로 교체합니다:

angular_github_search/pubspec.yaml
name: angular_github_search
description: 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.0

Flutter 앱과 마찬가지로 SearchBarSearchBody 컴포넌트가 있는 SearchForm을 만들어야 합니다.

SearchForm 컴포넌트는 GithubSearchBloc을 생성하고 닫아야 하므로 OnInitOnDestroy를 구현합니다.

  • SearchBar는 사용자 입력을 받는 역할을 합니다.
  • SearchBody는 검색 결과, 로딩 인디케이터, 에러를 표시하는 역할을 합니다.

search_form_component.dart를 생성합니다.

angular_github_search/lib/src/search_form/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)은 다음과 같습니다:

angular_github_search/lib/src/search_form/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 컴포넌트를 구현합니다.

SearchBar는 사용자 입력을 받고 GithubSearchBloc에 텍스트 변경을 알리는 컴포넌트입니다.

search_bar_component.dart를 생성합니다.

angular_github_search/lib/src/search_form/search_bar/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을 생성합니다.

angular_github_search/lib/src/search_form/search_bar/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로 넘어갑니다.

SearchBody는 검색 결과, 에러, 로딩 인디케이터를 표시하는 컴포넌트입니다. GithubSearchBloc의 소비자가 됩니다.

search_body_component.dart를 생성합니다.

angular_github_search/lib/src/search_form/search_body/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을 생성합니다.

angular_github_search/lib/src/search_form/search_body/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를 렌더링합니다. 다음에 구현해 봅시다.

SearchResultsList<SearchResultItem>을 받아 SearchResultItems 리스트로 표시하는 컴포넌트입니다.

search_results_component.dart를 생성합니다.

angular_github_search/lib/src/search_form/search_body/search_results/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을 생성합니다.

angular_github_search/lib/src/search_form/search_body/search_results/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을 구현할 차례입니다.

SearchResultItem은 단일 검색 결과의 정보를 렌더링하는 컴포넌트입니다. 사용자 상호작용을 처리하고 탭 시 리포지토리 URL로 이동하는 역할도 합니다.

search_result_item_component.dart를 생성합니다.

angular_github_search/lib/src/search_form/search_body/search_results/search_result_item/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에 해당 템플릿을 작성합니다.

angular_github_search/lib/src/search_form/search_body/search_results/search_result_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에서 통합합니다.

angular_github_search/lib/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();
}

이것이 전부입니다! blocangular_bloc 패키지를 사용하여 AngularDart에서 GitHub 검색 앱을 성공적으로 구현했고, 프레젠테이션 레이어와 비즈니스 로직을 성공적으로 분리했습니다.

전체 소스 코드는 여기에서 확인할 수 있습니다.

이번 튜토리얼에서는 Flutter와 AngularDart 앱을 만들면서 두 앱 간에 모든 모델, 데이터 프로바이더, bloc을 공유했습니다.

실제로 두 번 작성해야 했던 것은 프레젠테이션 레이어(UI)뿐이며, 이는 효율성과 개발 속도 면에서 큰 장점입니다. 또한 웹 앱과 모바일 앱이 서로 다른 사용자 경험과 스타일을 갖는 것은 꽤 흔한 일인데, 이 접근 방식은 완전히 다르게 보이는 두 앱이 동일한 데이터와 비즈니스 로직 레이어를 공유하는 것이 얼마나 쉬운지 보여줍니다.

전체 소스 코드는 여기에서 확인할 수 있습니다.