[Flutter 육아앱 만들기] 4편 - 데이터 저장하기 (앱 껐다 켜도 기록이 남는다)
3편에서 수유 기록을 입력하고 목록으로 볼 수 있게 됐다. 근데 문제가 있다. 앱을 끄면 기록이 다 날아간다. 이번 편에서 기록을 영구 저장하는 방법을 알아본다.
저장 방법, 뭘 써야 하나?
Flutter에서 데이터를 로컬에 저장하는 방법은 여러 가지다:
| 방법 | 특징 | 언제 쓰나 |
|---|---|---|
| shared_preferences | 간단한 키-값 저장 | 설정값, 소량의 데이터 |
| sqflite | SQLite DB | 대량의 구조화된 데이터 |
| hive / isar | 빠른 NoSQL DB | 성능이 중요한 경우 |
우리 육아앱은 지금 당장 sqflite까지 필요하진 않다. 일단 shared_preferences로 시작하고, 나중에 데이터가 많아지면 sqflite로 이전하는 편을 따로 다룰 거다.
💡 shared_preferences는 iOS의 NSUserDefaults, Android의 SharedPreferences를 Flutter에서 쓸 수 있게 감싼 패키지다. 앱을 껐다 켜도 데이터가 유지된다.
패키지 설치
pubspec.yaml에 추가:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
shared_preferences: ^2.3.2 # 추가
터미널에서:
flutter pub get
저장 원리
shared_preferences는 문자열, 숫자, 불리언 같은 단순한 값만 저장할 수 있다. 우리가 저장하려는 FeedingRecord 리스트는 직접 저장이 안 된다.
해결책은 JSON 변환이다:
FeedingRecord 리스트 → JSON 문자열 → 저장
저장된 문자열 → JSON 파싱 → FeedingRecord 리스트
코드 수정하기
lib/screens/feeding_page.dart 전체를 아래 코드로 교체한다:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FeedingRecord {
final TimeOfDay time;
final int amount;
FeedingRecord({required this.time, required this.amount});
// FeedingRecord → Map (JSON으로 변환하기 위해)
Map<String, dynamic> toJson() => {
'hour': time.hour,
'minute': time.minute,
'amount': amount,
};
// Map → FeedingRecord (JSON에서 불러오기 위해)
factory FeedingRecord.fromJson(Map<String, dynamic> json) => FeedingRecord(
time: TimeOfDay(hour: json['hour'], minute: json['minute']),
amount: json['amount'],
);
}
class FeedingPage extends StatefulWidget {
const FeedingPage({super.key});
@override
State<FeedingPage> createState() => _FeedingPageState();
}
class _FeedingPageState extends State<FeedingPage> {
final List<FeedingRecord> _records = [];
final TextEditingController _amountController = TextEditingController();
TimeOfDay _selectedTime = TimeOfDay.now();
static const _storageKey = 'feeding_records';
@override
void initState() {
super.initState();
_loadRecords(); // 앱 시작 시 저장된 기록 불러오기
}
Future<void> _loadRecords() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_storageKey);
if (jsonString == null) return; // 저장된 게 없으면 그냥 끝
final List<dynamic> jsonList = jsonDecode(jsonString);
setState(() {
_records.clear();
_records.addAll(jsonList.map((e) => FeedingRecord.fromJson(e)));
});
}
Future<void> _saveRecords() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(_records.map((r) => r.toJson()).toList());
await prefs.setString(_storageKey, jsonString);
}
Future<void> _pickTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _selectedTime,
);
if (picked != null) {
setState(() => _selectedTime = picked);
}
}
void _addRecord() {
final text = _amountController.text.trim();
if (text.isEmpty) return;
final amount = int.tryParse(text);
if (amount == null) return;
setState(() {
_records.insert(0, FeedingRecord(time: _selectedTime, amount: amount));
});
_saveRecords(); // 기록 추가할 때마다 저장
_amountController.clear();
}
@override
void dispose() {
_amountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('수유 기록'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(
children: [
_buildInputForm(),
const Divider(),
Expanded(child: _buildList()),
],
),
);
}
Widget _buildInputForm() {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
TextButton.icon(
onPressed: _pickTime,
icon: const Icon(Icons.access_time),
label: Text(_selectedTime.format(context)),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
hintText: '수유량 (ml)',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _addRecord,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
child: const Text('기록'),
),
],
),
);
}
Widget _buildList() {
if (_records.isEmpty) {
return const Center(
child: Text(
'아직 기록이 없어요 🍼\n첫 수유를 기록해보세요!',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: _records.length,
itemBuilder: (context, index) {
final record = _records[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.orange,
child: Icon(Icons.restaurant, color: Colors.white, size: 20),
),
title: Text('${record.amount}ml'),
subtitle: Text(record.time.format(context)),
);
},
);
}
}
코드 뜯어보기
toJson / fromJson = 데이터 변환
Map<String, dynamic> toJson() => {
'hour': time.hour,
'minute': time.minute,
'amount': amount,
};
factory FeedingRecord.fromJson(Map<String, dynamic> json) => FeedingRecord(
time: TimeOfDay(hour: json['hour'], minute: json['minute']),
amount: json['amount'],
);
TimeOfDay는 JSON으로 바로 변환이 안 돼서 hour와 minute로 쪼개서 저장한다. 불러올 때 다시 합친다.
initState = 화면 시작할 때 실행
@override
void initState() {
super.initState();
_loadRecords();
}
initState()는 Widget이 처음 만들어질 때 한 번 실행된다. 여기서 저장된 기록을 불러온다. super.initState()는 반드시 먼저 호출해야 한다.
_loadRecords / _saveRecords
Future<void> _loadRecords() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_storageKey);
if (jsonString == null) return;
final List<dynamic> jsonList = jsonDecode(jsonString);
setState(() {
_records.clear();
_records.addAll(jsonList.map((e) => FeedingRecord.fromJson(e)));
});
}
Future<void> _saveRecords() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(_records.map((r) => r.toJson()).toList());
await prefs.setString(_storageKey, jsonString);
}
SharedPreferences.getInstance(): 저장소 인스턴스를 가져온다.await으로 기다려야 한다.jsonEncode: Dart 객체 → JSON 문자열jsonDecode: JSON 문자열 → Dart 객체_storageKey: 저장할 때 쓰는 키 이름. 불러올 때도 같은 키를 써야 한다.
_addRecord()에서 기록을 추가할 때마다 _saveRecords()를 호출해서 저장한다.
확인해보기
flutter run
수유 기록을 몇 개 추가하고 앱을 완전히 종료했다가 다시 열어보자. 기록이 그대로 남아있으면 성공이다.

이번 편에서 배운 것
| 개념 | 설명 |
|---|---|
| shared_preferences | 앱 로컬 저장소. 키-값 형태로 데이터 저장 |
| JSON 변환 | 복잡한 객체를 문자열로 변환해서 저장 |
| toJson / fromJson | 객체 ↔ Map 변환 패턴 |
| initState() | Widget 최초 생성 시 실행되는 생명주기 메서드 |
| async / await | 비동기 작업 처리 (파일 읽기/쓰기 등) |
현재 앱 구조
lib/
├── main.dart
└── screens/
└── feeding_page.dart ← shared_preferences로 저장/불러오기 추가
💡 데이터가 많아지거나 검색/필터 기능이 필요해지면 sqflite로 이전해야 한다. 그 편은 나중에 따로 다룰 예정이다.
다음 편 예고: [Flutter 육아앱 만들기] 5편 - 성장 기록 차트 만들기
키/몸무게를 입력하면 그래프로 보여주는 화면을 만든다. Flutter에서 차트 그리는 법을 알아본다.
'Flutter 육아앱 만들기' 카테고리의 다른 글
| [Flutter 육아앱 만들기] 3편 - 수유 기록 화면 만들기 (ListView와 입력 폼) (0) | 2026.04.15 |
|---|---|
| [Flutter 육아앱 만들기] 2편 - 첫 화면 만들기 (Widget이 뭐야?) (0) | 2026.04.10 |
| [Flutter 육아앱 만들기] 1편 - 개발 환경 세팅하기 (macOS) (0) | 2026.04.10 |