Flutter에서 로컬 스토리지를 어떻게 관리하나요?

질문

Flutter 애플리케이션에서 로컬 스토리지를 관리하는 다양한 방법과 각 방법의 장단점에 대해 설명해주세요.

답변

Flutter 앱에서 로컬 스토리지는 사용자 설정, 캐시 데이터, 오프라인 모드 지원, 인증 토큰 등 다양한 데이터를 저장하는 데 필수적입니다. Flutter는 여러 가지 로컬 스토리지 옵션을 제공하며, 각각 다른 사용 사례에 적합합니다.

1. Shared Preferences

간단한 키-값 쌍 데이터를 저장하는 데 적합한 방법입니다. iOS에서는 NSUserDefaults, Android에서는 SharedPreferences를 사용합니다.

설치

dependencies:
  shared_preferences: ^2.1.0

기본 사용법

import 'package:shared_preferences/shared_preferences.dart';

// 데이터 저장
Future<void> saveData() async {
  final prefs = await SharedPreferences.getInstance();

  // 다양한 타입 저장
  await prefs.setString('username', '홍길동');
  await prefs.setInt('age', 30);
  await prefs.setBool('isLoggedIn', true);
  await prefs.setStringList('favorites', ['apple', 'banana', 'orange']);

  print('데이터 저장 완료');
}

// 데이터 읽기
Future<void> loadData() async {
  final prefs = await SharedPreferences.getInstance();

  // 기본값 제공과 함께 데이터 읽기
  final username = prefs.getString('username') ?? '익명';
  final age = prefs.getInt('age') ?? 0;
  final isLoggedIn = prefs.getBool('isLoggedIn') ?? false;
  final favorites = prefs.getStringList('favorites') ?? <String>[];

  print('사용자: $username, 나이: $age, 로그인 상태: $isLoggedIn');
  print('좋아하는 과일: $favorites');
}

// 데이터 삭제
Future<void> removeData() async {
  final prefs = await SharedPreferences.getInstance();

  // 특정 키 삭제
  await prefs.remove('username');

  // 모든 데이터 삭제
  // await prefs.clear();

  print('데이터 삭제 완료');
}

장점

  • 간단한 설정으로 빠르게 구현 가능
  • 기본 데이터 타입(문자열, 숫자, 불리언, 문자열 리스트) 저장 가능
  • 플랫폼 간 일관된 API

단점

  • 복잡한 객체 저장 불가(직접 JSON으로 변환 필요)
  • 대용량 데이터에는 적합하지 않음
  • 암호화 기능 없음

2. SQLite 데이터베이스 (sqflite)

구조화된 데이터나 대용량 데이터를 관계형 데이터베이스에 저장할 때 적합합니다.

설치

dependencies:
  sqflite: ^2.2.6
  path: ^1.8.2

기본 사용법

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static final DatabaseHelper _instance = DatabaseHelper._internal();
  static Database? _database;

  factory DatabaseHelper() => _instance;

  DatabaseHelper._internal();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }

  Future<Database> _initDatabase() async {
    String path = join(await getDatabasesPath(), 'my_app.db');
    return await openDatabase(
      path,
      version: 1,
      onCreate: _createDb,
    );
  }

  Future<void> _createDb(Database db, int version) async {
    await db.execute('''
      CREATE TABLE users(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        age INTEGER,
        email TEXT
      )
    ''');
  }

  // 데이터 삽입
  Future<int> insertUser(Map<String, dynamic> user) async {
    Database db = await database;
    return await db.insert('users', user);
  }

  // 모든 사용자 조회
  Future<List<Map<String, dynamic>>> getUsers() async {
    Database db = await database;
    return await db.query('users');
  }

  // 특정 사용자 조회
  Future<List<Map<String, dynamic>>> getUserById(int id) async {
    Database db = await database;
    return await db.query('users', where: 'id = ?', whereArgs: [id]);
  }

  // 사용자 정보 업데이트
  Future<int> updateUser(Map<String, dynamic> user) async {
    Database db = await database;
    return await db.update(
      'users',
      user,
      where: 'id = ?',
      whereArgs: [user['id']],
    );
  }

  // 사용자 삭제
  Future<int> deleteUser(int id) async {
    Database db = await database;
    return await db.delete('users', where: 'id = ?', whereArgs: [id]);
  }
}

// 사용 예시
Future<void> testDatabase() async {
  final dbHelper = DatabaseHelper();

  // 사용자 추가
  int userId = await dbHelper.insertUser({
    'name': '홍길동',
    'age': 30,
    'email': 'hong@example.com'
  });
  print('추가된 사용자 ID: $userId');

  // 모든 사용자 조회
  List<Map<String, dynamic>> users = await dbHelper.getUsers();
  for (var user in users) {
    print('이름: ${user['name']}, 이메일: ${user['email']}');
  }

  // 사용자 정보 업데이트
  await dbHelper.updateUser({
    'id': userId,
    'name': '홍길동',
    'age': 31,
    'email': 'hong2@example.com'
  });

  // 특정 사용자 조회
  List<Map<String, dynamic>> updatedUser = await dbHelper.getUserById(userId);
  print('업데이트된 사용자: ${updatedUser.first}');
}

장점

  • 복잡하고 구조화된 데이터 저장 가능
  • SQL 쿼리로 강력한 데이터 필터링 및 조작
  • 대용량 데이터 처리에 효율적
  • 트랜잭션 지원

단점

  • 설정이 복잡하고 SQL 지식 필요
  • 단순한 데이터 저장에는 과도할 수 있음
  • 객체-관계 매핑(ORM) 수동 구현 필요

3. Hive

NoSQL 기반의 경량 키-값 데이터베이스로, 빠른 속도와 사용 편의성이 특징입니다.

설치

dependencies:
  hive: ^2.2.3
  hive_flutter: ^1.1.0

dev_dependencies:
  hive_generator: ^2.0.0
  build_runner: ^2.3.3

모델 정의

import 'package:hive/hive.dart';

part 'user.g.dart';

@HiveType(typeId: 0)
class User {
  @HiveField(0)
  final String name;

  @HiveField(1)
  final int age;

  @HiveField(2)
  final String email;

  User({required this.name, required this.age, required this.email});
}

초기화 및 사용

import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'user.dart';

Future<void> initHive() async {
  await Hive.initFlutter();
  Hive.registerAdapter(UserAdapter()); // 생성된 어댑터 등록
  await Hive.openBox<User>('usersBox');
  await Hive.openBox('settingsBox');
}

Future<void> useHive() async {
  // 객체 저장
  final usersBox = Hive.box<User>('usersBox');
  await usersBox.put('user1', User(
    name: '홍길동',
    age: 30,
    email: 'hong@example.com'
  ));

  // 객체 조회
  final user = usersBox.get('user1');
  print('사용자: ${user?.name}, 이메일: ${user?.email}');

  // 기본 타입 저장 (별도 박스)
  final settingsBox = Hive.box('settingsBox');
  await settingsBox.put('darkMode', true);
  await settingsBox.put('language', 'ko');

  // 기본 타입 조회
  final isDarkMode = settingsBox.get('darkMode', defaultValue: false);
  final language = settingsBox.get('language', defaultValue: 'en');
  print('다크 모드: $isDarkMode, 언어: $language');

  // 데이터 삭제
  await usersBox.delete('user1');
}

장점

  • 빠른 성능(네이티브 바이너리 형식)
  • 복잡한 객체 저장 지원
  • 간단한 API
  • 암호화 지원
  • Flutter 웹 지원

단점

  • 코드 생성 설정 필요
  • 관계형 데이터 쿼리에는 적합하지 않음
  • 복잡한 트랜잭션에 제한적

4. Secure Storage

민감한 정보(암호, API 키, 토큰 등)를 안전하게 저장하기 위한 방법입니다.

설치

dependencies:
  flutter_secure_storage: ^8.0.0

기본 사용법

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

Future<void> useSecureStorage() async {
  final storage = FlutterSecureStorage();

  // 데이터 저장
  await storage.write(key: 'token', value: 'eyJhbGciOiJIUzI1NiIsInR5...');
  await storage.write(key: 'password', value: 'secret123');

  // 데이터 읽기
  String? token = await storage.read(key: 'token');
  print('토큰: $token');

  // 모든 데이터 조회
  Map<String, String> allValues = await storage.readAll();
  print('모든 저장 값: $allValues');

  // 특정 키 존재 여부 확인
  bool hasToken = await storage.containsKey(key: 'token');
  print('토큰 존재 여부: $hasToken');

  // 특정 키 삭제
  await storage.delete(key: 'password');

  // 모든 데이터 삭제
  // await storage.deleteAll();
}

Android 및 iOS 특정 옵션

// Android에서 추가 옵션
AndroidOptions _getAndroidOptions() => const AndroidOptions(
  encryptedSharedPreferences: true,
  // 추가 보안 설정
  // keyCipherAlgorithm: ,
  // storageCipherAlgorithm: ,
);

// iOS에서 추가 옵션
IOSOptions _getIOSOptions() => const IOSOptions(
  accountName: 'myApp',
  // 추가 보안 설정
  // accessibility: ,
  // synchronizable: ,
);

// 옵션 적용
final storage = FlutterSecureStorage(
  aOptions: _getAndroidOptions(),
  iOptions: _getIOSOptions(),
);

장점

  • 플랫폼별 보안 메커니즘 사용(Android 키스토어, iOS 키체인)
  • 암호화된 저장소로 민감한 정보에 적합
  • 간단한 API

단점

  • 대용량 데이터에 적합하지 않음
  • 성능이 다른 방식보다 느릴 수 있음
  • 기본 데이터 타입(문자열)만 저장 가능

5. Drift (이전 moor) - SQLite 기반 타입 안전 ORM

Drift는 SQLite 위에 구축된 반응형 지속성 라이브러리로, 타입 안전성과 코드 생성을 제공합니다.

설치

dependencies:
  drift: ^2.7.0
  sqlite3_flutter_libs: ^0.5.13
  path_provider: ^2.0.14
  path: ^1.8.2

dev_dependencies:
  drift_dev: ^2.7.0
  build_runner: ^2.3.3

데이터베이스 및 테이블 정의

import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

part 'database.g.dart';

// 테이블 정의
class Users extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
  IntColumn get age => integer().nullable()();
  TextColumn get email => text().unique()();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateTime)();
}

class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text()();
  TextColumn get description => text().nullable()();
  BoolColumn get completed => boolean().withDefault(const Constant(false))();
  IntColumn get userId => integer().references(Users, #id)();
}

// 데이터베이스 클래스
@DriftDatabase(tables: [Users, Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  // 사용자 관련 메서드
  Future<List<User>> getAllUsers() => select(users).get();

  Future<User> getUserById(int id) =>
      (select(users)..where((u) => u.id.equals(id))).getSingle();

  Future<int> insertUser(UsersCompanion user) => into(users).insert(user);

  Future<bool> updateUser(UsersCompanion user) =>
      update(users).replace(user);

  Future<int> deleteUser(int id) =>
      (delete(users)..where((u) => u.id.equals(id))).go();

  // 작업 관련 메서드
  Stream<List<Task>> watchUserTasks(int userId) =>
      (select(tasks)..where((t) => t.userId.equals(userId)))
          .watch();

  Future<int> insertTask(TasksCompanion task) => into(tasks).insert(task);

  Future<bool> completeTask(int id) =>
      (update(tasks)..where((t) => t.id.equals(id)))
          .write(TasksCompanion(completed: const Value(true)));
}

// 데이터베이스 연결
LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'app_database.sqlite'));
    return NativeDatabase(file);
  });
}

사용 예시

Future<void> useDrift() async {
  final database = AppDatabase();

  // 사용자 추가
  final userId = await database.insertUser(
    UsersCompanion.insert(
      name: 'Lee Minhee',
      age: const Value(28),
      email: 'minhee@example.com',
    ),
  );

  // 사용자 조회
  final user = await database.getUserById(userId);
  print('사용자: ${user.name}, 이메일: ${user.email}');

  // 작업 추가
  await database.insertTask(
    TasksCompanion.insert(
      title: '장보기',
      description: const Value('우유, 빵, 과일 구매'),
      userId: userId,
    ),
  );

  await database.insertTask(
    TasksCompanion.insert(
      title: '이메일 보내기',
      userId: userId,
    ),
  );

  // 작업 스트림 구독
  final subscription = database.watchUserTasks(userId).listen((tasks) {
    print('사용자 작업 ${tasks.length}개:');
    for (var task in tasks) {
      print('- ${task.title}: ${task.completed ? "완료" : "진행 중"}');
    }
  });

  // 작업 완료 표시
  await Future.delayed(Duration(seconds: 1));
  final firstTask = (await database.watchUserTasks(userId).first).first;
  await database.completeTask(firstTask.id);

  // 정리
  await Future.delayed(Duration(seconds: 1));
  await subscription.cancel();
  await database.close();
}

장점

  • 타입 안전성과 컴파일 타임 검사
  • 자동 생성된 테이블 및 DAO 코드
  • 리액티브 프로그래밍 지원(스트림)
  • 트랜잭션 및 마이그레이션 지원
  • SQL 쿼리의 유연성과 객체 지향 API의 편의성

단점

  • 설정이 복잡할 수 있음
  • 간단한 데이터 저장에는 과도한 솔루션
  • 학습 곡선이 있음

6. GetStorage

GetX 생태계의 일부로, 간단하고 빠른 키-값 저장소입니다.

설치

dependencies:
  get_storage: ^2.1.1

기본 사용법

import 'package:get_storage/get_storage.dart';

Future<void> useGetStorage() async {
  // 초기화
  await GetStorage.init();

  final storage = GetStorage();

  // 데이터 저장
  storage.write('username', '홍길동');
  storage.write('settings', {
    'darkMode': true,
    'fontSize': 16,
    'language': 'ko'
  });

  // 데이터 읽기
  String? username = storage.read('username');
  Map<String, dynamic>? settings = storage.read('settings');

  print('사용자: $username');
  print('설정: $settings');

  // 데이터 존재 여부 확인
  bool hasUsername = storage.hasData('username');

  // 데이터 제거
  storage.remove('username');

  // 모든 데이터 제거
  // storage.erase();

  // 리스너 추가
  final listener = storage.listen(() {
    print('스토리지가 변경되었습니다.');
  });

  // 리스너 제거
  GetStorage().removeListener(listener);
}

장점

  • 매우 간단한 API
  • GetX 프레임워크와 통합
  • 빠른 성능
  • 변경 감지 기능

단점

  • 타입 안전성이 부족
  • 복잡한 객체 저장 시 직접 JSON 변환 필요
  • 암호화 내장되지 않음

로컬 스토리지 선택 가이드

1. 간단한 사용자 설정 또는 앱 상태

  • 추천: Shared Preferences, GetStorage
  • 이유: 설정이 간단하고 사용이 쉬움

2. 형식화된 데이터 또는 관계형 데이터

  • 추천: SQLite(sqflite), Drift
  • 이유: 구조화된 데이터 및 쿼리 기능

3. 객체 저장 요구 사항

  • 추천: Hive
  • 이유: 객체 직렬화가 간단하고 성능이 우수함

4. 민감한 데이터(인증 토큰, 암호)

  • 추천: flutter_secure_storage
  • 이유: 암호화 및 플랫폼별 보안 메커니즘 사용

5. 대규모 애플리케이션

  • 추천: Drift 또는 sqflite와 데이터 액세스 계층
  • 이유: 확장성, 타입 안전성, 유지보수성

로컬 스토리지 모범 사례

  1. 적절한 저장소 선택: 사용 사례에 맞는 저장소 방식 선택
  2. 저장소 추상화:
abstract class StorageService {
  Future<void> setString(String key, String value);
  Future<String?> getString(String key);
  Future<void> setBool(String key, bool value);
  Future<bool?> getBool(String key);
  Future<void> remove(String key);
  Future<void> clear();
}

// Shared Preferences 구현
class SharedPrefsService implements StorageService {
  final SharedPreferences _prefs;

  SharedPrefsService(this._prefs);

  static Future<SharedPrefsService> init() async {
    final prefs = await SharedPreferences.getInstance();
    return SharedPrefsService(prefs);
  }

  @override
  Future<void> setString(String key, String value) async {
    await _prefs.setString(key, value);
  }

  @override
  Future<String?> getString(String key) async {
    return _prefs.getString(key);
  }

  // 다른 메서드 구현...
}
  1. 저장 데이터 암호화: 민감하지 않은 데이터도 필요시 암호화 고려

  2. 비동기 처리: 모든 스토리지 작업을 비동기로 처리

  3. 오류 처리:

Future<void> safeWrite(String key, String value) async {
  try {
    await storage.write(key: key, value: value);
  } catch (e) {
    print('저장소 쓰기 오류: $e');
    // 오류 처리 또는 다시 시도 로직
  }
}
  1. 여러 저장소 조합: 용도에 맞게 여러 저장소 기술 조합 사용
class AppStorage {
  final SharedPreferences prefs; // 일반 설정
  final FlutterSecureStorage secureStorage; // 민감한 정보
  final DatabaseHelper database; // 구조화된 데이터

  AppStorage({
    required this.prefs,
    required this.secureStorage,
    required this.database,
  });

  // 다양한 저장소 기능을 제공하는 메서드
}

결론

Flutter에서 로컬 스토리지 관리는 애플리케이션의 요구 사항과 데이터 복잡성에 따라 다양한 접근 방식을 제공합니다. 간단한 데이터는 Shared Preferences나 GetStorage로, 구조화된 데이터는 SQLite 또는 Drift로, 객체 저장은 Hive로, 민감한 정보는 Secure Storage를 사용하는 것이 좋습니다.

적절한 저장소 솔루션을 선택하고 잘 설계된 추상화 계층을 통해 앱의 데이터를 효율적으로 관리할 수 있습니다. 성능, 보안, 편의성을 모두 고려하여 프로젝트 요구 사항에 가장 적합한 방법을 선택하세요.

results matching ""

    No results matching ""