[TIL] Day 29 AnimatedList & JSON & MVVM & RiverPod & Open API

현서·2026년 1월 5일

[TIL] Flutter 9기

목록 보기
41/65
post-thumbnail

📍 튜터님과 Widget 공부

✏️ AnimatedList

AnimatedList란?

AnimatedList는 리스트 아이템이 추가·삭제될 때 애니메이션을 자동으로 적용해주는 위젯이다.
ListView → 아이템 변경 시 바로 갱신
AnimatedList → 아이템 삽입 / 제거 시 애니메이션 가능

📍 중요 포인트
상태(State)를 직접 관리해야 함
내부 데이터 리스트와 AnimatedList 상태를 항상 동기화해야 함

언제 쓸까?

상황추천 여부
아이템 추가/삭제 시 자연스러운 등장/퇴장⭐⭐⭐⭐⭐
단순 정적 리스트❌ (ListView 사용)
실시간 장바구니, 위시리스트⭐⭐⭐⭐
서버에서 전체 리스트 교체⚠️ (다시 구성 필요)

기본 구조

AnimatedList(
  key: _listKey,
  initialItemCount: items.length,
  itemBuilder: (context, index, animation) {
    return _buildItem(index, animation);
  },
)
요소설명
keyGlobalKey<AnimatedListState> 필수
initialItemCount초기 아이템 개수
itemBuilderanimation을 활용해 위젯 구성

itemBuilder의 핵심 파라미터

index : 현재 아이템 위치
animation : 삽입/삭제 시 사용되는 애니메이션 값

필수 구성요소

final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey();
final List<Item> _itemList = [];

GlobalKey<AnimatedListState>
→ 리스트에 insert / remove 명령을 내리기 위해 필요
_itemList
→ 실제 데이터 저장용 리스트

아이템 추가

void _addItem() {
  _itemList.add('item ${_itemList.length + 1}');
  _animatedListKey.currentState?.insertItem(
    _itemList.length - 1,
    duration: Duration(milliseconds: 300),
  );
}

📌 순서가 핵심
1. 데이터 추가
2. insertItem(index) 호출

아이템 삭제

void _removeItem(int index) {
  final removedItem = _itemList[index];
  _itemList.removeAt(index);

  _animatedListKey.currentState?.removeItem(
    index,
    (context, animation) {
      return MyItem(animation, removedItem);
    },
    duration: Duration(milliseconds: 300),
  );
}

📌 핵심 포인트
삭제 아이템을 먼저 저장
removeItem builder에서는
_itemList[index] 사용 금지

📝 데이터 통신 기초와 JSON

✏️ JSON이란?

JSON (JavaScript Object Notation)
JavaScript 객체 표기법 기반의 데이터 형식
서버 ↔ 클라이언트 간 데이터 교환에 가장 많이 사용
언어와 플랫폼에 독립적이라 서로 다른 환경에서도 사용 가능

✏️ 왜 JSON을 쓸까? (직렬화 개념)

직렬화 (Serialization)

객체 → 전송/저장 가능한 형태로 변환
서버로 보내거나 로컬 저장소에 저장할 때 필요
Dart 객체 → Map → JSON String

역직렬화 (Deserialization)

서버에서 받은 데이터를 다시 객체로 변환
JSON String → Map → Dart 객체
➡️ 서로 다른 언어(Dart ↔ Java 등)에서도 데이터를 주고받기 위해
공통 포맷으로 JSON을 사용

✏️ JSON 기본 구조

JSON 객체 형태

{} (중괄호)
key : value 쌍으로 구성

{
  "name": "김현서",
  "age": 24
}

⚠️ 규칙
key는 반드시 문자열
value는 아래 타입만 가능

타입설명
String"text"
Numberint, double
Booleantrue, false
Array[]
Object{}
null값 없음
📘 예시: 강아지 정보 JSON
{
  "name": "뽀삐",
  "age": 3,
  "isMale": true,
  "favorite_foods": ["삼겹살", "연어", "고구마"],
  "dislike_foods": [],
  "contact": {
    "mobile": "010-0000-0000",
    "email": null
  }
}

JSON의 루트는 객체만 가능할까?

❌ 아니다!
JSON의 최상위(root) 는
객체 {} 일 수도 있고
리스트 [] 일 수도 있음

 [
  { "id": 1, "name": "현서" },
  { "id": 2, "name": "서현" }
 ]

➡️ 서버 응답에서 리스트 형태 JSON 아주 흔함

📤 직렬화

Dart 객체 → Map → jsonEncode → String

📥 역직렬화

String → jsonDecode → Map → Dart 객체

📝 Dart에서 JSON 사용법 정리

✏️ jsonEncode (직렬화)

개념

Map / List → JSON String
서버로 데이터 보낼 때 사용

<import 'dart:convert';

void main() {
  Map<String, dynamic> myInfo = {
    "name": "감현서",
    "age": 24,
  };

  String jsonString = jsonEncode(myInfo);
  print(jsonString); // {"name":"김현서","age":24}

  List list = [myInfo, myInfo];
  String listJsonString = jsonEncode(list);
  print(listJsonString);
  // [{"name":"김현서","age":24},{"name":"김현서","age":24}]
}

📌 포인트
jsonEncode는 Map / List만 변환 가능
결과는 항상 String

✏️ jsonDecode (역직렬화)

개념

JSON String → Map 또는 List
서버에서 받은 데이터 처리할 때 사용
리턴 타입은 dynamic

import 'dart:convert';

void main() {
  String jsonString = """
  {
    "name": "김현서",
    "age": 24
  }
  """;

  var result = jsonDecode(jsonString);
  print(result); // {name: 김현서, age: 24}
  print(result.runtimeType); // Map<String, dynamic>
}

✏️ JSON → Dart 객체로 변환하기

왜 Map 말고 Dart 객체로 쓸까?

map['key'] 방식은
❌ 오타 잡기 어려움
❌ 타입 안정성 없음

클래스로 변환하면
IDE 자동완성 ✅
컴파일 타임 에러 확인 가능 ✅
유지보수 쉬움 ✅

기본 패턴: fromJson / toJson

class User {
  User({
    required this.name,
    required this.age,
  });

  String name;
  int age;

  User.fromJson(Map<String, dynamic> map)
      : this(
          name: map['name'],
          age: map['age'],
        );

  Map<String, dynamic> toJson() {
    return {
      "name": name,
      "age": age,
    };
  }
}

사용 예시

import 'dart:convert';

void main() {
  String jsonString = """
  {
    "name": "김현서",
    "age": 24
  }
  """;

  var jsonMap = jsonDecode(jsonString);
  User user = User.fromJson(jsonMap);

  print(user.toJson()); // {name: 김현서, age: 24}
}

왜 fromJson을 클래스 안에 둘까?

❌ 잘못된 방식 (오타 위험)

User user2 = User(name: jsonMap['nane'], age: jsonMap['age']);
User user3 = User(name: jsonMap['name'], age: jsonMap['aga']);

➡️ 런타임에서만 오류 발견 가능

✅ 좋은 방식

User user = User.fromJson(jsonMap);

➡️ JSON 구조가 바뀌면 한 곳만 수정하면 됨

실습

쉬운 버전 (Boolean 포함)

JSON
{
  "name": "뽀삐",
  "age": 3,
  "isMale": true
}

Pet 클래스

class Pet {
  String name;
  int age;
  bool isMale;

  Pet({
    required this.name,
    required this.age,
    required this.isMale,
  });

  Pet.fromJson(Map<String, dynamic> json)
      : this(
          name: json["name"],
          age: json["age"],
          isMale: json["isMale"],
        );

  Map<String, dynamic> toJson() {
    return {
      "name": name,
      "age": age,
      "isMale": isMale,
    };
  }
}

어려운 버전 (List + 중첩 객체)

JSON

{
  "name": "뽀삐",
  "age": 3,
  "isMale": true,
  "favorite_foods": ["삼겹살", "연어", "고구마"],
  "contact": {
    "mobile": "010-0000-0000",
    "email": null
  }
}

Contact 클래스

class Contact {
  String mobile;
  String? email;

  Contact({
    required this.mobile,
    required this.email,
  });

  Contact.fromJson(Map<String, dynamic> json)
      : this(
          mobile: json["mobile"],
          email: json["email"],
        );

  Map<String, dynamic> toJson() {
    return {
      "mobile": mobile,
      "email": email,
    };
  }
}

Pet 클래스 (중첩 구조)

class Pet {
  final String name;
  final int age;
  final bool isMale;
  final List<String> favoriteFoods;
  final Contact contact;

  Pet({
    required this.name,
    required this.age,
    required this.isMale,
    required this.favoriteFoods,
    required this.contact,
  });

  Pet.fromJson(Map<String, dynamic> json)
      : this(
          name: json["name"],
          age: json["age"],
          isMale: json["isMale"],
          favoriteFoods: List<String>.from(json["favorite_foods"]),
          contact: Contact.fromJson(json["contact"]),
        );

  Map<String, dynamic> toJson() {
    return {
      "name": name,
      "age": age,
      "isMale": isMale,
      "favorite_foods": favoriteFoods,
      "contact": contact.toJson(),
    };
  }
}

📝 MVVM 아키텍쳐

✏️ MVVM이란?

Model + View + ViewModel
역할을 명확히 나눠서 개발하는 아키텍처 패턴
UI 로직과 비즈니스 로직을 분리하는 것이 핵심

  • 📦 Model
    데이터를 담당하는 계층
    서버, DB, API 등에서 받아오는 원본 데이터
    데이터 클래스 자체도 Model에 포함됨

    • 예시
      User, Pet 같은 DTO / Entity 클래스
      서버 통신 담당 Repository
      API 호출
      JSON → 객체 변환
      ViewModel에 데이터 전달
      👉 “데이터 그 자체 + 데이터를 가져오는 로직” = Model
  • 🖥 View
    화면(UI) 담당 계층
    Flutter에서는 Widget
    ViewModel을 구독해서 상태 변화에 따라 화면만 그려줌

    • 특징
      비즈니스 로직 ❌
      데이터 가공 ❌
      화면 표현만 담당 ⭕
  • 🧠 ViewModel
    Model과 View 사이의 중간 관리자
    Model에서 받은 데이터를 가공해서 View가 쓰기 좋은 상태로 변환
    상태를 관리하고 변경을 알림

    • 역할
      로직 처리
      상태 관리
      데이터 요청
      View는 모르고, View가 나를 구독하는지만 신경 씀

✏️ StatefulWidget vs MVVM

StatefulWidget 방식

  1. 사용자 클릭, 로직 처리
  2. 데이터 요청해서 데이터 받음
  3. 받은 데이터 가공 및 상태 업데이트
  4. 화면 업데이트

문제점
하나의 Widget이 너무 많은 역할 수행
UI + 로직 + 데이터 처리 혼합
코드가 길어지고 복잡해짐
테스트 어려움
👉 “작을 땐 괜찮은데, 커지면 지옥”

MVVM 동작 방식

  1. View가 ViewModel 구독(변경되는지 바라보고 있음)
  2. ViewModel에게 사용자 클릭했으니 처리해!
  3. 로직 처리
  4. 데이터 요청해서 데이터 받음 (Model)
  5. 받은 데이터 가공 및 상태 업데이트
  6. 자신의 상태가 바뀌었다고 알림(View가 누군지는 모름)
  7. 뷰는 ViewModel을 구독하고 있기 때문에 상태가 바뀌었다는걸 감지 → 화면 업데이트

핵심 포인트
ViewModel은 View가 누군지 모름
View만 ViewModel을 알고 있음 (단방향 의존성)

✏️ MVVM을 쓰면 얻는 효과

  • ✅ 코드 구조
    역할 분리 → 코드 가독성 상승
    유지보수 쉬움
    중복 코드 감소

  • ✅ 안정성
    로직이 한 곳(ViewModel)에 모임
    버그 발생 지점 명확

  • ✅ 테스트
    ViewModel 단위 테스트 가능
    UI 없이 로직 테스트 가능

  • ✅ 결합도 감소
    View ↔ ViewModel 느슨한 결합
    구조 변경에 강함

MVVM 정리

예시

MVVM 식당

역할식당에서의 역할실제 코드에서의 역할
Model주방 & 창고 (식재료, 요리 로직)데이터 그 자체 (DB, API 데이터)
View손님 & 테이블 (메뉴판 보고 음식 먹기)화면 (버튼, 텍스트, 위젯)
ViewModel홀 서빙 직원 (주문 전달, 음식 세팅)화면에 보여줄 데이터 가공 & 상태 관리


상태 관리 패키지(RiverPod) 사용

Riverpod란?

Flutter에서 사용하는 상태관리 라이브러리
ViewModel 역할을 쉽게 구현할 수 있음
상태 변경 시 View(Widget)에 자동으로 알려줌
MVVM 패턴에서 View ↔ ViewModel 연결을 단순화

핵심 개념

개념역할
State관리할 데이터
Notifier상태를 저장·변경하는 ViewModel
ProviderViewModel을 생성·관리·공급
Consumer / refWidget에서 상태 관찰

1️⃣ State 클래스 만들기

관리할 데이터만 담는 순수 클래스
보통 immutable하게 사용

class HomeState {
  final int counter;

  HomeState(this.counter);
}

2️⃣ ViewModel 만들기 (Notifier 상속)

Notifier<T> 를 상속
T = 관리할 상태 타입
build() → 초기 상태 설정

class HomeViewModel extends Notifier<HomeState> {

  
  HomeState build() {
    return HomeState(1); // 초기 상태
  }

  void updateState() {
    // 반드시 새로운 객체로 교체
    state = HomeState(state.counter + 1);

    // ❌ 이렇게 하면 상태 변경 감지 안 됨
    // state.counter++;
  }
}

🔎 중요한 포인트

Riverpod은 객체가 바뀌었는지로 상태 변경을 판단
기존 객체 내부 값만 수정하면 재빌드 안 됨

3️⃣ Provider 생성 (ViewModel 관리자)

ViewModel을 생성·보관·공급하는 역할
View에서는 Provider에게 요청만 하면 됨

final homeViewModelProvider =
    NotifierProvider<HomeViewModel, HomeState>(
  (){
  return HomeViewModel()
);
제너릭의미
HomeViewModel제공할 ViewModel
HomeState관리하는 상태

4️⃣ Widget에서 사용하기

Consumer 사용
WidgetRef(ref)를 통해 Provider 접근 가능

Consumer(
  builder: (context, ref, child) {
    final state = ref.watch(homeViewModelProvider);

    return Column(
      children: [
        Text('카운터: ${state.counter}'),
      ],
    );
  },
);

ref 사용법 정리

메서드설명
ref.watch(provider)상태 변경 감지 → 재빌드
ref.read(provider)1회성 읽기 (재빌드 X)
ref.read(provider.notifier)ViewModel 접근
ref.read(homeViewModelProvider.notifier).updateState();

5️⃣ ProviderScope 설정 (필수)

Riverpod을 앱 전체에서 사용하려면 최상위에 감싸기

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

🧩 전체 흐름 한 줄 요약
State 생성Notifier(ViewModel) 작성Provider로 관리Widget에서 watch/read

✏️ Provider 정리

예시

Riverpod: 붕어빵 가게 운영 시스템

구성 요소

  1. State (붕어빵 개수)
    가장 단순한 '데이터'
    "현재 남은 붕어빵은 5개야."라는 정보 그 자체
    붕어빵 가게에서 가장 중요한 핵심 정보

  2. Notifier (붕어빵 사장님)
    데이터를 '관리하고 바꾸는 사람'
    사장님은 붕어빵이 팔리면 개수를 줄이고, 새로 구우면 개수를 늘리
    단, Riverpod 사장님은 고집이 있어서 "기존 판에 숫자만 지우고 다시 쓰는 게 아니라, 항상 새 판으로 교체"해야만 손님들이 바뀐 걸 알아챈다고 생각해요. (Immutable/불변성)

  3. Provider (붕어빵 매대/창구)
    손님이 사장님과 대화할 수 있는 '연결 통로'
    손님(Widget)이 사장님에게 직접 말을 걸 수는 없음
    매대(Provider)를 통해서만 "사장님, 붕어빵 몇 개 남았나요?"라고 묻거나 "붕어빵 하나 주세요!"라고 요청할 수 있음

  4. Consumer / ref (가게 손님)
    화면에 나타나는 'UI(위젯)'
    매대를 계속 쳐다보고 있다가 개수가 바뀌면 즉시 화면을 새로 그림 (ref.watch)
    혹은 딱 한 번만 물어보고 주문만 하고 가기도 함 (ref.read)

ref: 사장님과 대화하는 무전기

손님(위젯)이 매대(Provider)에 가서 사장님과 소통할 때 쓰는 무전기가 바로 ref

  1. ref.watch (실시간 전광판 감시)

    • 상황: "사장님, 붕어빵 개수 바뀌면 바로 알려주세요. 저 수첩(화면) 새로 써야 해요!"
    • 특징: 데이터가 바뀌면 위젯이 새로고침(Rebuild)
    • 용도: 화면에 실시간으로 변하는 숫자를 보여줄 때 사용
  2. ref.read (필요할 때만 호출)

    • 상황: "지금 딱 몇 개인지만 알려주세요." 또는 "사장님! 붕어빵 하나 팔렸어요! (함수 실행)"
    • 특징: 데이터가 바뀌어도 화면을 새로 그리지 않습니다.
    • 용도: 버튼 클릭 이벤트(onPressed)나 함수를 실행할 때 사용

붕어빵 가게 코드 가이드

  1. 사장님(Notifier)과 데이터(State) 정의
// 1. 데이터: 붕어빵 개수 (State)
class BungeoState {
  final int count;
  BungeoState(this.count);
}

// 2. 사장님: 붕어빵 관리 (Notifier)
class BungeoViewModel extends Notifier<BungeoState> {
  
  BungeoState build() => BungeoState(5); // 초기 개수 5개

  void sell() {
    // 반드시 '새 객체'로 교체해야 사장님이 알림을 보냅니다
    state = BungeoState(state.count - 1);
  }
}

// 3. 매대: 누구나 접근 가능 (Provider)
final bungeoProvider = NotifierProvider<BungeoViewModel, BungeoState>(() {
  return BungeoViewModel();
});
  1. 손님(Widget)이 가게 이용하기
class BungeoShopView extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // 📢 watch 버튼: 개수가 바뀌면 이 화면 전체가 자동으로 새로 그려짐
    final bungeo = ref.watch(bungeoProvider);

    return Scaffold(
      body: Center(
        child: Column(
          children: [
            Text('남은 붕어빵: ${bungeo.count}개'), 
            
            ElevatedButton(
              onPressed: () {
                // 📢 read 버튼: 버튼 누를 때만 사장님을 불러서 '판매' 시킴
                ref.read(bungeoProvider.notifier).sell();
              },
              child: Text('붕어빵 팔기'),
            ),
          ],
        ),
      ),
    );
  }
}

✏️ Riverpod 실습

실습 시나리오

버튼 클릭
서버에서 유저 정보 수신 (가정)
Riverpod을 이용해 상태 저장
UI 자동 갱신

  • 📦 서버 응답 데이터 (가정)
{
  "name": "김현서",
  "age": 24
}
  • 🗂 프로젝트 구조
lib/
 ┣ home_page.dart        // View
 ┣ home_view_model.dart  // ViewModel + State + Provider
 ┣ user.dart             // Model
 ┗ user_repository.dart  // Data Source

1️⃣ Model — User 클래스

데이터를 담는 순수 모델
user.dart

class User {
  User({
    required this.name,
    required this.age,
  });

  final String name;
  final int age;

  User.fromJson(Map<String, dynamic> map)
      : name = map['name'],
        age = map['age'];

  Map<String, dynamic> toJson() {
    return {
      "name": name,
      "age": age,
    };
  }
}

2️⃣ Repository — 데이터 가져오기 역할

서버 통신을 가정
JSON → Map → User 변환
user_repository.dart

class UserRepository {
  Future<User> getUser() async {
    await Future.delayed(Duration(seconds: 1));
    String dummy = """
{
  "name": "김현서",
  "age": 24
}
""";

    Map<String, dynamic> map = jsonDecode(dummy);
    return User.fromJson(map);
  }
}

3️⃣ State — HomePage에서 사용할 상태

화면에 필요한 데이터만 모아둔 상태 클래스

class HomeState {
  User? user;
  HomeState(this.user);
}

4️⃣ ViewModel — 상태를 관리하는 핵심

home_view_model.dart

class HomeViewModel extends Notifier<HomeState> {
  
  HomeState build() {
    return HomeState(null);
  }

  void getUser() async {
    UserRepository userRepository = UserRepository();
    User user = await userRepository.getUser();
    state = HomeState(user);
  }
}

5️⃣ Provider — ViewModel 관리자

final homeViewModelProvider = NotifierProvider<HomeViewModel, HomeState>(() {
  return HomeViewModel();
});

📌 역할 요약
ViewModel 생성
상태 보관
여러 Widget에서 공유

7️⃣ View — HomePage (Consumer 사용)

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Consumer(
        builder: (context, ref, child) {
          final homeState = ref.watch(homeViewModelProvider);
          return Column(
            children: [
              Text("name: ${homeState.user?.name}"),
              Text("age: ${homeState.user?.age}"),
              ElevatedButton(
                onPressed: () {
                  final viewModel = ref.read(homeViewModelProvider.notifier);
                  viewModel.getUser();
                },
                child: Text("데이터 가져오기"),
              ),
            ],
          );
        },
      ),
    );
  }
}

✏️ Riverpod의 장점

  • 하위 위젯으로 파라미터 전달 불필요
    필요한 위젯에서 Consumer로 바로 접근

  • 전역 상태 관리 가능
    ViewModel 생성·유지·공급을 Riverpod이 담당

  • 역할 분리 명확
    Widget → UI만 담당
    ViewModel → 상태 관리
    Repository → 데이터 처리

  • 유지보수 쉬움
    UI 수정 ≠ 비즈니스 로직 수정

통신 시 Riverpod 사용 흐름

  1. RiverPod 패키지 추가 flutter pub add flutter_riverpod

  2. main()에서 ProviderScope로 최상위 위젯 감싸기

  3. Widget 구현 (화면)

  4. 데이터를 담을 Model 클래스 생성

  5. 데이터를 가져오는 Repository 생성

  6. Widget에서 사용할 State 클래스 생성

  7. 상태를 관리할 ViewModel 생성 (Notifier 상속)

    • build()에서 초기 상태 설정
    • Repository 호출 → 상태 업데이트 함수 작성
  8. ViewModel을 공급할 NotifierProvider 생성

  9. Widget에서 Consumer 사용

    • 상태 표시 + 함수 연결

📝 책 검색 앱 만들기

WebView

Flutter 앱 안에서 웹 페이지를 보여주는 위젯
앱 내부에 웹 브라우저를 임베드한다고 생각하면 됨
Android / iOS의 네이티브 WebView 컴포넌트 사용

패키지특징
flutter_webviewFlutter 공식 패키지, 기본 기능만 제공
flutter_inappwebview비공식이지만 기능 매우 풍부 (쿠키 관리, JS 통신, 권한 처리 등)

패키지 추가

flutter pub add flutter_inappwebview

기본 사용법

InAppWebView(
  initialUrlRequest: URLRequest(
    url: WebUri("https://www.naver.com/"),
  ),
  initialSettings: InAppWebViewSettings(
    javaScriptEnabled: true,
    mediaPlaybackRequiresUserGesture: true,
    userAgent: '브라우저 User-Agent 문자열',
  ),
)

주요 설정

  • initialUrlRequest
    WebView가 처음 로드할 URL

  • javaScriptEnabled
    JavaScript 사용 여부 (대부분 true 필수)

  • mediaPlaybackRequiresUserGesture
    자동 재생 허용 여부

  • userAgent
    WebView 차단 방지용 → 브라우저처럼 위장

주요 콜백

이런 게 있다 정도만 알기
onWebViewCreated : WebView 생성 시
onLoadStart : 페이지 로딩 시작
onLoadStop : 페이지 로딩 완료
onPermissionRequest : 카메라, GPS 등 권한 요청 시

구현 예시

class DetailPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('해리포터와 마법사의 돌'),
      ),
      body: InAppWebView(
        initialSettings: InAppWebViewSettings(
          javaScriptEnabled: true,
          mediaPlaybackRequiresUserGesture: true,
          userAgent:
              'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36',
        ),
        initialUrlRequest: URLRequest(
          url: WebUri("https://www.naver.com/"),
        ),
      ),
    );
  }
}

Open API

Open API (Open Application Programming Interface)
외부 개발자가 특정 서비스나 소프트웨어의 기능을 사용할 수 있도록 공개한 인터페이스

특징

외부 서비스의 기능이나 데이터를 사용할 수 있음
HTTP 통신 기반으로 동작
요청(Request)을 보내면 응답(Response)을 받는 구조
응답 데이터는 주로 JSON 형식

Open API 동작 흐름

클라이언트에서 요청(Request) 전송
서버에서 요청 처리
JSON 형태의 데이터 응답(Response)
클라이언트에서 데이터 활용

Repository 구현

1️⃣ http 패키지 설치

flutter pub add http

http 패키지란?
Flutter / Dart에서 HTTP 요청(GET, POST 등) 을 보내기 위한 패키지
REST API 연동 시 필수

2️⃣ Repository 역할

API 통신 담당
DTO(JSON) → Model 객체로 변환
ViewModel / UI에서 직접 통신하지 않도록 분리

3️⃣ BookRepository 코드

import 'dart:convert';

import 'package:flutter_book_search_page/data/model/book.dart';
import 'package:http/http.dart';

class BookRepository {
  // API 응답을 받아 List<Book>으로 가공해서 반환
  Future<List<Book>?> search(String query) async {
    // 네트워크 통신은 반드시 예외 처리
    try {
      Client client = Client();

      Response result = await client.get(
        Uri.parse(
          'https://openapi.naver.com/v1/search/book.json?query=$query',
        ),
        headers: {
          'X-Naver-Client-Id': 'YOUR_CLIENT_ID',
          'X-Naver-Client-Secret': 'YOUR_CLIENT_SECRET',
        },
      );

      // HTTP 200 → 요청 성공
      if (result.statusCode == 200) {
        final json = jsonDecode(result.body);
        return List.from(json['items'])
            .map((e) => Book.fromJson(e))
            .toList();
      }

      return null;
    } catch (e) {
      print(e);
      return null;
    }
  }
}

📌 핵심 포인트

try-catch 필수
→ 인터넷 미연결, 서버 오류 등 예외 발생 가능
statusCode == 200
→ 정상 응답 여부 확인
jsonDecode
→ JSON 문자열 → Map
fromJson()
→ DTO → Model 변환

Book Model JSON 테스트

테스트 목적

JSON 문자열을 jsonDecode로 파싱
Book.fromJson()이 정상적으로 동작하는지 검증
문자열 타입 필드(discount) 값 확인

테스트 코드

String dummyData = """
{
  "title": "Harry! (Gedichte)",
  "link": "...",
  "image": "...",
  "author": "",
  "discount": "25360",
  "publisher": "Books on Demand",
  "pubdate": "20210519",
  "isbn": "9783753499949",
  "description": "text \\n text"
}
""";
Map<String, dynamic> map = jsonDecode(dummyData);
Book book = Book.fromJson(map);
expect(book.discount, '25360');

트러블 슈팅

문제

❌ 에러
FormatException: Control character in string

JSON 문자열 내부에 실제 줄바꿈(개행) 존재
Dart multiline string의 \n → 실제 개행 문자
JSON 규칙 위반

해결

JSON 문자열 안에서는 반드시 escape 처리
\\n

HomeViewModel 데이터 바인딩 정리

1️⃣ HomePage → ConsumerStatefulWidget 사용

왜 ConsumerStatefulWidget?

StatefulWidget과 사용법은 동일
State 클래스 안에서 ref 사용 가능
ViewModel 상태를 구독(watch) 하고 메서드 호출(read) 가능

class HomePage extends ConsumerStatefulWidget {
  
  ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {

2️⃣ HomeViewModel 상태 구독

HomeState homeState = ref.watch(homeViewModelProvider);

watch
→ 상태 변화 감지 → UI 자동 리빌드
read
→ 상태 변경 / 함수 호출용

ref.read(homeViewModelProvider.notifier).search(text);

3️⃣ 검색 흐름 정리

TextField 입력
   ↓
search(text)
   ↓
HomeViewModel.search()
   ↓
books 상태 변경
   ↓
UI 자동 갱신 (GridView)

4️⃣ GridView에 ViewModel 데이터 바인딩

Book 데이터 사용

Book book = homeState.books[index];
Image.network(
  book.image,
  fit: BoxFit.cover,
)

5️⃣ BottomSheet로 Book 전달

HomePage → HomeBottomSheet

showModalBottomSheet(
  context: context,
  builder: (context) {
    return HomeBottomSheet(book);
  },
);

6️⃣ HomeBottomSheet 구조

Book을 생성자로 전달받음

class HomeBottomSheet extends StatelessWidget {
  HomeBottomSheet(this.book);

  Book book;

데이터 바인딩

Text(book.title)
Text(book.author)
Text(book.description)
Image.network(book.image)

공부 소감

이제 api 사용할 준비와 상태 관리 패키지를 사용할 준비가 되어서 설렌담 ㅎㅎ

0개의 댓글