Flutter 튜토리얼 33편: 로컬 데이터 저장
요약
핵심 요지
문서가 설명하는 범위
- 캐싱의 기본 개념과 메모리 캐싱 구현
- shared_preferences로 키-값 데이터 저장하기
- path_provider와 dart
파일 읽고 쓰기 - sqflite로 SQLite 데이터베이스 활용하기
- 상황별 저장 방식 선택 가이드
읽는 시간: 20분 | 난이도: 초급
참고 자료
- Local data and caching - 로컬 캐싱 기본 개념
- Store key-value data on disk - shared_preferences 사용법
- Read and write files - 파일 시스템 활용
- Persist data with SQLite - SQLite 데이터베이스
문제 상황
네트워크에서 데이터를 불러오는 앱을 만들었습니다.
하지만 사용자가 앱을 다시 열 때마다 같은 데이터를 반복해서 불러옵니다.
인터넷 연결이 느리거나 끊기면 앱이 작동하지 않습니다.
// 매번 네트워크 요청을 하는 비효율적인 코드Future<User> loadUser() async { final response = await http.get(Uri.parse('https://api.example.com/user')); return User.fromJson(jsonDecode(response.body));}문제는 다음과 같습니다.
- 같은 데이터를 반복 요청하여 네트워크 비용이 낭비된다.
- 오프라인 상태에서 앱이 전혀 작동하지 않는다.
- 사용자 설정이나 로그인 정보를 저장할 방법이 없다.
해결 방법
데이터를 기기에 저장하면 네트워크 요청을 줄이고 오프라인에서도 앱이 작동합니다.
Flutter는 데이터 특성에 따라 다양한 저장 방식을 제공합니다.
챕터 1: 캐싱의 기본 개념
Why
NOTE네트워크 요청은 시간이 걸립니다.
같은 데이터를 여러 번 요청하면 사용자는 매번 기다려야 합니다.// 사용자가 프로필 화면을 열 때마다 2초씩 기다려야 함Future<void> showProfile() async {setState(() => isLoading = true);user = await api.fetchUser(); // 매번 네트워크 요청setState(() => isLoading = false);}
캐싱3은 한 번 불러온 데이터를 저장해두고 다시 사용하는 기법입니다.
What
NOTE캐싱은 세 단계로 작동합니다.
- 캐시 확인: 저장된 데이터가 있는지 확인
- 원본 요청: 없으면 원본(네트워크, 파일 등)에서 불러오기
- 캐시 반환: 저장된 데이터 반환
용어 의미 캐시 히트4캐시에 원하는 데이터가 있는 경우 캐시 미스5캐시에 데이터가 없어서 원본에서 불러와야 하는 경우 스테일 캐시6원본 데이터가 변경되어 캐시가 오래된 경우
How
TIP가장 간단한 캐싱은 메모리에 데이터를 저장하는 것입니다.
Repository 패턴7을 사용하면 캐싱 로직을 깔끔하게 관리할 수 있습니다.class UserRepository {UserRepository(this.api);final Api api;final Map<int, User?> _userCache = {};Future<User?> loadUser(int id) async {// 1. 캐시 확인if (!_userCache.containsKey(id)) {// 2. 캐시에 없으면 API에서 불러오기final response = await api.get(id);if (response.statusCode == 200) {_userCache[id] = User.fromJson(response.body);} else {_userCache[id] = null;}}// 3. 캐시된 데이터 반환return _userCache[id];}}메모리 캐싱은 앱이 종료되면 자동으로 사라집니다.
이 특성 덕분에 스테일 캐시 문제를 자연스럽게 해결합니다.
Watch out
WARNING캐시된 데이터가 원본과 다를 수 있습니다.
서버에서 데이터가 변경되었는데 캐시는 예전 데이터를 가지고 있으면 문제가 됩니다.class CacheEntry<T> {final T data;final DateTime createdAt;final Duration ttl; // Time To Live (유효 시간)CacheEntry(this.data, {this.ttl = const Duration(minutes: 5)}): createdAt = DateTime.now();bool get isExpired =>DateTime.now().difference(createdAt) > ttl;}대부분의 캐시 시스템은 만료 시간을 설정합니다.
일정 시간이 지나면 캐시를 무효화하고 새 데이터를 불러옵니다.
결론: 메모리 캐싱으로 네트워크 요청을 줄이고 앱 반응 속도를 높일 수 있습니다.
챕터 2: shared_preferences로 키-값 저장하기
Why
NOTE사용자 설정이나 간단한 상태를 저장할 때가 있습니다.
”다크 모드 켜짐”, “마지막 로그인 날짜”, “자동 로그인 여부” 같은 데이터입니다.// 앱을 다시 열면 설정이 사라짐bool isDarkMode = false; // 메모리에만 저장됨이런 간단한 데이터는 shared_preferences가 가장 적합합니다.
What
NOTEshared_preferences는 키-값 쌍을 기기에 저장하는 플러그인입니다.
각 플랫폼의 기본 저장소를 사용합니다.
플랫폼 저장 위치 Android SharedPreferences iOS NSUserDefaults Web LocalStorage macOS NSUserDefaults Windows 레지스트리 Linux 파일 시스템 지원되는 데이터 타입:
int,double,bool,String,List<String>
How
TIP1단계: 패키지 추가
Terminal window flutter pub add shared_preferences2단계: 데이터 저장하기
import 'package:shared_preferences/shared_preferences.dart';// SharedPreferences 인스턴스 가져오기final prefs = await SharedPreferences.getInstance();// 다양한 타입의 데이터 저장await prefs.setInt('counter', 10);await prefs.setBool('darkMode', true);await prefs.setString('username', 'flutter_dev');await prefs.setStringList('favorites', ['item1', 'item2']);3단계: 데이터 읽기
final prefs = await SharedPreferences.getInstance();// 데이터 읽기 (없으면 기본값 사용)final counter = prefs.getInt('counter') ?? 0;final darkMode = prefs.getBool('darkMode') ?? false;final username = prefs.getString('username') ?? '';4단계: 데이터 삭제
await prefs.remove('counter'); // 특정 키 삭제await prefs.clear(); // 모든 데이터 삭제전체 예제: 카운터 앱
import 'package:flutter/material.dart';import 'package:shared_preferences/shared_preferences.dart';class CounterPage extends StatefulWidget {const CounterPage({super.key});@overrideState<CounterPage> createState() => _CounterPageState();}class _CounterPageState extends State<CounterPage> {int _counter = 0;@overridevoid initState() {super.initState();_loadCounter();}// 저장된 카운터 값 불러오기Future<void> _loadCounter() async {final prefs = await SharedPreferences.getInstance();setState(() {_counter = prefs.getInt('counter') ?? 0;});}// 카운터 증가 및 저장Future<void> _incrementCounter() async {final prefs = await SharedPreferences.getInstance();setState(() {_counter++;prefs.setInt('counter', _counter);});}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('Counter')),body: Center(child: Text('Count: $_counter'),),floatingActionButton: FloatingActionButton(onPressed: _incrementCounter,child: const Icon(Icons.add),),);}}
Watch out
WARNINGshared_preferences는 데이터 지속성을 100% 보장하지 않습니다.
운영 체제가 저장 공간을 비울 수 있습니다.// ❌ 중요한 데이터에 부적합await prefs.setString('authToken', token); // 토큰이 사라질 수 있음// ✅ 민감한 데이터는 flutter_secure_storage 사용import 'package:flutter_secure_storage/flutter_secure_storage.dart';final storage = FlutterSecureStorage();await storage.write(key: 'authToken', value: token);또한 대용량 데이터에는 부적합합니다.
리스트가 길어지거나 복잡한 구조가 필요하면 SQLite를 사용하세요.
결론: shared_preferences로 사용자 설정 같은 간단한 데이터를 쉽게 저장할 수 있습니다.
챕터 3: 파일 시스템 활용하기
Why
NOTEshared_preferences보다 더 큰 데이터를 저장해야 할 때가 있습니다.
JSON 파일, 로그 파일, 다운로드한 콘텐츠 등을 저장할 수 있습니다.// 큰 JSON 데이터는 shared_preferences에 부적합final bigData = await fetchLargeDataset(); // 수 MB의 데이터await prefs.setString('data', jsonEncode(bigData)); // 느리고 비효율적파일 시스템은 데이터 형식에 제한이 없습니다.
What
NOTE
path_provider8 패키지로 적절한 저장 위치를 찾습니다.
디렉토리 설명 사용 예시 임시 디렉토리 시스템이 언제든 지울 수 있음 캐시 파일 문서 디렉토리 앱 삭제 전까지 유지됨 사용자 데이터 지원 디렉토리 앱 전용 데이터 설정 파일 파일 시스템 API는 웹에서 작동하지 않습니다.
웹 앱에서는 IndexedDB나 LocalStorage를 사용해야 합니다.
How
TIP1단계: 패키지 추가
Terminal window flutter pub add path_provider2단계: 파일 경로 가져오기
import 'dart:io';import 'package:path_provider/path_provider.dart';Future<String> get _localPath async {final directory = await getApplicationDocumentsDirectory();return directory.path;}Future<File> get _localFile async {final path = await _localPath;return File('$path/counter.txt');}3단계: 파일 읽고 쓰기
// 파일에 쓰기Future<File> writeCounter(int counter) async {final file = await _localFile;return file.writeAsString('$counter');}// 파일에서 읽기Future<int> readCounter() async {try {final file = await _localFile;final contents = await file.readAsString();return int.parse(contents);} catch (e) {return 0; // 파일이 없거나 에러 발생 시 기본값}}전체 예제: 파일 기반 카운터
import 'dart:io';import 'package:flutter/material.dart';import 'package:path_provider/path_provider.dart';class CounterStorage {Future<String> get _localPath async {final directory = await getApplicationDocumentsDirectory();return directory.path;}Future<File> get _localFile async {final path = await _localPath;return File('$path/counter.txt');}Future<int> readCounter() async {try {final file = await _localFile;final contents = await file.readAsString();return int.parse(contents);} catch (e) {return 0;}}Future<File> writeCounter(int counter) async {final file = await _localFile;return file.writeAsString('$counter');}}class FileCounterPage extends StatefulWidget {const FileCounterPage({super.key, required this.storage});final CounterStorage storage;@overrideState<FileCounterPage> createState() => _FileCounterPageState();}class _FileCounterPageState extends State<FileCounterPage> {int _counter = 0;@overridevoid initState() {super.initState();widget.storage.readCounter().then((value) {setState(() => _counter = value);});}Future<void> _incrementCounter() async {setState(() => _counter++);await widget.storage.writeCounter(_counter);}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('File Counter')),body: Center(child: Text('Count: $_counter')),floatingActionButton: FloatingActionButton(onPressed: _incrementCounter,child: const Icon(Icons.add),),);}}
Watch out
WARNING파일 작업은 실패할 수 있습니다.
파일이 없거나, 권한이 없거나, 저장 공간이 부족할 수 있습니다.Future<int> readCounter() async {try {final file = await _localFile;final contents = await file.readAsString();return int.parse(contents);} on FileSystemException catch (e) {// 파일 시스템 에러 (권한 없음, 공간 부족 등)debugPrint('파일 시스템 에러: $e');return 0;} on FormatException catch (e) {// 파싱 에러 (파일 내용이 숫자가 아님)debugPrint('파싱 에러: $e');return 0;}}항상 try-catch로 에러를 처리하세요.
결론: 파일 시스템으로 큰 데이터나 다양한 형식의 파일을 저장할 수 있습니다.
챕터 4: SQLite 데이터베이스 활용하기
Why
NOTE많은 양의 데이터를 저장하고 검색해야 할 때가 있습니다.
사용자 목록, 메시지 기록, 상품 정보 같은 데이터입니다.// 파일로 저장하면 검색이 어려움final users = await file.readAsString();final userList = jsonDecode(users) as List;// 특정 사용자 찾기: 전체 목록을 순회해야 함final user = userList.firstWhere((u) => u['id'] == 123);SQLite는 대용량 데이터와 복잡한 쿼리에 최적입니다.
What
NOTE
sqflite9는 Flutter에서 SQLite를 사용할 수 있게 해주는 패키지입니다.
macOS, iOS, Android에서 작동합니다.SQLite의 장점:
- 파일이나 키-값 저장소보다 훨씬 빠른 검색 속도
- 복잡한 쿼리 지원 (필터링, 정렬, 조인)
- 데이터 무결성 보장 (타입 검사, 제약 조건)
SQLite에 익숙하지 않다면 SQLite Tutorial을 먼저 학습하세요.
How
TIP예제로 강아지 정보를 저장하는 앱을 만들어 봅시다.
1단계: 패키지 추가
Terminal window flutter pub add sqflite path2단계: 데이터 모델 정의
class Dog {final int id;final String name;final int age;const Dog({required this.id, required this.name, required this.age});// Dog 객체를 Map으로 변환 (데이터베이스 저장용)Map<String, Object?> toMap() {return {'id': id, 'name': name, 'age': age};}@overrideString toString() => 'Dog{id: $id, name: $name, age: $age}';}3단계: 데이터베이스 열기 및 테이블 생성
import 'package:flutter/widgets.dart';import 'package:path/path.dart';import 'package:sqflite/sqflite.dart';void main() async {WidgetsFlutterBinding.ensureInitialized();final database = openDatabase(join(await getDatabasesPath(), 'doggie_database.db'),onCreate: (db, version) {return db.execute('CREATE TABLE dogs(id INTEGER PRIMARY KEY, name TEXT, age INTEGER)',);},version: 1,);}4단계: CRUD 작업 구현
// Create: 데이터 삽입Future<void> insertDog(Dog dog) async {final db = await database;await db.insert('dogs',dog.toMap(),conflictAlgorithm: ConflictAlgorithm.replace,);}// Read: 데이터 조회Future<List<Dog>> dogs() async {final db = await database;final List<Map<String, Object?>> dogMaps = await db.query('dogs');return [for (final {'id': id as int, 'name': name as String, 'age': age as int} in dogMaps)Dog(id: id, name: name, age: age),];}// Update: 데이터 수정Future<void> updateDog(Dog dog) async {final db = await database;await db.update('dogs',dog.toMap(),where: 'id = ?',whereArgs: [dog.id],);}// Delete: 데이터 삭제Future<void> deleteDog(int id) async {final db = await database;await db.delete('dogs', where: 'id = ?', whereArgs: [id]);}사용 예시
// 강아지 추가var fido = Dog(id: 0, name: 'Fido', age: 35);await insertDog(fido);print(await dogs()); // [Dog{id: 0, name: Fido, age: 35}]// 강아지 정보 수정fido = Dog(id: fido.id, name: fido.name, age: fido.age + 7);await updateDog(fido);print(await dogs()); // [Dog{id: 0, name: Fido, age: 42}]// 강아지 삭제await deleteDog(fido.id);print(await dogs()); // []
Watch out
WARNING사용자 입력을 SQL에 직접 넣으면
SQL 인젝션10 공격에 취약해집니다.// ❌ 위험한 방법 - 절대 사용하지 마세요!await db.delete('dogs', where: "id = ${dog.id}");// ✅ 안전한 방법 - whereArgs 사용await db.delete('dogs', where: 'id = ?', whereArgs: [dog.id]);
whereArgs를 사용하면 입력값이 자동으로 이스케이프됩니다.
결론: SQLite로 대용량 데이터를 효율적으로 저장하고 검색할 수 있습니다.
챕터 5: 저장 방식 선택 가이드
Why
NOTE여러 가지 저장 방식이 있어서 어떤 것을 선택해야 할지 혼란스러울 수 있습니다.
잘못된 선택은 성능 문제나 유지보수 어려움으로 이어집니다.// 단순 설정값을 SQLite에 저장하는 것은 과도함final db = await openDatabase('settings.db');await db.insert('settings', {'key': 'darkMode', 'value': 'true'});// shared_preferences가 더 적합final prefs = await SharedPreferences.getInstance();await prefs.setBool('darkMode', true);
What
NOTE데이터 특성에 따라 적절한 저장 방식을 선택합니다.
flowchart TD A[데이터 저장이 필요함] --> B{앱 종료 후에도 유지?} B -->|아니오| C[메모리 캐싱] B -->|예| D{데이터 구조} D -->|단순 키-값| E{데이터 크기} E -->|작음| F[shared_preferences] E -->|큼| G[파일 시스템] D -->|복잡한 구조| H{검색/쿼리 필요?} H -->|아니오| G H -->|예| I[SQLite]
How
TIP각 방식 비교
특성 메모리 shared_preferences 파일 SQLite 지속성 앱 실행 중 앱 재시작 후 앱 재시작 후 앱 재시작 후 데이터 타입 모든 타입 기본 타입만 모든 형식 정형 데이터 쿼리 기능 없음 없음 없음 강력함 성능 매우 빠름 빠름 보통 빠름 복잡성 매우 간단 간단 보통 복잡 웹 지원 예 예 아니오 아니오 실제 사용 예시
사용 사례 추천 방식 사용자 설정 (다크모드, 언어) shared_preferences 로그인 토큰 flutter_secure_storage 이미지 캐시 cached_network_image 패키지 로그 파일 파일 시스템 오프라인 데이터 동기화 SQLite 채팅 메시지 기록 SQLite 다운로드한 문서 파일 시스템 API 응답 캐시 메모리 + SQLite
Watch out
WARNING민감한 정보(비밀번호, 토큰)는 일반 저장소에 저장하면 안 됩니다.
// ❌ 안전하지 않음await prefs.setString('password', userPassword);// ✅ 암호화된 저장소 사용import 'package:flutter_secure_storage/flutter_secure_storage.dart';final storage = FlutterSecureStorage();await storage.write(key: 'password', value: userPassword);flutter_secure_storage는 iOS Keychain과 Android EncryptedSharedPreferences를 사용합니다.
결론: 데이터 특성을 분석하고 적절한 저장 방식을 선택하면 효율적인 앱을 만들 수 있습니다.
한계
이 문서는 기본적인 로컬 저장 방법을 다룹니다.
다음 주제는 별도로 학습해야 합니다.
- 보안 저장소: 민감한 정보는 flutter_secure_storage 패키지 사용
- 동기화: 서버와 로컬 데이터 동기화 전략
- 마이그레이션: 데이터베이스 스키마 변경 관리
- 암호화: 로컬 데이터 암호화
Footnotes
-
SQLite(에스큐엘라이트): 서버 없이 파일 기반으로 동작하는 경량 관계형 데이터베이스이다. ↩
-
caching(캐싱): 자주 사용하는 데이터를 임시 저장소에 보관하여 빠르게 접근할 수 있게 하는 기법이다. ↩
-
cache hit(캐시 히트): 요청한 데이터가 캐시에 존재하여 원본 소스에 접근할 필요가 없는 상황이다. ↩
-
cache miss(캐시 미스): 요청한 데이터가 캐시에 없어서 원본 소스에서 데이터를 가져와야 하는 상황이다. ↩
-
stale cache(스테일 캐시): 원본 데이터가 변경되어 캐시에 저장된 데이터가 최신이 아닌 상태이다. ↩
-
Repository pattern(리포지토리 패턴): 데이터 접근 로직을 캡슐화하는 디자인 패턴으로, 데이터 소스와 비즈니스 로직을 분리한다. ↩
-
path_provider(패스 프로바이더): 파일 시스템에서 적절한 저장 경로를 찾아주는 Flutter 플러그인이다. ↩
-
sqflite(에스큐플라이트): Flutter에서 SQLite 데이터베이스를 사용할 수 있게 해주는 패키지이다. ↩
-
SQL injection(SQL 인젝션): 악의적인 SQL 코드를 주입하여 데이터베이스를 공격하는 보안 취약점이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!