이번 학습에서는 Flutter를 활용한 책검색 앱(Book Search App) 개발을 통해
UI 구성부터 Open API 연동, ViewModel 설계, 데이터 바인딩까지 전 과정을 익혔다.
1. 프로젝트 구조 세팅 및 Riverpod 적용
flutter_book_search_app 프로젝트를 생성하고, MVVM 구조로 폴더를 나누었다.
- data/ : 모델(model/)과 레포지토리(repository/)
- ui/ : 페이지(pages/)와 공용 위젯(widgets/)
main.dart에서는 ProviderScope로 앱 전체를 감싸 Riverpod 상태관리를 적용했다.
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(home: HomePage());
}
}
2. AppBar와 TextField 구현
AppBar의 title에 TextField를 배치해 검색창을 구현하고,
actions 속성에 검색 아이콘을 추가했다.
UX 향상을 위해 화면을 탭하면 키보드 포커스를 해제하도록 GestureDetector를 사용했다.
appBar: AppBar(
title: TextField(
controller: textEditingController,
decoration: InputDecoration(
hintText: '검색어를 입력해 주세요',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
onSubmitted: search,
),
actions: [
GestureDetector(
onTap: () => search(textEditingController.text),
child: const Icon(Icons.search),
),
],
),
3. GridView와 BottomSheet 구성
검색 결과를 GridView.builder로 표시하고,
책 이미지를 누르면 showModalBottomSheet()로 요약 정보를 표시했다.
body: GridView.builder(
padding: const EdgeInsets.all(20),
itemCount: 10,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, childAspectRatio: 3/4, crossAxisSpacing: 10, mainAxisSpacing: 10),
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => showModalBottomSheet(
context: context,
builder: (_) => const HomeBottomSheet(),
),
child: Image.network('https://picsum.photos/300/400', fit: BoxFit.cover),
);
},
),
4. 상세 페이지 및 WebView
flutter_inappwebview 패키지를 이용해 상세 페이지에서 책의 링크를 로드했다.
외부 브라우저로 나가지 않고 앱 내에서 웹 페이지를 확인할 수 있다.
class DetailPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('해리포터와 마법사의 돌')),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri("https://www.naver.com/")),
),
);
}
}
5. 네이버 책 검색 API 연동 및 모델 생성
네이버 Open API를 사용해 검색어를 요청하고, 응답받은 JSON을 Book 모델로 변환했다.
class Book {
final String title, link, image, author, publisher, pubdate, isbn, description, discount;
Book.fromJson(Map<String, dynamic> json)
: title = json["title"],
link = json["link"],
image = json["image"],
author = json["author"],
discount = json["discount"],
publisher = json["publisher"],
pubdate = json["pubdate"],
isbn = json["isbn"],
description = json["description"];
}
6. BookRepository 구현
http 패키지를 이용해 네이버 API에 요청을 보내고, 결과를 파싱했다.
class BookRepository {
Future<list?> search(String query) async {
try {
final result = await get(
Uri.parse('https://openapi.naver.com/v1/search/book.json?query=$query'),
headers: {'X-Naver-Client-Id': '...', 'X-Naver-Client-Secret': '...'},
);
if (result.statusCode == 200) {
final json = jsonDecode(result.body);
return List.from(json["items"]).map((e) => Book.fromJson(e)).toList();
}
} catch (e) {
print(e);
}
return null;
}
}
</list
7. ViewModel 설계 및 상태관리
Notifier<HomeState>를 상속받은 HomeViewModel을 통해 상태를 관리했다.
검색 시 BookRepository에서 데이터를 받아 HomeState를 갱신한다.
class HomeState { HomeState({this.books}); List<Book>? books; }
class HomeViewModel extends Notifier<HomeState> {
@override HomeState build() => HomeState(books: null);
Future<void> search(String query) async {
final repo = BookRepository();
state = HomeState(books: await repo.search(query));
}
}
final homeViewModelProvider =
NotifierProvider<HomeViewModel, HomeState>(() => HomeViewModel());
8. 데이터 바인딩 및 화면 연결
HomePage를 ConsumerStatefulWidget으로 변경하여 ViewModel의 상태를 감시했다.
검색 결과가 바뀌면 GridView에 자동으로 반영된다.
class HomePage extends ConsumerStatefulWidget {
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
final textEditingController = TextEditingController();
void search(String text) => ref.read(homeViewModelProvider.notifier).search(text);
@override
Widget build(BuildContext context) {
final homeState = ref.watch(homeViewModelProvider);
return Scaffold(
appBar: AppBar(
title: TextField(onSubmitted: search, controller: textEditingController),
actions: [IconButton(icon: const Icon(Icons.search),
onPressed: () => search(textEditingController.text))],
),
body: GridView.builder(
itemCount: homeState.books?.length ?? 0,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
itemBuilder: (_, i) {
final book = homeState.books![i];
return GestureDetector(
onTap: () => showModalBottomSheet(
context: context, builder: (_) => HomeBottomSheet(book)),
child: Image.network(book.image, fit: BoxFit.cover),
);
},
),
);
}
}
이번 실습을 통해 Flutter의 UI 구성, Riverpod 기반 상태관리,
API 연동 및 JSON 파싱, 데이터바인딩 구조를 완전하게 이해할 수 있었다.
특히 ConsumerStatefulWidget을 활용해 ViewModel의 상태를
실시간으로 반영하는 구조가 인상 깊었다.
MVC보다 더 유연하고 유지보수하기 쉬운 MVVM 구조의 장점을 직접 체감할 수 있었던 실습이었다.
그리고 추가적으로 오늘은 수료하신 선배님들의 조언을 들을 수 있는 기회가 되었는데 다시한번 마음에 불씨를 지필 수 있는 기회가된 좋은 시간을 보냈고 앞으로도 포기하지 않고 열심히 해보자!