| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- FastAPI
- 인증
- JWT
- 프론트엔드
- typescript
- 마이그레이션
- 크롤링
- Android
- 온디바이스AI
- React
- 소셜로그인
- contextBridge
- 도메인설계
- 상태관리
- Kotlin
- playwright
- 아키텍처
- 메일자동화
- 보안
- next.js
- php
- electron
- 배포
- sqlite
- 백엔드
- python
- REST API
- javascript
- github actions
- Vite
- Today
- Total
HM소프트
Flutter 크로스플랫폼 앱 개발기 — Riverpod 상태관리와 Dio API 연동 (4) 본문
한 코드베이스로 모바일·웹·데스크톱을 빌드하는 Flutter 앱 구조. Riverpod 불변 상태와 copyWith·StateNotifier로 화면과 데이터를 떼고, FutureProvider.family로 상세를 캐시한다. Dio로 타임아웃·조건부 파라미터를 묶고, fromJson이 None·타입 혼입을 흡수하며 즐겨찾기는 낙관적 업데이트로 처리한다.
시리즈를 닫으며 — 드디어 사용자가 만지는 화면
이 시리즈의 1~3편은 전부 눈에 안 보이는 일이었다. 외부 화면을 긁어 데이터를 모으는 크롤러(1편), 그 데이터를 깨끗하게 정규화해 내려보내는 API와 검색·인증(2편), 그걸 매니지드 호스팅에 올리는 배포(3편). 사용자 입장에서는 아무것도 손에 잡히지 않는다. 마지막 4편은 그 모든 백엔드 노동의 결과를 사용자 손에 쥐여 주는 클라이언트 앱 이야기다.
앱은 처음부터 한 가지 제약을 안고 시작했다. 나는 혼자고, 화면은 여러 곳에서 돌아야 한다. 모바일이 중심이지만 웹 데모도 보여 줘야 했고, 데스크톱에서도 띄울 수 있으면 좋았다. iOS 따로, 안드로이드 따로, 웹 따로 짜는 건 한 사람이 감당할 일이 아니다. 그래서 한 코드베이스로 전 플랫폼을 빌드하는 크로스플랫폼 프레임워크가 필요했고, Flutter(Dart)를 골랐다.
이 글은 "Flutter 좋아요"라는 홍보가 아니라, 혼자서 여러 화면을 다 그리려면 어떤 구조를 잡아야 하는가에 대한 기록이다. 상태관리를 어떻게 했는지, 백엔드 API를 어떻게 연동했는지, 화면 전환과 무한 스크롤·즐겨찾기 같은 흔하지만 막상 짜면 까다로운 것들을 어떻게 처리했는지를, 실제 코드에 근거해 풀어 본다. 민감 도메인이라 시설의 종류나 실제 화면 문구는 일반화했고, 구조 이야기만 다룬다.
한 코드, 여러 플랫폼 — 폴더 구조가 말해 주는 것
Flutter 프로젝트를 만들면 플랫폼마다 폴더가 하나씩 생긴다. 이 프로젝트도 android/, ios/, windows/, macos/, linux/, web/가 전부 들어 있다. 처음 봤을 때 "이걸 다 관리해야 하나" 싶지만, 실제로 내가 코드를 쓰는 곳은 단 하나, lib/ 안의 Dart 코드뿐이다.
lib/
├── main.dart # 앱 진입점
├── core/ # 상수·테마 (전역 설정)
│ ├── constants/ # API 주소, 분류 목록, 정렬 옵션
│ └── theme/ # 색상·타이포 한곳에
├── models/ # 데이터 모델 (JSON ↔ 객체)
├── services/ # API 호출, 위치 서비스
├── providers/ # 상태관리 (Riverpod)
├── routes/ # 화면 전환 정의 (go_router)
├── screens/ # 화면 (홈·검색·상세·즐겨찾기·지도)
└── widgets/ # 재사용 위젯 (카드·칩·검색바)
플랫폼별 폴더(android/, ios/ …)는 빌드 설정과 네이티브 진입점일 뿐이다. 권한 선언이나 앱 아이콘처럼 플랫폼이 강제하는 것만 거기서 건드린다. 실제 앱 로직 — 화면이 어떻게 생겼고 무엇을 하는지 — 는 전부 lib/ 한 곳에 있고, 그게 모든 플랫폼에서 똑같이 돈다. 이 "한 번 짜면 어디서나"가 혼자 개발하는 입장에서는 결정적이었다.
내부 구조에서 신경 쓴 건 관심사를 폴더로 못 박는 일이었다. models는 데이터의 모양만, services는 바깥 세계(API·GPS)와의 통신만, providers는 상태만, screens/widgets는 화면만 책임진다. 화면 코드 안에서 직접 HTTP를 호출하거나, API 코드 안에서 위젯을 만드는 일이 구조적으로 일어나지 않게 칸을 나눴다. 이 칸막이 덕분에 "검색이 안 돼"라는 버그가 나면 services → providers → screens 순으로 한 층씩 좁혀 들어가면 된다.

의존성을 고르는 일 — pubspec 한 장의 무게
Flutter에서 외부 패키지는 pubspec.yaml에 선언한다. 이 한 장이 사실상 앱의 기술적 골격을 결정한다. 어떤 패키지를 넣느냐가 곧 "상태는 무엇으로 관리할지, 네트워크는 뭘 쓸지, 화면 전환은 어떻게 할지"의 답이기 때문이다. 그래서 의존성은 충동적으로 추가하지 않고, 역할별로 한 번에 정리해 골랐다.

각 선택에는 이유가 있었다. 상태관리는 Riverpod — 뒤에서 자세히 다루지만, 화면과 데이터를 깔끔하게 떼어 놓기 위해서다. 네트워크는 Dio — 기본 http 패키지보다 타임아웃·인터셉터·쿼리 파라미터 처리가 편하다. 로컬 저장소는 둘 — 간단한 키-값(사용자 식별자 같은)은 shared_preferences, 구조화된 캐시는 hive로 역할을 나눴다. 이미지는 cached_network_image — 외부에서 긁어 온 이미지 URL을 매번 다시 받지 않게 캐싱한다. 화면 전환은 go_router로, URL 기반 라우팅을 써서 웹에서도 자연스럽게 동작하게 했다.
여기서 의도적으로 안 넣은 것도 있다. 무거운 의존성 주입 프레임워크나 복잡한 아키텍처 패키지는 피했다. 혼자 만드는 앱에서 과한 추상화는 속도만 깎아 먹는다. "필요한 만큼만, 검증된 패키지로"가 이 한 장의 원칙이었다.
진입점은 얇게 — main.dart가 하는 일
main.dart는 앱의 시작점이지만, 일부러 거의 아무 일도 안 하게 뒀다. 진입점이 비대해지면 앱 전체의 초기화 순서가 한 함수에 엉켜 디버깅이 어려워진다.

핵심은 두 가지다. 첫째, ProviderScope로 앱 전체를 감쌌다. 이게 Riverpod 상태가 사는 최상위 그릇이다. 이 안에서만 모든 상태(provider)가 공유된다. 둘째, MaterialApp.router에 라우팅 설정과 테마를 넘겨 주기만 하고, 정작 "화면이 어떻게 생겼는지"는 한 줄도 여기 없다. 테마는 AppTheme, 화면 전환은 appRouter가 책임진다. 진입점은 "초기화하고, 위임한다"만 하면 된다.
Hive.initFlutter()를 await로 먼저 부른 데는 이유가 있다. 로컬 캐시는 화면이 그려지기 전에 준비돼 있어야 한다. 화면이 먼저 뜨고 저장소가 나중에 초기화되면, 첫 화면에서 캐시를 읽을 때 아직 준비가 안 돼 있는 경쟁 상태가 생긴다. 그래서 ensureInitialized() → 저장소 초기화 → runApp 순서를 못 박았다.
화면 전환을 URL로 — go_router
여러 플랫폼을 동시에 노리니 화면 전환에서 한 가지가 중요해졌다. 웹에서도 자연스러워야 한다. 웹은 주소창이 있고 뒤로 가기 버튼이 있다. 모바일식으로 화면을 스택에 막 쌓기만 하면 웹에서 URL이 엉키고 뒤로 가기가 망가진다. 그래서 모든 화면을 URL 경로에 일대일로 대응시키는 go_router를 썼다.

이렇게 해 두면 화면 하나하나가 곧 주소가 된다. /detail/42처럼 경로에 식별자를 박아 상세 화면을 열고, 그 화면 코드는 state.pathParameters['id']로 식별자를 꺼낸다. 모바일에서는 평범한 화면 전환이지만, 웹에서는 그대로 공유 가능한 URL이 된다. 사용자가 상세 화면에서 새로고침해도, 주소가 /detail/42니까 같은 화면이 다시 뜬다 — SPA에서 흔히 밟는 "새로고침하면 화면이 사라지는" 지뢰를 라우터 차원에서 피한 것이다.
errorBuilder도 한곳에 뒀다. 정의되지 않은 경로로 진입하면 무조건 "찾을 수 없음" 화면으로 떨어진다. 화면마다 404를 처리하는 게 아니라, 라우터가 일괄로 받아 낸다. 화면 코드는 "내 경로가 맞다"는 전제 위에서만 짜면 된다.
상태관리의 심장 — Riverpod로 화면과 데이터 떼어 놓기
앱에서 가장 공들인 부분이 상태관리다. 검색 결과 목록, 로딩 중인지, 에러가 났는지, 지금 몇 페이지인지, 즐겨찾기는 무엇인지 — 이 모든 "지금 상태"를 화면 위젯 안에 박아 두면, 화면이 곧 거대한 진흙 덩어리가 된다. 그래서 상태를 화면 바깥으로 빼내, 화면은 상태를 "보고 그리기만" 하게 만들었다. 그 도구가 Riverpod이다.
핵심 발상은 상태를 불변 객체로 들고, 바꿀 땐 통째로 새 객체로 교체하는 것이다. 검색 화면의 모든 상태를 SearchState 한 객체에 모으고, 일부만 바꾸고 싶을 때는 copyWith로 바뀐 필드만 갈아 끼운 새 객체를 만든다.

copyWith에서 한 가지 의도적인 비대칭이 있다. 대부분의 필드는 값 ?? this.값으로 "안 넘기면 기존 값 유지"인데, error만은 그냥 error다. 이게 작지만 중요하다. 에러는 매 동작마다 자동으로 비워져야 한다. 새 검색을 시작하면 직전 에러는 사라져야지, 깜빡 유지되면 성공했는데도 빨간 에러가 화면에 남는다. 그래서 에러는 "넘기지 않으면 null로 떨어지게" 일부러 다르게 처리했다.
이 상태를 실제로 조작하는 건 StateNotifier다. 검색·페이지 더 불러오기·분류 변경·정렬 변경 같은 동작이 전부 여기 모인다.

이 한 클래스에 작은 방어들이 촘촘하게 박혀 있다. 맨 앞의 if (state.isLoading) return;은 중복 요청 차단이다. 사용자가 빠르게 두 번 스크롤하거나 버튼을 연타해도, 이미 불러오는 중이면 두 번째 호출은 그냥 무시된다. refresh 플래그 하나로 "새 검색(목록 교체)"과 "더 보기(이어 붙이기)"를 가른 것도 핵심이다 — refresh ? fetched : [...state.items, ...fetched] 이 한 줄이 무한 스크롤의 본질이다. loadMore는 마지막 페이지면 조용히 멈춘다.

provider를 조립하다 — 위치 정보를 곁들이는 법
위 search 코드에 슬쩍 지나간 한 줄이 있다. _ref.read(currentLocationProvider.future). 검색을 하려면 "내 현재 위치"가 필요한데, 위치를 얻는 일은 그 자체로 비동기에 권한 처리까지 얽힌 별개의 일이다. 이걸 검색 로직 안에 욱여넣으면 검색 함수가 GPS 권한까지 책임지는 괴물이 된다. 그래서 위치를 독립된 provider로 떼어 내고, 검색은 그걸 가져다 쓰기만 했다.

Riverpod의 진짜 강점이 여기서 나온다. provider가 다른 provider를 부품처럼 끼워 쓴다. 검색 provider는 위치 provider를 read하고, 위치 provider는 위치 서비스를 watch한다. 의존이 한 방향으로 흐르고, 각 provider는 자기 일만 한다. 위치를 못 받아도(권한 거부 등) null로 떨어질 뿐, 검색 자체는 위치 없이 진행된다. 위치 없는 검색은 거리 정렬만 안 될 뿐 결과는 나온다.
FutureProvider.family도 영리한 도구다. 상세 화면은 항목마다 다르니, id를 매개변수로 받아 id별로 따로 캐시되는 provider를 만든다. 같은 항목을 두 번 열면 두 번째는 캐시에서 즉시 뜬다. 화면 코드는 그냥 ref.watch(detailProvider(42))라고 쓰면 끝이고, 캐싱이며 중복 호출 방지는 Riverpod이 알아서 한다.
백엔드와 연결하기 — Dio로 감싼 API 계층
상태관리가 앱의 심장이라면, API 서비스는 백엔드(1~3편)와 잇는 동맥이다. 화면이나 상태 코드가 직접 HTTP를 부르면 주소·헤더·타임아웃 같은 잡일이 곳곳에 흩어진다. 그래서 모든 네트워크 호출을 ApiService 한 클래스에 가두고, Dio로 공통 설정을 한 번만 했다.
class ApiService {
late final Dio _dio;
ApiService() {
_dio = Dio(BaseOptions(
baseUrl: ApiConstants.baseUrl,
connectTimeout: const Duration(seconds: 10), // 연결 10초
receiveTimeout: const Duration(seconds: 10), // 응답 10초
headers: {'Content-Type': 'application/json'},
));
_dio.interceptors.add(LogInterceptor( // 모든 요청/응답 로깅
requestBody: true, responseBody: true,
));
}
Future<Map<String, dynamic>> search({
String? keyword, String? category,
double? latitude, double? longitude,
double radius = 5.0, String sortBy = 'distance',
int page = 1, int limit = 20,
}) async {
try {
final res = await _dio.get(ApiConstants.search, queryParameters: {
if (keyword != null) 'keyword': keyword,
// '전체'는 필터가 아니므로 아예 파라미터에서 뺀다
if (category != null && category != '전체') 'category': category,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
'radius': radius, 'sort_by': sortBy,
'page': page, 'limit': limit,
});
final data = res.data;
return {
'items': (data['data'] as List)
.map((j) => Facility.fromJson(j)).toList(),
'page': data['page'],
'totalPages': data['total_pages'],
};
} catch (e) {
throw Exception('검색 실패: $e');
}
}
}
이 계층에서 신경 쓴 세 가지가 있다. 첫째, 타임아웃을 명시했다. 외부 데이터에 의존하는 백엔드라 응답이 늦을 수 있는데, 타임아웃이 없으면 화면이 영원히 로딩 스피너를 돈다. 연결·응답 각각 10초로 끊어, 늦으면 차라리 에러로 떨어뜨려 사용자가 다시 시도할 수 있게 했다.
둘째, 쿼리 파라미터의 조건부 포함이다. Dart의 if (x != null) 'key': x 문법(컬렉션 if)으로, 값이 있을 때만 파라미터에 넣는다. 특히 분류가 '전체'일 때는 아예 파라미터에서 빼 버린다 — "전체"는 필터가 아니라 "필터 없음"이라는 뜻이니까. 이 작은 처리로 백엔드는 깔끔한 쿼리만 받는다.
셋째, 에러를 도메인 예외로 변환한다. Dio가 던지는 저수준 예외를 그대로 위로 올리지 않고, Exception('검색 실패: ...')로 한 번 감싼다. 상태관리 계층은 이 메시지를 그대로 state.error에 담아 화면에 띄우면 된다. 네트워크의 세부(소켓이니 타임아웃이니)는 이 계층 안에서 끝난다.
빈 값과의 싸움 — 모델이 받아 내는 None
2편에서 백엔드가 그토록 None과 싸운 이유가 여기서 드러난다. 외부에서 긁어 온 데이터는 좌표가 없거나, 평점이 비거나, 이미지 목록이 통째로 빠진 경우가 흔하다. 백엔드가 1차로 정규화해 주지만, 클라이언트 모델도 마지막 방어선을 쳐야 한다. JSON을 객체로 바꾸는 fromJson이 그 자리다.
factory Facility.fromJson(Map<String, dynamic> json) {
return Facility(
id: json['id'],
name: json['name'],
category: json['category'],
address: json['address'],
// 좌표·전화는 없을 수 있다 → nullable + 안전 변환
latitude: json['latitude']?.toDouble(),
longitude: json['longitude']?.toDouble(),
phone: json['phone'],
// 목록형은 null이면 null로, 있으면 String 리스트로
departments: json['departments'] != null
? List<String>.from(json['departments']) : null,
// 불리언 플래그는 없으면 false로 — '정보 없음'을 '아니오'로 안전 처리
hasParking: json['has_parking'] ?? false,
hasNightCare: json['has_night_care'] ?? false,
hasWeekendCare: json['has_weekend_care'] ?? false,
// 숫자는 없으면 0 — toDouble로 int/double 혼입까지 흡수
averageRating: (json['average_rating'] ?? 0).toDouble(),
totalReviews: json['total_reviews'] ?? 0,
distance: json['distance']?.toDouble(),
);
}
여기 박힌 패턴들이 전부 "빈 값일 수 있다"는 전제에서 나왔다. json['latitude']?.toDouble()의 ?.는 좌표가 없으면 변환을 시도하지 않고 그냥 null로 둔다. json['has_parking'] ?? false의 ?? false는 "주차 정보가 없으면 없는 것으로 친다"는 보수적 기본값이다. 평점은 (json['average_rating'] ?? 0).toDouble()로, 없으면 0, 있으면 무조건 double로 만든다 — 백엔드가 4(int)로 주든 4.5(double)로 주든 둘 다 받아 낸다. JSON의 숫자 타입이 들쭉날쭉한 건 흔한 일이라, 이 toDouble() 한 번이 "타입이 안 맞는다"는 런타임 크래시를 통째로 막는다.
모델 필드 대부분이 ?(nullable)인 것도 같은 이유다. 좌표·전화·운영시간·취급 분야는 있을 수도 없을 수도 있다고 타입 자체에 새겨 뒀다. 그러면 화면 코드는 "이 값이 null일 수 있다"를 컴파일러가 강제로 상기시켜 준다. 깜빡하고 null인 값을 그냥 쓰면 빌드가 안 된다. 빈 값과의 싸움을 타입 시스템에 떠넘긴 것이다.
화면 그리기 — 위젯은 상태를 보고 분기만
상태와 데이터가 화면 바깥에 잘 정리돼 있으니, 화면 코드는 놀랍도록 단순해진다. 위젯이 하는 일은 단 하나, "지금 상태가 어떤가"를 보고 그에 맞는 화면을 고르는 것뿐이다. 홈 화면이 좋은 예다.
final searchState = ref.watch(searchProvider); // 상태를 '구독'
// 상태에 따라 다른 화면을 그린다
if (searchState.isLoading && searchState.items.isEmpty)
return const Center(child: CircularProgressIndicator()); // 첫 로딩
else if (searchState.error != null && searchState.items.isEmpty)
return _ErrorView(onRetry: () => // 에러 + 재시도
ref.read(searchProvider.notifier).search(refresh: true));
else if (searchState.items.isEmpty)
return const _EmptyView(); // 결과 없음
else
return SliverList( // 결과 목록
delegate: SliverChildBuilderDelegate(
(context, index) {
// 목록 끝에 닿으면 다음 페이지 자동 로딩 (무한 스크롤)
if (index == searchState.items.length) {
if (searchState.currentPage < searchState.totalPages) {
ref.read(searchProvider.notifier).loadMore();
return const Center(child: CircularProgressIndicator());
}
return null;
}
final item = searchState.items[index];
return FacilityCard(
facility: item,
isFavorite: favoritesState.favoriteIds.contains(item.id),
onTap: () => context.push('/detail/${item.id}'),
onFavoriteToggle: () =>
ref.read(favoritesProvider.notifier).toggleFavorite(item),
);
},
),
);
ref.watch(searchProvider) 한 줄이 마법이다. 화면은 상태를 구독하고, 상태가 바뀌면 Flutter가 알아서 화면을 다시 그린다. 로딩이 시작되면 스피너가 뜨고, 결과가 도착하면 목록이 뜨고, 에러가 나면 재시도 버튼이 뜬다 — 화면 코드는 이 전이를 직접 관리하지 않는다. 그저 "이 상태면 이 화면"이라는 분기 규칙만 선언한다.
무한 스크롤도 영리하게 처리했다. 목록의 마지막 인덱스(index == items.length)에 닿으면, 그 자리에서 loadMore()를 부르고 스피너를 끼워 넣는다. 사용자가 끝까지 스크롤하면 다음 페이지가 자동으로 이어 붙는다. 별도의 스크롤 리스너를 다는 대신, 목록 빌더가 끝에 도달하는 순간을 신호로 삼은 것이다. 빈 화면(_EmptyView)·에러 화면(_ErrorView)을 작은 위젯으로 따로 뺀 것도, 홈 화면 코드가 분기 흐름만 보이게 하려는 정리다.
즐겨찾기 — 낙관적 업데이트로 손맛 살리기
마지막으로 손맛을 챙긴 부분이 즐겨찾기다. 하트를 눌렀을 때 서버 응답을 기다렸다가 채워지면, 누른 뒤 화면이 한 박자 늦게 반응해 답답하다. 그래서 누르는 즉시 화면을 먼저 바꾸고, 서버 요청은 뒤에서 보내는 낙관적 업데이트(optimistic update)를 택했다.
Future<void> toggleFavorite(Facility item) async {
try {
final userId = await _ref.read(userIdProvider.future);
final isFav = state.favoriteIds.contains(item.id);
if (isFav) { // 이미 즐겨찾기 → 해제
await _api.removeFavorite(userId, item.id);
state = state.copyWith(
favorites: state.favorites.where((h) => h.id != item.id).toList(),
favoriteIds: {...state.favoriteIds}..remove(item.id),
);
} else { // 아니면 → 추가
await _api.addFavorite(userId, item.id);
state = state.copyWith(
favorites: [...state.favorites, item],
favoriteIds: {...state.favoriteIds, item.id},
);
}
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
상태를 두 가지로 들고 있는 게 포인트다. 전체 즐겨찾기 객체 목록(favorites)과, 빠른 조회용 ID 집합(favoriteIds)이다. 화면에서 "이 항목이 즐겨찾기인가?"는 수백 번 호출되는데, 매번 객체 리스트를 훑으면 느리다. 그래서 Set<int>을 따로 둬서 favoriteIds.contains(id)로 상수 시간에 판정한다. 추가/해제할 때 두 자료구조를 함께 갱신해 둔 덕분에, 카드마다 하트 상태를 즉시 그릴 수 있다.
{...state.favoriteIds, item.id}처럼 항상 새 집합을 만드는 것도 의도적이다. 기존 집합을 그 자리에서 수정(mutate)하면 Riverpod이 "상태가 바뀌었다"를 감지하지 못해 화면이 안 그려진다. 불변성을 지켜 새 객체로 교체해야 구독자(화면)가 변화를 알아챈다. 앞의 copyWith와 똑같은 철학이 여기서도 관철된다 — 상태는 고치지 말고, 새로 만들어 갈아 끼운다.
사용자 식별자는 shared_preferences에 저장한 값을 쓴다. 앱 첫 실행 때 한 번 발급해 로컬에 저장해 두고, 이후엔 그걸 계속 읽는다. 로그인 없이도 "내 즐겨찾기"를 기기 단위로 유지하는, 가장 가벼운 방법이다.
정리하며 — 혼자서 여러 화면을 다 그리는 법
크로스플랫폼 앱을 혼자 만들며 얻은 교훈을 줄이면 이렇다.
- 한 코드, 여러 플랫폼.
lib/의 Dart 코드 하나가 모바일·데스크톱·웹 전부에서 돈다. 플랫폼별 폴더는 빌드 설정일 뿐. 혼자 개발하는 입장에서 이 한 줄이 모든 걸 가능하게 했다. - 관심사를 폴더로 못 박는다. models·services·providers·screens·widgets로 칸을 나눠, 화면이 직접 HTTP를 부르거나 API가 위젯을 만드는 일이 구조적으로 안 생기게 했다.
- 상태는 화면 바깥에, 불변으로. Riverpod의
StateNotifier+copyWith로 상태를 통째로 교체한다. 화면은 상태를 구독해 "이 상태면 이 화면"만 선언하고, 전이 관리는 하지 않는다. - provider를 부품처럼 조립한다. 위치는 위치 provider, 상세는 id별 family provider — 각자 자기 일만 하고 서로를 끼워 쓴다. 캐싱·중복 방지는 프레임워크가 한다.
- 네트워크는 한곳에 가두고, 빈 값은 타입으로 막는다. 타임아웃·조건부 파라미터·도메인 예외 변환을 API 계층에 모았고, 모델의
fromJson이 None·타입 혼입을 마지막으로 흡수한다. - 손맛은 낙관적 업데이트로. 즐겨찾기는 누르는 즉시 화면을 바꾸고 서버는 뒤에서 처리한다. 빠른 조회용
Set을 곁들여 하트 상태를 상수 시간에 판정한다.
네 편을 관통하는 한 문장은 이거였다. "지저분한 외부 데이터를 백엔드가 모아 깨끗하게 정규화하고, 클라이언트는 그걸 받아 단순하게 보여 준다." 1~3편에서 백엔드가 데이터의 지저분함을 흡수해 준 덕에, 이 앱은 끝까지 가볍고 단순하게 유지됐다. 화면 코드가 "이미지가 비었을까, 좌표가 없을까"를 일일이 방어하지 않아도, 모델의 얇은 한 겹만 치면 됐으니까. 좋은 백엔드가 좋은 프런트엔드의 절반이라는 걸, 이 시리즈를 통째로 만들고 나서야 몸으로 알았다.
'외주 개발일지' 카테고리의 다른 글
| PWA 풀스크린으로 태블릿 키오스크 만들기 — 주소창 없는 앱처럼 (2) (0) | 2026.06.13 |
|---|---|
| WebSocket 실시간 기기 연동 구현 — 키오스크 UI에서 폴링과 푸시를 한 화면에 (1) (0) | 2026.06.13 |
| Render 무료 플랜 + Supabase로 FastAPI 배포 — cold start와 커넥션 풀 (3) (0) | 2026.06.13 |
| FastAPI None 직렬화 500 에러 해결 — 검색·소셜인증 API 설계기 (2) (0) | 2026.06.13 |
| 파이썬 Selenium 크롤러와 스케줄러 — 데이터 수집 자동화 (1) (0) | 2026.06.13 |