1. 데이터 통신
- 프론트와 REST API 서버는 서로 JSON을 주고 받음
- JSON = 중간 데이터
- 데이터를 활용하기 위해서는 자신의 언어에 맞는 데이터로 변환해서 사용해야 함
- JSON 변환 함수가 필요
- Flutter의 Map은 JSON과 같은 형태를 가짐
- JSON을 Map으로 변환하는 라이브러리도 보유 (Dio)

2. fromMap
- 통신을 통해 받아온 Map 데이터를 VM의 state에 저장하기 위해 사용하는 함수
- 댓글의 경우 리스트를 따로 사용하지 않고 레코드를 사용
- 이름이 있는 레코드 사용 시 값 대입이 편리
- Initialized Keyword 사용으로 값을 바로 초기화할 수 있음
class Post {
int id;
int userId;
String title;
List<({int id, String comment, String owner})> replies;
Post(this.id, this.userId, this.title, this.replies);
// 1. fromJson -> fromMap (dio 통신 라이브러리)
// REST API -> Map(dio) -> Post
Post.fromMap(Map<String, dynamic> data)
: id = data["id"],
userId = data["userId"],
title = data["title"],
replies = (data["replies"] as List)
.map(
(e) => (
id: e["id"] as int,
comment: e["comment"] as String,
owner: e["owner"] as String,
),
)
.toList();
// copyWith
Post copyWith({
int? id,
int? userId,
String? title,
List<({int id, String comment, String owner})>? replies,
}) {
return Post(
id ?? this.id,
userId ?? this.userId,
title ?? this.title,
replies ?? this.replies,
);
}
}
void main() {
// given
Map<String, dynamic> data = {
"id": 1,
"userId": 1,
"title": "제목1",
"replies": [
{"id": 1, "comment": "댓글1", "owner": "ssar"},
{"id": 2, "comment": "댓글2", "owner": "cos"},
{"id": 3, "comment": "댓글3", "owner": "love"},
],
};
Post post = Post.fromMap(data);
print(post.id);
print(post.userId);
print(post.title);
print(post.replies);
}

3. copyWith
- 기존에 존재하던 값을 업데이트해야 할 경우 얕은 복사로는 Provider가 변화 감지를 할 수 없음
- 객체 자체를 교체해야 변화 감지 가능
- 미리 존재하던 객체에서 새로운 값만 대입해서 값을 바꾼 객체로 현재 상태를 바꿈
- 바꿀 값만 넣으면 다른 값들은 이전 상태의 값 유지
// copyWith
Post copyWith({
int? id,
int? userId,
String? title,
List<({int id, String comment, String owner})>? replies,
}) {
return Post(
id ?? this.id,
userId ?? this.userId,
title ?? this.title,
replies ?? this.replies,
);
}
Post post2 = post.copyWith(userId: 5);
print(post2.id);
print(post2.userId);
print(post2.title);
print(post2.replies);

4. VM (Riverpod)
- Riverpod의 3가지 요소
- 창고 관리자 : Provider
- 창고 : 비즈니스 로직 (build, 여러 데이터 처리 함수 작성)
- 창고 데이터 타입 : 값이 저장될 primitive or class 작성
- 통신의 경우 비동기적으로 작동해야 하므로, 실제 값을 전달하는 것이 아닌 값이 들어갈 상자를 전달
- 통신이 끝나면 값이 채워짐
- 이 형태를 Dart에서는 Future 타입으로 지원
- 자바의 경우 멀티 스레드를 지원하므로 비동기 함수를 그대로 사용할 수 있음
- Flutter는 단일 스레드로 작동하고, 화면에 비동기적으로 데이터 표현 시 UX가 낮아질 수 있음
- 데이터를 전부 불러온 후 화면 표시
- 실제 받아오는 데이터의 경우 null이 들어올 수도 있기 때문에 ? 타입을 붙이는 것이 좋음
- 비동기 함수의 경우, async와 await 부착 필요
- 자바스크립트와 다르게 async는 함수 맨 끝에 부착
import 'package:flutter_prac_data/repository/post_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 1. 창고 관리자
final postProvider = NotifierProvider<PostVM, Post?>(() {
return PostVM();
});
// 2. 창고 (비지니스 로직)
class PostVM extends Notifier<Post?> {
PostRepository postRepository = PostRepository.instance;
@override
Post? build() {
init();
return null;
}
Future<void> init() async {
Post post = await postRepository.findById(1);
state = post;
}
Future<void> update() async {
Post post = await postRepository.update();
state = state!.copyWith(title: post.title);
}
}
// 3. 창고 데이터 타입
class Post {
int? id;
int? userId;
String? title;
List<({int id, String comment, String owner})> replies;
Post(this.id, this.userId, this.title, this.replies);
// 1. fromJson -> fromMap 으로 만든다. (Dio 통신라이브러리)
// 무엇을(restapi서버 -> dio -> map 데이터) -> 어떻게(post 데이터)
Post.fromMap(Map<String, dynamic> data)
: id = data["id"],
userId = data["userId"],
title = data["title"],
replies = (data["replies"] != null)
? (data["replies"] as List)
.map(
(e) => (
id: e["id"] as int,
comment: e["comment"] as String,
owner: e["owner"] as String,
),
)
.toList()
: [];
// 2. copyWith
// copyWith 메서드 (특정 필드만 바꾸는 복사 생성자)
Post copyWith({
int? id,
int? userId,
String? title,
List<({int id, String comment, String owner})>? replies,
}) {
return Post(
id ?? this.id,
userId ?? this.userId,
title ?? this.title,
replies ?? this.replies,
);
}
}
5. Repository
- 서버에서 데이터를 주고받는 위치
- body 또는 에러 메시지를 파싱하여 전달
- Repository에서 에러 캐치 시, 이를 VM 또는 Page, Main까지 전달 가능
- VM, Page에서 에러 처리를 넘기면 Main에서 처리 가능
- Main에서 ExceptionHandler 등을 통해 throw로 넘긴 에러들을 전부 처리 가능
- 어떤 화면에서 에러가 발생했고, 에러가 발생한 화면에 경고창 등을 통해 알려야 하므로 context를 전달받아야 함
- Singleton Pattern 사용 : 함수를 통해서만 Repository 생성 → 의존성 부여
import 'package:flutter_prac_data/page/post_vm.dart';
// 서버접근 + 파싱
class PostRepository {
static PostRepository instance = PostRepository._single();
PostRepository._single();
Future<Post> findById(int id) async {
// 1. 통신 코드
Map<String, dynamic> response = await Future.delayed(
Duration(seconds: 5),
() {
return _mockPost;
},
);
Post post = Post.fromMap(response);
// 2. 리턴
return post;
}
Future<Post> update() async {
// 1. 통신 코드
Map<String, dynamic> response = await Future.delayed(
Duration(seconds: 5),
() {
return _mockUpdatePost;
},
);
Post post = Post.fromMap(response);
// 2. 리턴
return post;
}
}
// 가짜 데이터
final Map<String, dynamic> _mockUpdatePost = {"id": 1, "userId": 3, "title": "제목10"};
// 가짜 데이터
final Map<String, dynamic> _mockPost = {
"id": 1,
"userId": 3,
"title": "제목1",
"replies": [
{"id": 1, "comment": "댓글1", "owner": "ssar"},
{"id": 2, "comment": "댓글2", "owner": "cos"},
{"id": 3, "comment": "댓글3", "owner": "love"},
],
};
6. Page
- 창고 관리자 : ConsumerWidget 변경 후 WidgetRef로 호출 가능
- state 접근 : Provider를 read 또는 watch
- read : const
- watch : 일반 객체 (값이 변할 수 있음)
- 창고 접근 (함수) : Provider.notifier를 read
- model이 통신을 통해 전달 받기 때문에 null일 수 있음
- null 일 경우 Progress Bar 등을 노출
- 데이터가 들어온 경우 watch를 통해 값의 변화를 인지 : 화면 작성
- 버튼 클릭 시 update 함수가 작동
- 창고를 받아와야 하므로 notifier read가 필요
import 'package:flutter/material.dart';
import 'package:flutter_prac_data/page/post_vm.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PostPage extends ConsumerWidget {
PostPage();
@override
Widget build(BuildContext context, WidgetRef ref) {
PostVM vm = ref.read(postProvider.notifier);
return Scaffold(
body: Column(
children: [
Expanded(child: PostDetail()),
ElevatedButton(
onPressed: () {
vm.update();
},
child: Text("값변경"),
),
],
),
);
}
}
class PostDetail extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1. 창고 관리자 만들기 (watch, read)
Post? model = ref.watch(postProvider);
// 2. null 체크
if (model == null) {
return Center(child: CircularProgressIndicator());
} else {
return Column(
children: [
Text("id : ${model.id}, userId: ${model.userId}, title : ${model.title}"),
Expanded(
child: ListView.builder(
itemCount: model.replies.length,
itemBuilder: (context, index) {
var reply = model.replies[index];
return ListTile(
leading: Text("${reply.id}"),
title: Text("${reply.comment}"),
trailing: Text("${reply.owner}"),
);
},
),
),
],
);
}
}
}
Result
- 실행 시간부터 5초 동안 대기 상태
- 통신 시간 고려

- 데이터 로딩 완료 시
- id, userId, 제목, 댓글 리스트 표시

- 값 변경 클릭 시 5초 후 제목 변경
- 통신 시간 고려

7. Recycling Data
- 위의 경우는 body 내에서 글, 댓글을 한 번에 받아오기 때문에, 부분적인 업데이트가 어려움
- 케이스 별로 생성자를 계속 만들어야 하는 상황이 생김
- 이를 위해 Post의 내부 모델을 구분하여 저장할 수 있도록 모델의 분리가 필요
- Post : id, userId, title만 저장
- List<Reply> : id, comment, owner가 담긴 Reply 리스트 저장
- 변경된 값을 전송받았을 때에 Map으로 변환하는 fromMap 생성자를 각각 작성
class Post {
int? id;
int? userId;
String? title;
void update(String title) {
this.title = title;
}
// 일반 생성자
Post(this.id, this.userId, this.title);
// fromMap 생성자 (initializer list 사용)
Post.fromMap(Map<String, dynamic> data) : id = data['id'], userId = data['userId'], title = data['title'];
}
class Reply {
int? id;
String? comment;
String? owner;
// 일반 생성자
Reply(this.id, this.comment, this.owner);
// fromMap 생성자
Reply.fromMap(Map<String, dynamic> data) : id = data['id'], comment = data['comment'], owner = data['owner'];
}
- Post로 받던 데이터는 Post와 List<Reply>를 합친 PostModel로 변경
- copyWith를 통해 update한 post 또는 replies를 사용한 새 객체 생성으로 state 갱신
- 업데이트 된 데이터를 불러올 때에는 데이터 반환 여부에 따라 다르게 수행
- 반환되는 데이터가 없을 경우 : init() 함수 호출로 새로 데이터를 불러옴
- 반환되는 데이터가 있을 경우 : init() 함수 호출 또는 fromMap() 함수로 새 모델 생성 후 copyWith() 사용하여 state 갱신
import 'package:flutter_prac_data/model/post.dart';
import 'package:flutter_prac_data/model/reply.dart';
import 'package:flutter_prac_data/repository/post_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 1. 창고 관리자
final postProvider = NotifierProvider<PostVM, PostModel?>(() {
return PostVM();
});
// 2. 창고 (비지니스 로직)
class PostVM extends Notifier<PostModel?> {
PostRepository postRepository = PostRepository.instance;
@override
PostModel? build() {
init();
return null;
}
Future<void> updateV2() async {
// 통신 (제목10)
await postRepository.update();
Post prevPost = state!.post;
prevPost.update("제목10");
state = state!.copyWith(post: prevPost);
}
Future<void> update() async {
// 통신
final response = await postRepository.update();
Post nextPost = Post.fromMap(response);
state = state!.copyWith(post: nextPost);
}
Future<void> init() async {
final response = await postRepository.findById(1);
state = PostModel.fromMap(response);
}
}
// 3. 창고 데이터 타입
class PostModel {
final Post post;
final List<Reply> replies;
// 레코드
// 생성자
PostModel({required this.post, required this.replies});
// fromMap 생성자
PostModel.fromMap(Map<String, dynamic> data)
: post = Post.fromMap(data),
replies = (data["replies"] as List<dynamic>).map((e) => Reply.fromMap(e)).toList();
// copyWith
PostModel copyWith({
Post? post,
List<Reply>? replies,
}) {
return PostModel(
post: post ?? this.post,
replies: replies ?? this.replies,
);
}
}
Share article