Flutter 튜토리얼 33편: 로컬 데이터 저장

요약#

핵심 요지#

  • 문제 정의: 네트워크에서 불러온 데이터를 매번 다시 요청하면 느리고, 오프라인에서는 앱이 작동하지 않는다.
  • 핵심 주장: 데이터의 크기와 복잡성에 따라 적절한 로컬 저장 방식을 선택해야 한다.
  • 주요 근거: shared_preferences1는 간단한 키-값 저장에, 파일 시스템은 텍스트/바이너리 데이터에, SQLite2는 복잡한 쿼리가 필요한 대용량 데이터에 적합하다.
  • 실무 기준: 사용자 설정은 shared_preferences, 대용량 정형 데이터는 SQLite, 캐시 파일은 파일 시스템을 사용한다.

문서가 설명하는 범위#

  • 캐싱의 기본 개념과 메모리 캐싱 구현
  • shared_preferences로 키-값 데이터 저장하기
  • path_provider와 dart 파일 읽고 쓰기
  • sqflite로 SQLite 데이터베이스 활용하기
  • 상황별 저장 방식 선택 가이드

읽는 시간: 20분 | 난이도: 초급


참고 자료#


문제 상황#

네트워크에서 데이터를 불러오는 앱을 만들었습니다.
하지만 사용자가 앱을 다시 열 때마다 같은 데이터를 반복해서 불러옵니다.
인터넷 연결이 느리거나 끊기면 앱이 작동하지 않습니다.

// 매번 네트워크 요청을 하는 비효율적인 코드
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

캐싱은 세 단계로 작동합니다.

  1. 캐시 확인: 저장된 데이터가 있는지 확인
  2. 원본 요청: 없으면 원본(네트워크, 파일 등)에서 불러오기
  3. 캐시 반환: 저장된 데이터 반환
용어의미
캐시 히트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#

NOTE

shared_preferences는 키-값 쌍을 기기에 저장하는 플러그인입니다.
각 플랫폼의 기본 저장소를 사용합니다.

플랫폼저장 위치
AndroidSharedPreferences
iOSNSUserDefaults
WebLocalStorage
macOSNSUserDefaults
Windows레지스트리
Linux파일 시스템

지원되는 데이터 타입: int, double, bool, String, List<String>

How#

TIP

1단계: 패키지 추가

Terminal window
flutter pub add shared_preferences

2단계: 데이터 저장하기

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});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _counter = 0;
@override
void 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);
});
}
@override
Widget 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#

WARNING

shared_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#

NOTE

shared_preferences보다 더 큰 데이터를 저장해야 할 때가 있습니다.
JSON 파일, 로그 파일, 다운로드한 콘텐츠 등을 저장할 수 있습니다.

// 큰 JSON 데이터는 shared_preferences에 부적합
final bigData = await fetchLargeDataset(); // 수 MB의 데이터
await prefs.setString('data', jsonEncode(bigData)); // 느리고 비효율적

파일 시스템은 데이터 형식에 제한이 없습니다.

What#

NOTE

path_provider8 패키지로 적절한 저장 위치를 찾습니다.

디렉토리설명사용 예시
임시 디렉토리시스템이 언제든 지울 수 있음캐시 파일
문서 디렉토리앱 삭제 전까지 유지됨사용자 데이터
지원 디렉토리앱 전용 데이터설정 파일

파일 시스템 API는 웹에서 작동하지 않습니다.
웹 앱에서는 IndexedDB나 LocalStorage를 사용해야 합니다.

How#

TIP

1단계: 패키지 추가

Terminal window
flutter pub add path_provider

2단계: 파일 경로 가져오기

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;
@override
State<FileCounterPage> createState() => _FileCounterPageState();
}
class _FileCounterPageState extends State<FileCounterPage> {
int _counter = 0;
@override
void initState() {
super.initState();
widget.storage.readCounter().then((value) {
setState(() => _counter = value);
});
}
Future<void> _incrementCounter() async {
setState(() => _counter++);
await widget.storage.writeCounter(_counter);
}
@override
Widget 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 path

2단계: 데이터 모델 정의

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};
}
@override
String 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#

  1. shared_preferences(쉐어드 프리퍼런스): 간단한 키-값 쌍을 기기에 저장하는 Flutter 플러그인이다.

  2. SQLite(에스큐엘라이트): 서버 없이 파일 기반으로 동작하는 경량 관계형 데이터베이스이다.

  3. caching(캐싱): 자주 사용하는 데이터를 임시 저장소에 보관하여 빠르게 접근할 수 있게 하는 기법이다.

  4. cache hit(캐시 히트): 요청한 데이터가 캐시에 존재하여 원본 소스에 접근할 필요가 없는 상황이다.

  5. cache miss(캐시 미스): 요청한 데이터가 캐시에 없어서 원본 소스에서 데이터를 가져와야 하는 상황이다.

  6. stale cache(스테일 캐시): 원본 데이터가 변경되어 캐시에 저장된 데이터가 최신이 아닌 상태이다.

  7. Repository pattern(리포지토리 패턴): 데이터 접근 로직을 캡슐화하는 디자인 패턴으로, 데이터 소스와 비즈니스 로직을 분리한다.

  8. path_provider(패스 프로바이더): 파일 시스템에서 적절한 저장 경로를 찾아주는 Flutter 플러그인이다.

  9. sqflite(에스큐플라이트): Flutter에서 SQLite 데이터베이스를 사용할 수 있게 해주는 패키지이다.

  10. SQL injection(SQL 인젝션): 악의적인 SQL 코드를 주입하여 데이터베이스를 공격하는 보안 취약점이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 33편: 로컬 데이터 저장
https://moodturnpost.net/posts/flutter/flutter-local-persistence/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차