상태 관리 라이브러리
앱에는 고정된 데이터와 변경되는 데이터가 존재한다. 앱의 주된 사용 목적 중 대부분은 변경되는 데이터의 조회로 이 데이터를 어떻게 관리하고 표현하는지가 앱의 성능, 사용성에 큰 영향을 미친다. 상태 관리 라이브러리는 변경되는 데이터를 클래스 내부에서 관리하고 위젯에 담아 화면에 출력하는 클래스간 데이터 흐름을 총괄한다.
상태 관리 라이브러리로는 대표적으로 Provider, Bloc, Riverpod, GetX가 존재한다. GetX는 이제 거의 안 쓰이고, Provider는 일전에 설명한 관계로 Bloc, Riverpod 두 라이브러리를 중심으로 설명한다.
Bloc
Business Logic Component
imperative (명령형) : 외부 자극을 단일 경로를 따라 내부 상태 변화로 매핑하는 상태머신
UI → Event → Bloc → State → UI
Bloc에서의 데이터 흐름은 다음과 같다.
- 외부 입력
- UI가 Bloc에 Event 전달
- Bloc 객체에서 로직 실행
- State 변경(emit)
- BlocBuilder가 변경 감지
- UI 반영(build)
- state machine : 외부 이벤트를 감지 시 로직을 실행시켜 내부의 상태를 변화시키는 상태 머신
- (현재 상태 + 이벤트 로직) ———(이벤트)——> (새 상태)
- Event기반 모델 - 상태 변경을 트리거한 동작을 명시하여 모든 변화를 추적(디버깅, 로깅, 테스트 용이)
add(IncrementEvent); //Bloc
ref.read(counter.notifier).increment(); //Riverpod
- 관련 객체
- BlocProvider: Bloc 객체를 트리에 주입
- Bloc: Widget에서 받은 Event를 처리하는 로직 수행
- BlocBuilder: State 구독 후 변경사항 UI에 반영
- MVVM 패턴 표방 - Widget(View)과 Bloc(Model)의 분리
위젯과 Bloc 코드
//일반 Widget
...
ElevatedButton(
onPressed: () {
count++;
setState(() {});
},
child: Text("증가"),
);
//UI단에서 상태를 변경하는 로직 직접 실행 -> V+M 강하게 결합
Widget build(BuildContext context) {
return Text('$count');
}
// setState에 의해 자동 빌드
//Bloc 적용 Widget
...
ElevatedButton(
onPressed: () {
context.read<CounterBloc>().add(IncrementEvent());
},
child: Text("증가"),
);
//UI단에서는 이벤트를 수신할 Bloc에 적당한 이벤트를 던지고
//Bloc에서 이벤트 감지 후 로직 실행하도록 분리
BlocBuilder<CounterBloc, int>(
builder: (context, state) {
return Text('$state');
},
);
//Bloc 로직 실행으로 인해 변경 감지 시 빌드되도록 상태 구독
//Bloc 내부
on<IncrementEvent>((event, emit) async {
emit(Loading());
final result = await apiCall(); //비동기 처리되는 콜백 등록
if (result) {
emit(Success(state + 1)); //로직 실행후 변경된 상태 emit
} else {
emit(Failure());
}
});
전통적인 setState()와 Bloc의 차이
- 상태의 소유권
- setState 구조: 상태(State)는 UI(StatefulWidget)와 강하게 결합(존재가 독립적이긴 해도 로직상 의존적)
- Bloc 구조: 상태는 UI 외부의 Bloc이라는 객체 안에서 Event를 거침으로써 한번 flow가 끊김→ 독립성 강화
- 상태 변화의 예측 가능성
- setState 구조: 위젯 내부에 변경 로직이 포함 + setState() 후 자동 빌드로 인해 입출력 흐름이 잘 안보임
- UI 이벤트 → state 변경 → build 재호출이 한 파일 내에서 암묵적으로 진행
- setState 호출, count++ 등으로 어느 위치에서든 상태를 자유롭게 수정 가능
- 변경 경로가 여러 개 존재 가능
- Bloc: UI와 로직이 완전 분리되어 상태 변화 추적에 용이
- add(Event)를 통한 입력(UI→Bloc)과 on<Event> 를 통한 출력(Bloc→UI) 흐름이 명시적으로 드러남
- 외부 이벤트라는 단일 시작점 강제
- 상태 변경 경로를 하나로 통일
- setState 구조: 위젯 내부에 변경 로직이 포함 + setState() 후 자동 빌드로 인해 입출력 흐름이 잘 안보임
Riverpod
Provider 구조를 계승하여 확장했다는 의미에서 애너그램(provider -> riverpod)
declarative (선언형) : 상태를 정의해두면 의존 관계에 따라 자동으로 갱신되는 반응형 시스템
UI → Notifier → State → Provider 의존성 전파 → Consumer → UI
Riverpod에서의 데이터 흐름은 다음과 같다.
- 외부 입력
- UI가 Notifier로 상태 변경 전달 (ref.read)
- Notifier에서 로직 실행
- State 변경
- Notifier를 소유한 Provider가 변경 감지 (ref.watch로 연결)
- 의존성 그래프 전파 (Consumer까지 전달)
- UI 반영(build)
- Provider의 상태 저장
- 상태 변경 감지
- watch 의존성 관리
- rebuild 트리거
Riverpod
├─ Provider (정의)
├─ Notifier (상태 조작)
├─ Consumer/ref.watch (구독)
└─ ProviderContainer (핵심 저장소 + 의존성 관리)
Provider
값을 제공하는 선언 단위로 값을 생성하는 규칙(함수)과 의존성을 정의하는 객체
- 상태를 직접 들고 있거나 다른 provider를 조합, 가공하는 객체 모두 해당
- ref.watch()로 연결된 의존성 그래프의 모든 노드는 provider
- 위젯이 State에 접근할 수 있는 유일한 통로(상태에 대한 식별자 제공)
- 대부분 상태를 직접 들고 있지 않고 Notifier로 접근
- 상태에 접근할 통로를 어떤 방식으로 제공할지 정의
final counterProvider = NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
위는 int 타입의 상태 count를 관리하는 CounterNotifier라는 객체에 외부 위젯이 접근할 수 있는 통로를 counterProvider라는 이름으로 정의한 코드이다.
Notifier
상태를 직접 관리하는 컨트롤러
- 실제로 state를 보유한 객체
- state를 변경하는 로직 포함
- 위젯에게 노출되는 상태변경 통로
- 위젯에 의한 상태 변경 흐름
- 위젯이 Notifier 메서드 호출
- Notifier가 state를 갱신
- ProviderContainer가 변경 감지
- watch 중인 위젯(Consumer)에 최신 state 전달 + rebuild
Consumer
상태를 구독하는 위젯
- provider를 watch하는 위젯으로 Notifier를 통해 상태 변경 로직에 접근 가능
- 상태가 바뀌면 자동 rebuild
ProviderContainer
(provider-consumer)쌍을 저장하고 State의 변경 감지 시, watch 등록한 위젯에게 변경된 상태 전달
- 앱 전체 상태 저장
- 모든 provider 인스턴스 관리
- 모든 Consumer의 watch 정보 추적
- 모든 State의 변경 감지
- Notifier가 state 변경 시 ProviderContainer가 이를 감지
- 변경된 상태가 속한 Provider 확인
- 그 Provider를 watch 중인 위젯 목록 조회
- 해당 위젯들에게 state 전달
ProviderContainer ← 최상위 상태 관리자
└─ Provider ← 상태 접근 경로 정의
└─ Notifier ← 상태 변경 관리 객체
└─ State ← 실제 상태
Bloc과 Riverpod
Bloc과 Riverpod의 유사점
- UI → Notifier → State → Provider → Consumer → UI
- UI → Event → Bloc → State → UI
- 이벤트 대신 Notifier 함수를 통한 상태 변경의 통제 구조 계승
- 상태 외부화 - 독립적인 상태를 두고 이를 참조하여 관리하는 객체(Bloc, Notifier)를 분리하여 상태 직접 수정 차단
- UI와 로직 분리
- Listen → Watch
- Bloc : 상태 변경 감지 책임이 수신 측에 존재 (상태를 인자로 받는 위젯 빌더)
- Riverpod : (송신 - 수신) 쌍을 따로 관리하는 제3자(ProviderContainer)에게 변경 감지 책임 위임(위젯이 Watch로 Provider 의존성 등록), 위젯은 UI 반영 책임만 존재
코드상 흐름 차이
Bloc: UI → add(Event) → Bloc → emit(state) → BlocBuilder 리빌드(위젯 리빌드)
Riverpod: UI → notifier.increment() → state 변경 → watch 중인 Provider가 감지 → watch 중인 Consumer가 감지 → 위젯 리빌드


// Riverpod 적용 위젯
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // 상태 구독
...
ElevatedButton(
onPressed: () {
ref.read(counterProvider.notifier).increment();
},
child: Text("증가"),
);
// UI단에서는 상태를 변경할 Notifier의 메서드를 호출
final count = ref.watch(counterProvider);
// 특정 상태를 구독하고 변경 시 자동 rebuild
Text('$count');
//Provider 내부 (provider + notifier)
final counterProvider =
NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() {
state++;
}
}
| Bloc | Riverpod |
| add(Event) | notifier.method() |
| emit(state) | state = |
| BlocBuilder | ref.watch() |
| state 인자 | final count 변수 |
Stream 기반 반응 vs 의존성 추적 기반 반응
상태 변경을 전달받고 반응하는 방식에 있어서 Stream기반의 Bloc과 의존성 추적 기반의 Riverpod 사이에는 본질적인 차이가 존재한다.
Bloc - 상태 변경 자체를 데이터로 표현하여 전달 - 이벤트 중심 아키텍쳐
Riverpod - 변경된 상태를 감지하도록 연관된 객체끼리 연결 - 의존성 중심 아키텍쳐



| Stream 기반 | 의존성 추적 기반 | |
| 중심 개념 | 데이터 흐름 | 의존 관계 |
| 연결 방식 | 구독(listener) 중심 | watch 등록 (제3자가 연결 관리) |
| 구조 | 선형 흐름 | 그래프 구조 |
| 철학 | 이벤트 드리븐 | 선언적 의존성 |
API 통신
실시간으로 변경되는 데이터를 앱 외부에서 받아오기 위해서는 API 통신이 필수적이다. http, Dio 같은 라이브러리를 사용하여 JSON과 다트 객체 간의 변환과 API 요청 전송 및 응답 처리를 구현할 수 있다.
API
Application Programming Interface
서로 다른 소프트웨어 애플리케이션이 서로 어떻게 통신하고 데이터를 공유할 지 미리 정의해둔 규칙이다. API를 통해 클라이언트는 서버의 내부 구조를 알지 못하더라도 약속한 형식에 맞춰 요청을 보내기만 하면 약속한 형식으로 응답을 받아올 수 있다.
- 어떻게 하는지는 몰라도 무엇을 하는지는 안다
플러터 앱에서 API 호출을 통해 데이터를 받아오는 흐름은 다음과 같다.
1. 사용자 UI 이벤트 처리
- 버튼 클릭이나 입력 변경에 따른 사용자 행동에 대해 이벤트 생성, 해당 이벤트리스너에 의한 API 통신 프로세스 시작
2. API 요청 전송
- 이벤트리스너에서 적절한 파라미터를 채운 후 http 클라이언트를 통해 API 요청 전송
3. API 응답 수신
- 서버로부터 받은 API 응답에 대한 수신 처리(상태 코드 확인, 에러 핸들링)
4. 응답 데이터 처리
- API 응답으로 받은 데이터(JSON, XML)의 파싱 및 변환 후 앱의 로직에 맞게 조작
플러터에서 API 관련 기능을 제공하는 대표적인 라이브러리로 http, Dio 2가지가 존재한다.
http 패키지
http 패키지는 HTTP(Hypertext Transfer Protocol)기반으로 API 요청을 작성하고 응답을 처리할 수 있도록 일반적인 http 메서드를 제공한다.
GET (http.get)
데이터 조회
//패키지 내부 정의
Future<Response> get(Uri url, {Map<String, String>? headers})
//사용 예시
final response = await http.get(
'https://api.example.com/users', headers : {'Authorization': 'Bearer token'}
);
- url : 요청을 보낼 목적지 URL
- header: GET 요청에 필요한 정보(일반적으로 인증 정보 또는 content-type)
POST (http.post)
데이터 생성
// 패키지 내부 정의
Future<Response> post(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
})
// 사용 예시
final response = await http.post(
Uri.parse('https://api.example.com/users'),
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
body: '{"name": "John"}',
);
- body: 서버에서 POST 요청에 대한 처리로 새 데이터를 생성할 때 필요한 정보
PUT (http.put)
전체 수정
// 패키지 내부 정의
Future<Response> put(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
})
// 사용 예시
final response = await http.put(
Uri.parse('https://api.example.com/users/1'),
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
body: '{"name": "Updated"}',
);
PATCH (http.patch)
부분 수정
// 패키지 내부 정의
Future<Response> patch(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
})
// 사용 예시
final response = await http.patch(
Uri.parse('https://api.example.com/users/1'),
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
body: '{"name": "Partial Update"}',
);
DELETE (http.delete)
데이터 삭제
// 패키지 내부 정의
Future<Response> delete(
Uri url, {
Map<String, String>? headers,
Object? body,
Encoding? encoding,
})
// 사용 예시
final response = await http.delete(
Uri.parse('https://api.example.com/users/1'),
headers: {'Authorization': 'Bearer token'},
);
멱등성
멱등성은 어떤 연산을 여러 번 수행해도 결과가 달라지지 않는 성질로 네트워크로 전달되는 API 통신 특성상 로직 설계에 중요한 고려 사항이 된다. 서버에서는 http의 각 메서드 특징을 고려하여 로직을 설계해야한다.
| 메서드 | 멱등성 | 반복 요청 시 행동 | 예시 |
| GET | O | 상태 변화 없이 같은 결과 반환 | GET /users/1 |
| POST | X | 호출마다 새 데이터 생성 | POST/users/ |
| PUT | O | 받은 값으로 덮어쓰기(결과 동일) |
PUT /users/1
{name: "John"}
|
| PATCH | △ | 상황에 따라 결과 달라질 수 있음 |
PATCH /users/1
{count: count + 1}
|
| DELETE | O | 해당하는 데이터 존재할 경우에 삭제 | DELETE /users/1 |
- 같은 요청을 여러 번 보내도 한 번 보낸 결과와 동일하면 멱등
- 응답이 아닌 최종 상태 기준으로 판단
API 응답 데이터 처리
API 응답 데이터는 주로 다음과 같은 순서로 처리되어 UI에 반영된다.
JSON → Map → Model(다트 객체) → State → UI
1. API 응답 (JSON)
2. jsonDecode() 호출: JSON → Map
- jsonDecode() 메서드가 JSON 문자열 파싱 후 Dart 기본 타입 Map<String, dynamic> 객체로 역직렬화
3. fromJson() 호출: Map → 모델
- 미리 정의해둔 Model.fromJson() 메서드가 Map<String, dynamic> 객체를 Model 객체로 변환
4. State 변경
5. UI 표시
다음과 같은 구조로 API를 처리하도록 설계된 애플리케이션에서 각 컴포넌트의 역할과 예시 코드는 다음과 같다.
UI
↓
Notifier (상태관리)
↓
Repository (비즈니스 로직)
↓
ApiService (HTTP + 응답 처리)
↓
http client
Service = 실제 네트워크 통신 수행
Model = 외부 데이터를 앱 내부에서 사용할 수 있도록 구조화한 Dart 객체 (응답의 최종 변환 결과)
Repository = 데이터 변환 및 비즈니스 로직 (데이터 조합, 조건 처리, 캐싱 전략)
Notifier = 상태 관리 및 변경 흐름(success, loading, error) 제어
Provider = 변경 전파를 위한 의존성 연결 및 진입점 제공
UI = 변경된 상태를 화면에 표시
Service
class ApiService {
final String baseUrl;
ApiService(this.baseUrl);
Future<Map<String, dynamic>> get(String path) async {
final response = await http.get( //http 메서드 get 호출
Uri.parse('$baseUrl$path'),
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
);
return _handleResponse(response);
}
Map<String, dynamic> _handleResponse(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonDecode(response.body);
} else {
throw Exception('API Error: ${response.statusCode}');
}
}
}
Model
class User {
final int id;
final String name;
User({required this.id, required this.name});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
);
}
}
Repository
class UserRepository {
final ApiService api;
UserRepository(this.api);
Future<User> getUser() async {
final data = await api.get('/users/1'); //서비스의 메서드 get 호출
return User.fromJson(data);
}
}
Notifier
class UserNotifier extends StateNotifier<User?> {
final UserRepository repository;
UserNotifier(this.repository) : super(null);
Future<void> fetchUser() async {
state = await repository.getUser(); //상태 변경
}
}
Provider
final apiServiceProvider = Provider(
(ref) => ApiService('https://api.example.com'),
);
final userRepositoryProvider = Provider(
(ref) => UserRepository(ref.read(apiServiceProvider)),
);
final userProvider = StateNotifierProvider<UserNotifier, User?>(
(ref) => UserNotifier(ref.read(userRepositoryProvider)),
);
UI
...
final user = ref.watch(userProvider);
ElevatedButton(
onPressed: () {
ref.read(userProvider.notifier).fetchUser();
},
child: Text('Load User'),
);
Text(user?.name ?? 'Loading...');
Dio 라이브러리
Dio는 플러터 애플리케이션 전용으로 최적화된 네트워킹 및 파일 입출력을 제공하는 라이브러리이다. http 패키지 위에 설계된 Dio의 유연한 http 기능 및 인터셉터, 타임아웃을 활용하여 API 요청 및 응답 처리 로직을 간편하게 구성할 수 있다. 다음은 Dio에서 추가로 제공하는 기능이다.
- http 클라이언트 기본 옵션 설정: http 클라이언트의 기본 헤더, 기본 URL, 연결 시간 제한 등의 옵션을 쉽게 설정 가능
- 인터셉터 지원: 인터셉터를 통해 모든 요청 및 응답에 네트워크 통신의 공통 로직 적용 가능 (토큰 자동 갱신, 공통 헤더 추가, 공통 응답 처리, 로깅, 전역 오류 처리 등)
- 사용자 정의: 사용자 지정 형식 처리나 직렬화 로직 등 자유도 높은 요청 및 응답 변환기 구현 가능
- 파일 업로드 및 다운로드: FormData, MultipartFile 등 대용량 파일 업로드/다운로드 기능 지원
Dio API 요청
Dio를 통해 http 요청을 보내기 위해서는 클라이언트 기본 설정을 적용한 Dio 인스턴스를 만들어야한다.
final dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
headers: {
'Authorization': 'Bearer token',
'Content-Type': 'application/json',
},
connectTimeout: Duration(seconds: 5),
receiveTimeout: Duration(seconds: 5),
),
);
GET (dio.get)
dio.get(String)
// 내부 정의
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
});
// 사용 예시
Future<void> fetchUser() async {
try {
final response = await dio.get('/users/1'); //파라미터로 엔드포인트 url 전달
print(response.data);
} on DioException catch (e) {
// Dio 전용 에러 처리
print('Dio error: ${e.message}');
if (e.response != null) {
print('StatusCode: ${e.response?.statusCode}');
print('Error Data: ${e.response?.data}');
}
} catch (e) {
// 기타 에러
print('Unknown error: $e');
}
}
POST (dio.post)
dio.post(String, Map)
// 내부 정의
Future<Response<T>> post<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
});
// 사용 예시
Future<void> createUser() async {
try {
final response = await dio.post(
'/users', //첫번째 파라미터 = 엔드포인트 url
data: {'name': 'John'}, //두번째 파라미터 = body
);
print('Created: ${response.data}');
} on DioException catch (e) {
if (e.response?.statusCode == 400) {
print('잘못된 요청');
} else if (e.response?.statusCode == 401) {
print('인증 필요');
} else {
print('기타 서버 에러');
}
} catch (e) {
print('Unexpected error: $e');
}
}
PUT (dio.put)
dio.put(String, Map)
//내부 정의
Future<Response<T>> put<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
});
// 사용 예시
Future<void> updateUser() async {
try {
final response = await dio.put(
'/users/1', //첫번째 파라미터 = 엔드포인트 url
data: {'name': 'Updated Name'} //두번째 파라미터 = body
,
);
print('Updated: ${response.data}');
} on DioException catch (e) {
print('Error: ${e.message}');
if (e.response != null) {
print('StatusCode: ${e.response?.statusCode}');
print('Error Data: ${e.response?.data}');
}
} catch (e) {
print('Unexpected error: $e');
}
}
DELETE (dio.delete)
dio.delete(String)
//내부 정의
Future<Response<T>> delete<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
});
//사용 예시
Future<void> deleteUser() async {
try {
final response = await dio.delete(
'/users/1',
);
print('Deleted: ${response.data}');
} on DioException catch (e) {
print('Error: ${e.message}');
if (e.response != null) {
print('StatusCode: ${e.response?.statusCode}');
}
} catch (e) {
print('Unexpected error: $e');
}
}
Dio 라이브러리 내부 http 메서드 정의
| 파라미터 | 의미 |
| path | 요청 경로 |
| data | body (POST/PUT/DELETE 가능) |
| queryParameters | URL 쿼리 |
| options | 헤더 등 추가 설정 |
| cancelToken | 요청 취소 |
| onSendProgress | 업로드 진행률 |
| onReceiveProgress | 다운로드 진행률 |
POST, PUT 요청에서 대용량 파일 전송
http 요청에서 body에 해당하는 두번째 파라미터(Object)에 Map 형식으로 전송
final formData = FormData.fromMap({
'name': 'john',
'file': await MultipartFile.fromFile(
'/path/image.png',
filename: 'image.png',
),
}); //텍스트와 파일 데이터를 담은 Map 정의
final response = await dio.post(
'/upload',
data: formData, //data 파라미터로 정의해둔 Map 전송
);
'dev > app' 카테고리의 다른 글
| flutter - 플러터로 크로스 플랫폼 앱 개발하기(4) (0) | 2026.02.28 |
|---|---|
| flutter - 플러터로 크로스 플랫폼 앱 개발하기(2) (0) | 2026.02.27 |
| flutter - 플러터로 크로스 플랫폼 앱 개발하기(1) (0) | 2026.02.22 |