본문 바로가기

카테고리 없음

[Flutter_개발] 책 검색 앱 만들기

이번 학습에서는 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 구조의 장점을 직접 체감할 수 있었던 실습이었다.

 

그리고 추가적으로 오늘은 수료하신 선배님들의 조언을 들을 수 있는 기회가 되었는데 다시한번 마음에 불씨를 지필 수 있는 기회가된 좋은 시간을 보냈고 앞으로도 포기하지 않고 열심히 해보자!