🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Flutter 3.0 Offline 데이터 영속화 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 11 Views

Flutter 3.0 Offline 데이터 영속화 완벽 가이드

Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.


목차

  1. Offline 기능 소개
  2. Storage 인터페이스 구현
  3. SharedPreferences Storage Provider
  4. 사용자 설정 저장 예제
  5. 장바구니 영속화 예제
  6. 오프라인 우선 패턴

1. Offline 기능 소개

김개발 씨는 쇼핑몰 앱을 개발하고 있습니다. 사용자가 앱을 껐다 켜도 장바구니 내용이 유지되어야 한다는 요구사항을 받았습니다.

"앱을 다시 켜면 데이터가 다 사라지는데, 이걸 어떻게 해결하지?"

Offline 데이터 영속화는 앱이 종료되어도 데이터를 디바이스에 저장해두는 기술입니다. 마치 공책에 메모를 적어두면 나중에 다시 볼 수 있는 것처럼, 앱의 데이터도 저장소에 기록해두면 언제든 다시 불러올 수 있습니다.

Flutter 3.0에서는 이런 기능을 더 쉽게 구현할 수 있는 패턴을 제공합니다.

다음 코드를 살펴봅시다.

// Offline 데이터 영속화의 기본 개념
abstract class Storage {
  // 데이터 저장
  Future<void> save(String key, String value);

  // 데이터 불러오기
  Future<String?> load(String key);

  // 데이터 삭제
  Future<void> delete(String key);
}

김개발 씨는 입사 6개월 차 주니어 개발자입니다. 오늘 팀장님께 새로운 태스크를 받았습니다.

"사용자가 장바구니에 담은 상품이 앱을 껐다 켜도 남아있어야 해요." 처음에는 간단할 거라고 생각했습니다. 하지만 막상 구현하려니 막막했습니다.

변수에 저장한 데이터는 앱을 종료하면 모두 사라지기 때문입니다. 선배 개발자 박시니어 씨가 다가와 물었습니다.

"Offline 데이터 영속화 알아요?" 김개발 씨는 고개를 갸우뚱했습니다. 그렇다면 데이터 영속화란 정확히 무엇일까요?

쉽게 비유하자면, 데이터 영속화는 마치 일기장에 글을 쓰는 것과 같습니다. 머릿속으로만 생각하면 시간이 지나면 잊어버리지만, 일기장에 적어두면 나중에 다시 읽을 수 있습니다.

앱의 데이터도 마찬가지입니다. 메모리에만 두면 앱을 종료할 때 사라지지만, 디바이스의 저장소에 기록해두면 언제든 다시 불러올 수 있습니다.

데이터 영속화가 없던 시절에는 어땠을까요? 초창기 모바일 앱들은 매번 서버에서 데이터를 받아와야 했습니다.

네트워크가 느리면 앱도 느려졌고, 오프라인 상태에서는 아예 사용할 수 없었습니다. 사용자 경험이 매우 나빴죠.

더 큰 문제는 사용자가 입력한 데이터가 순식간에 사라진다는 점이었습니다. 긴 글을 작성하다가 앱이 종료되면 모든 내용이 날아갔습니다.

사용자들의 불만이 폭주했습니다. 바로 이런 문제를 해결하기 위해 Offline 데이터 영속화 기술이 등장했습니다.

데이터 영속화를 사용하면 앱이 종료되어도 데이터가 보존됩니다. 네트워크 없이도 이전 데이터를 사용할 수 있습니다.

무엇보다 사용자 경험이 크게 향상됩니다. Flutter 3.0에서는 이런 기능을 더욱 쉽게 구현할 수 있는 패턴을 제공합니다.

위의 코드를 살펴보면 Storage라는 추상 클래스를 정의했습니다. 이것은 데이터를 저장하고 불러오는 기본 인터페이스입니다.

save 메서드로 데이터를 저장하고, load 메서드로 불러오고, delete 메서드로 삭제합니다. 이 인터페이스는 마치 전기 콘센트의 규격과 같습니다.

콘센트의 모양만 맞으면 어떤 전자제품이든 사용할 수 있듯이, Storage 인터페이스만 구현하면 어떤 저장소든 사용할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 쇼핑몰 앱을 개발한다고 가정해봅시다. 사용자가 장바구니에 상품을 담고, 설정을 변경하고, 최근 본 상품을 기록합니다.

이 모든 데이터를 Storage 인터페이스로 관리하면 일관성 있게 처리할 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 모든 데이터를 로컬에 저장하려는 것입니다. 민감한 정보나 보안이 필요한 데이터는 반드시 암호화하거나 서버에만 저장해야 합니다.

로컬 저장소는 언제든 접근 가능하기 때문입니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 이해했다는 표정을 지었습니다. "아, Storage 인터페이스를 만들어서 구현하면 되는 거군요!" 데이터 영속화를 제대로 이해하면 사용자 경험이 훨씬 좋은 앱을 만들 수 있습니다.

이제 구체적인 구현 방법을 배워봅시다.

실전 팁

💡 - Storage 인터페이스를 먼저 정의하면 나중에 구현체를 쉽게 교체할 수 있습니다

  • 민감한 정보는 반드시 암호화하거나 Secure Storage를 사용하세요
  • 저장할 데이터의 크기와 빈도를 고려해서 적절한 저장소를 선택하세요

2. Storage 인터페이스 구현

박시니어 씨가 코드를 하나 보여줍니다. "Storage 인터페이스는 좋은데, 실제로 어떻게 구현하나요?" 김개발 씨가 물었습니다.

"SharedPreferences를 사용해서 구현해볼까요?"

Storage 인터페이스 구현은 추상화된 저장소 개념을 실제 저장소로 연결하는 작업입니다. Flutter에서는 SharedPreferences, Hive, SQLite 등 다양한 저장소를 사용할 수 있습니다.

인터페이스를 먼저 정의하고 나중에 구현하면 저장소를 쉽게 교체할 수 있습니다.

다음 코드를 살펴봅시다.

// SharedPreferences를 사용한 Storage 구현
import 'package:shared_preferences/shared_preferences.dart';

class SharedPreferencesStorage implements Storage {
  final SharedPreferences _prefs;

  SharedPreferencesStorage(this._prefs);

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

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

  @override
  Future<void> delete(String key) async {
    await _prefs.remove(key);
  }
}

김개발 씨는 Storage 인터페이스의 개념은 이해했습니다. 하지만 실제로 어떻게 구현해야 할지 막막했습니다.

"인터페이스만 있으면 뭐하나요? 실제로 데이터를 저장할 수 있어야 하는데..." 박시니어 씨가 웃으며 말했습니다.

"바로 그게 인터페이스와 구현을 분리하는 이유예요. 이제 구현체를 만들어봅시다." 인터페이스 구현이란 무엇일까요?

쉽게 비유하자면, 인터페이스는 리모컨의 버튼 배치도이고 구현체는 실제 리모컨입니다. 배치도에는 "전원 버튼", "볼륨 버튼"이라고 적혀있지만, 실제 리모컨에는 버튼을 누르면 작동하는 회로가 들어있습니다.

Storage 인터페이스도 마찬가지입니다. save, load, delete 메서드가 정의되어 있지만, 실제로 어떻게 저장할지는 구현체에서 결정합니다.

Flutter에서는 다양한 저장소 옵션이 있습니다. SharedPreferences는 키-값 쌍으로 간단한 데이터를 저장하는 저장소입니다.

설정값, 사용자 이름, 토큰 같은 작은 데이터를 저장하기 좋습니다. Hive는 더 복잡한 객체를 빠르게 저장할 수 있는 NoSQL 데이터베이스입니다.

SQLite는 관계형 데이터베이스로 복잡한 쿼리가 필요한 경우에 사용합니다. 오늘은 가장 간단한 SharedPreferences로 구현해보겠습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 SharedPreferencesStorage 클래스가 Storage 인터페이스를 implements 한다고 선언합니다.

이는 "나는 Storage 인터페이스에 정의된 모든 메서드를 구현하겠다"는 약속입니다. 생성자에서 SharedPreferences 인스턴스를 받습니다.

이것은 의존성 주입 패턴입니다. 외부에서 필요한 객체를 받아오면 테스트하기 쉽고 유연한 코드가 됩니다.

save 메서드에서는 _prefs.setString을 호출해서 실제로 데이터를 저장합니다. SharedPreferences는 키-값 쌍으로 데이터를 저장하므로 key와 value를 전달합니다.

load 메서드에서는 _prefs.getString으로 저장된 데이터를 불러옵니다. 데이터가 없으면 null을 반환합니다.

delete 메서드에서는 _prefs.remove로 데이터를 삭제합니다. 이렇게 구현하면 어떤 장점이 있을까요?

첫째, 나중에 저장소를 바꾸고 싶으면 구현체만 교체하면 됩니다. SharedPreferences 대신 Hive를 쓰고 싶다면 HiveStorage를 만들어서 갈아끼우면 됩니다.

코드의 다른 부분은 전혀 수정할 필요가 없습니다. 둘째, 테스트하기 쉽습니다.

실제 SharedPreferences 대신 Mock Storage를 만들어서 테스트할 수 있습니다. 테스트가 빨라지고 안정적으로 됩니다.

셋째, 코드가 깔끔해집니다. Storage 인터페이스를 사용하는 쪽에서는 구체적인 저장소가 무엇인지 몰라도 됩니다.

"저장해줘", "불러와줘"라고만 하면 됩니다. 실제 프로젝트에서는 어떻게 사용할까요?

보통 앱 시작 시점에 SharedPreferences 인스턴스를 초기화하고, SharedPreferencesStorage를 생성합니다. 그리고 Riverpod Provider로 등록해서 앱 전체에서 사용합니다.

하지만 주의할 점도 있습니다. SharedPreferences는 간단한 데이터에만 적합합니다.

큰 파일이나 복잡한 객체를 저장하려면 다른 저장소를 사용해야 합니다. 또한 SharedPreferences는 암호화를 제공하지 않으므로 민감한 정보는 flutter_secure_storage 같은 보안 저장소를 사용해야 합니다.

김개발 씨는 이제 이해했습니다. "인터페이스로 추상화하고, 구현체로 실제 기능을 만드는 거군요!" 박시니어 씨가 고개를 끄덕였습니다.

"맞아요. 이제 실제로 사용해봅시다."

실전 팁

💡 - SharedPreferences 인스턴스는 앱 시작 시 한 번만 초기화하고 재사용하세요

  • 구현체를 Riverpod Provider로 등록하면 앱 전체에서 쉽게 사용할 수 있습니다
  • 테스트용 Mock Storage를 만들어두면 단위 테스트가 훨씬 쉬워집니다

3. SharedPreferences Storage Provider

김개발 씨가 물었습니다. "Storage를 구현했는데, 이걸 앱에서 어떻게 사용하나요?" 박시니어 씨가 답했습니다.

"Riverpod Provider로 등록하면 앱 어디서든 사용할 수 있어요."

Provider 등록은 앱 전체에서 Storage를 쉽게 사용할 수 있게 만드는 작업입니다. Riverpod의 Provider를 사용하면 의존성을 자동으로 관리할 수 있고, 필요한 곳에서 간단하게 주입받을 수 있습니다.

SharedPreferences 초기화도 Provider에서 처리합니다.

다음 코드를 살펴봅시다.

// Storage Provider 정의
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'storage_provider.g.dart';

@riverpod
Future<SharedPreferences> sharedPreferences(
  SharedPreferencesRef ref,
) async {
  return await SharedPreferences.getInstance();
}

@riverpod
Storage storage(StorageRef ref) {
  final prefs = ref.watch(sharedPreferencesProvider);
  return prefs.when(
    data: (prefs) => SharedPreferencesStorage(prefs),
    loading: () => throw Exception('Storage not ready'),
    error: (err, stack) => throw err,
  );
}

김개발 씨는 SharedPreferencesStorage를 구현했지만 막막했습니다. "이걸 매번 생성해서 쓰는 건가요?

그럼 코드가 지저분해질 것 같은데..." 박시니어 씨가 노트북을 열었습니다. "그래서 Riverpod Provider를 사용합니다.

한 번만 정의해두면 앱 어디서든 쉽게 쓸 수 있어요." Riverpod Provider란 무엇일까요? 쉽게 비유하자면, Provider는 마치 회사의 총무팀과 같습니다.

직원들이 필요한 물건을 달라고 하면 총무팀이 알아서 제공해줍니다. 직원들은 물건이 어디서 왔는지, 어떻게 준비되었는지 몰라도 됩니다.

Provider도 마찬가지입니다. 앱의 어느 부분에서든 Storage가 필요하다고 하면 Provider가 알아서 제공해줍니다.

Provider를 사용하면 어떤 장점이 있을까요? 첫째, 싱글톤 패턴을 자동으로 처리해줍니다.

SharedPreferences는 앱에서 하나만 있으면 되는데, Provider가 이것을 보장해줍니다. 여러 번 호출해도 같은 인스턴스를 반환합니다.

둘째, 의존성 관리가 자동입니다. Storage는 SharedPreferences에 의존하는데, Provider가 이 관계를 알아서 처리해줍니다.

Storage를 요청하면 자동으로 SharedPreferences도 준비됩니다. 셋째, 비동기 처리가 쉽습니다.

SharedPreferences.getInstance()는 비동기 메서드인데, Provider가 이것을 깔끔하게 처리해줍니다. 위의 코드를 자세히 살펴봅시다.

먼저 sharedPreferencesProvider를 정의합니다. 이것은 SharedPreferences 인스턴스를 제공하는 Provider입니다.

@riverpod 어노테이션을 사용하면 자동으로 Provider 코드가 생성됩니다. 함수 안에서 SharedPreferences.getInstance()를 호출합니다.

이것은 Future를 반환하므로 Provider도 Future를 반환합니다. Riverpod은 비동기 Provider를 잘 지원합니다.

다음으로 storageProvider를 정의합니다. 이것이 최종적으로 사용할 Storage Provider입니다.

ref.watch(sharedPreferencesProvider)로 SharedPreferences Provider를 구독합니다. 이렇게 하면 의존성이 자동으로 관리됩니다.

when 메서드로 비동기 상태를 처리합니다. data 상태일 때는 SharedPreferencesStorage를 생성해서 반환합니다.

loading 상태나 error 상태일 때는 예외를 던집니다. 실제로 사용할 때는 어떻게 할까요?

위젯이나 다른 Provider에서 ref.watch(storageProvider)만 호출하면 됩니다. Riverpod이 알아서 필요한 모든 것을 준비해줍니다.

예를 들어 사용자 설정을 저장하는 Provider를 만든다고 해봅시다. storageProvider를 watch하기만 하면 Storage를 바로 사용할 수 있습니다.

SharedPreferences를 직접 다룰 필요가 없습니다. 하지만 주의할 점도 있습니다.

Provider는 기본적으로 전역 상태입니다. 테스트할 때는 ProviderContainer를 사용해서 격리된 환경을 만들어야 합니다.

그렇지 않으면 테스트끼리 영향을 줄 수 있습니다. 또한 Provider 순환 참조를 조심해야 합니다.

A Provider가 B를 참조하고 B가 다시 A를 참조하면 에러가 발생합니다. 의존성 방향을 한쪽으로만 흐르게 설계해야 합니다.

김개발 씨가 코드를 보더니 말했습니다. "와, 이렇게 하면 정말 깔끔하네요.

어디서든 ref.watch만 하면 되는 거잖아요!" 박시니어 씨가 미소 지었습니다. "맞아요.

이제 실제로 사용자 데이터를 저장해봅시다."

실전 팁

💡 - @riverpod 어노테이션을 사용하면 Provider 코드가 자동 생성되어 타입 안정성이 보장됩니다

  • 비동기 Provider는 AsyncValue로 감싸져서 loading, error 상태를 쉽게 처리할 수 있습니다
  • 테스트할 때는 ProviderContainer.overrideWith로 Mock Provider를 주입하세요

4. 사용자 설정 저장 예제

"이제 실전입니다." 박시니어 씨가 말했습니다. "사용자가 다크 모드를 켜면 앱을 껐다 켜도 다크 모드가 유지되어야 해요.

Storage를 사용해서 구현해봅시다."

사용자 설정 영속화는 앱의 설정값을 로컬에 저장하는 패턴입니다. 다크 모드, 언어 설정, 알림 설정 같은 값을 Storage에 저장하면 앱을 다시 켜도 이전 설정이 유지됩니다.

Riverpod의 Notifier와 함께 사용하면 상태 관리와 영속화를 동시에 처리할 수 있습니다.

다음 코드를 살펴봅시다.

// 사용자 설정을 저장하는 Notifier
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'settings_provider.g.dart';

@riverpod
class UserSettings extends _$UserSettings {
  static const _darkModeKey = 'dark_mode';

  @override
  Future<bool> build() async {
    // 저장된 설정 불러오기
    final storage = ref.watch(storageProvider);
    final saved = await storage.load(_darkModeKey);
    return saved == 'true';
  }

  Future<void> setDarkMode(bool enabled) async {
    // 설정 저장하고 상태 업데이트
    final storage = ref.watch(storageProvider);
    await storage.save(_darkModeKey, enabled.toString());
    state = AsyncValue.data(enabled);
  }
}

김개발 씨는 드디어 실전 예제를 만들 준비가 되었습니다. "다크 모드 설정을 저장하면 되는 거죠?" 박시니어 씨가 고개를 끄덕였습니다.

"네, Notifier를 사용해서 만들어봅시다." 설정 영속화는 왜 중요할까요? 사용자들은 앱을 자기 취향대로 설정합니다.

다크 모드를 켜고, 알림을 끄고, 언어를 바꿉니다. 그런데 앱을 다시 켤 때마다 설정이 초기화된다면?

매번 다시 설정해야 합니다. 사용자는 금방 짜증이 나고 앱을 삭제할 것입니다.

쉽게 비유하자면, 설정 영속화는 마치 자동차 시트 위치를 저장하는 것과 같습니다. 운전자가 시트 위치를 조정하면 차가 기억합니다.

다음에 시동을 걸면 시트가 저장된 위치로 자동으로 움직입니다. 매번 조정할 필요가 없어 편리합니다.

Riverpod에서는 AsyncNotifier를 사용해서 이것을 구현합니다. 위의 코드를 자세히 살펴봅시다.

먼저 UserSettings라는 AsyncNotifier를 정의합니다. AsyncNotifier는 비동기 상태를 관리하는 클래스입니다.

설정을 불러오는 것이 비동기 작업이므로 AsyncNotifier를 사용합니다. _darkModeKey는 Storage에 저장할 키 이름입니다.

상수로 정의해두면 오타를 방지할 수 있습니다. build 메서드는 초기 상태를 만듭니다.

앱이 시작될 때 자동으로 호출됩니다. 여기서 Storage에서 저장된 설정을 불러옵니다.

ref.watch(storageProvider)로 Storage를 가져옵니다. 그리고 storage.load로 저장된 값을 불러옵니다.

값이 없으면 null이 반환되므로 기본값인 false를 사용합니다. setDarkMode 메서드는 설정을 변경하는 메서드입니다.

사용자가 다크 모드를 켜거나 끄면 이 메서드가 호출됩니다. 먼저 storage.save로 새로운 값을 저장합니다.

그리고 state를 업데이트합니다. state를 업데이트하면 이 Provider를 구독하는 모든 위젯이 자동으로 다시 빌드됩니다.

실제로 위젯에서는 어떻게 사용할까요? 다크 모드 토글 스위치를 만든다고 해봅시다.

ref.watch(userSettingsProvider)로 현재 설정을 가져오고, ref.read(userSettingsProvider.notifier).setDarkMode()로 설정을 변경합니다. 사용자가 스위치를 토글하면 setDarkMode가 호출되고, Storage에 저장되고, 화면이 업데이트됩니다.

앱을 종료하고 다시 켜면 build 메서드가 Storage에서 값을 불러와서 이전 설정이 복원됩니다. 이 패턴의 장점은 무엇일까요?

첫째, 관심사의 분리가 잘 되어 있습니다. 위젯은 Storage가 어떻게 동작하는지 몰라도 됩니다.

그냥 설정을 읽고 쓰기만 하면 됩니다. 둘째, 자동 동기화가 됩니다.

설정이 바뀌면 모든 관련 위젯이 자동으로 업데이트됩니다. 수동으로 화면을 갱신할 필요가 없습니다.

셋째, 테스트하기 쉽습니다. storageProvider를 Mock으로 교체하면 실제 파일 시스템 없이 테스트할 수 있습니다.

하지만 주의할 점도 있습니다. 모든 설정을 개별 Provider로 만들면 Provider가 너무 많아집니다.

대신 설정들을 하나의 클래스로 묶고, 전체를 저장하는 방식도 고려해보세요. JSON으로 직렬화하면 여러 설정을 한 번에 저장할 수 있습니다.

김개발 씨가 직접 코드를 작성해보고는 감탄했습니다. "와, 정말 간단하네요.

몇 줄 안 되는데 완벽하게 동작해요!" 박시니어 씨가 웃었습니다. "다음은 더 복잡한 예제를 해봅시다.

장바구니를 저장해볼까요?"

실전 팁

💡 - 설정값이 여러 개면 클래스로 묶어서 JSON으로 저장하는 것이 효율적입니다

  • build 메서드에서 에러 처리를 잘 해두면 저장된 데이터가 손상되어도 앱이 안정적입니다
  • 설정 키 이름은 상수로 정의해서 오타를 방지하세요

5. 장바구니 영속화 예제

"이번에는 조금 더 복잡합니다." 박시니어 씨가 말했습니다. "장바구니에는 여러 상품이 들어있고, 각 상품은 이름, 가격, 수량 같은 정보를 가지고 있어요.

이것을 어떻게 저장할까요?"

복잡한 객체의 영속화는 여러 필드를 가진 데이터를 저장하는 패턴입니다. 장바구니 같은 복잡한 데이터는 JSON으로 직렬화해서 문자열로 변환한 후 저장합니다.

불러올 때는 역직렬화해서 다시 객체로 만듭니다. Freezed와 json_serializable을 사용하면 이 과정을 자동화할 수 있습니다.

다음 코드를 살펴봅시다.

// 장바구니 상품 모델
@freezed
class CartItem with _$CartItem {
  factory CartItem({
    required String id,
    required String name,
    required double price,
    required int quantity,
  }) = _CartItem;

  factory CartItem.fromJson(Map<String, dynamic> json) =>
      _$CartItemFromJson(json);
}

// 장바구니 Provider
@riverpod
class ShoppingCart extends _$ShoppingCart {
  static const _cartKey = 'shopping_cart';

  @override
  Future<List<CartItem>> build() async {
    final storage = ref.watch(storageProvider);
    final saved = await storage.load(_cartKey);
    if (saved == null) return [];

    final List<dynamic> jsonList = jsonDecode(saved);
    return jsonList.map((json) => CartItem.fromJson(json)).toList();
  }

  Future<void> addItem(CartItem item) async {
    final current = state.value ?? [];
    final updated = [...current, item];
    await _saveCart(updated);
    state = AsyncValue.data(updated);
  }

  Future<void> _saveCart(List<CartItem> items) async {
    final storage = ref.watch(storageProvider);
    final jsonList = items.map((item) => item.toJson()).toList();
    await storage.save(_cartKey, jsonEncode(jsonList));
  }
}

김개발 씨는 다크 모드 설정 저장은 쉽게 구현했습니다. 하지만 장바구니는 다릅니다.

"설정은 true/false 같은 간단한 값인데, 장바구니는 여러 상품이 있고 각 상품도 여러 정보가 있잖아요. 이걸 어떻게 저장하죠?" 박시니어 씨가 설명을 시작했습니다.

"복잡한 객체는 JSON으로 변환해서 저장합니다." JSON 직렬화란 무엇일까요? 쉽게 비유하자면, JSON 직렬화는 마치 물건을 택배 상자에 포장하는 것과 같습니다.

복잡한 물건을 그대로 보낼 수는 없으니 상자에 잘 포장합니다. 받는 사람은 상자를 열어서 원래 물건을 꺼냅니다.

JSON 직렬화도 마찬가지입니다. 복잡한 객체를 JSON 문자열로 변환해서 저장하고, 나중에 다시 객체로 복원합니다.

Flutter에서는 Freezedjson_serializable을 사용해서 이것을 자동화할 수 있습니다. 위의 코드를 자세히 살펴봅시다.

먼저 CartItem 모델을 정의합니다. @freezed 어노테이션을 사용하면 불변 클래스가 자동으로 생성됩니다.

id, name, price, quantity 필드를 가지고 있습니다. fromJson 팩토리 생성자를 정의합니다.

이것은 JSON Map을 CartItem 객체로 변환합니다. _$CartItemFromJson 함수는 build_runner가 자동으로 생성해줍니다.

toJson 메서드도 자동으로 생성됩니다. 이것은 CartItem 객체를 JSON Map으로 변환합니다.

다음으로 ShoppingCart Provider를 정의합니다. build 메서드에서 저장된 장바구니를 불러옵니다.

Storage에서 문자열을 가져와서 jsonDecode로 파싱합니다. 그리고 각 항목을 CartItem.fromJson으로 변환합니다.

addItem 메서드는 장바구니에 상품을 추가합니다. 현재 장바구니에 새 상품을 추가하고, _saveCart를 호출해서 저장합니다.

_saveCart는 장바구니를 JSON으로 변환해서 Storage에 저장하는 헬퍼 메서드입니다. 각 CartItem을 toJson으로 변환하고, jsonEncode로 문자열을 만들어서 저장합니다.

이렇게 구현하면 어떤 일이 일어날까요? 사용자가 상품을 장바구니에 담으면 즉시 Storage에 저장됩니다.

앱을 종료하고 다시 열면 build 메서드가 Storage에서 장바구니를 불러옵니다. 사용자는 이전에 담았던 상품을 그대로 볼 수 있습니다.

실제 쇼핑몰 앱에서는 이런 기능이 필수입니다. 사용자가 여러 상품을 둘러보다가 마음에 드는 것을 장바구니에 담습니다.

잠시 다른 일을 하다가 다시 앱을 열었을 때 장바구니가 비어있다면? 사용자는 실망하고 구매를 포기할 것입니다.

장바구니 영속화는 전환율을 높이는 중요한 기능입니다. 아마존, 쿠팡 같은 대형 쇼핑몰들은 모두 이 기능을 제공합니다.

하지만 주의할 점도 있습니다. 첫째, 데이터 마이그레이션을 고려해야 합니다.

CartItem에 새 필드를 추가하면 기존에 저장된 데이터는 어떻게 될까요? 호환성을 유지하는 전략이 필요합니다.

둘째, 저장 시점을 잘 선택해야 합니다. 매번 상태가 바뀔 때마다 저장하면 성능이 떨어질 수 있습니다.

하지만 너무 늦게 저장하면 데이터를 잃을 수 있습니다. 셋째, 에러 처리가 중요합니다.

JSON 파싱은 실패할 수 있습니다. 저장된 데이터가 손상되었거나 형식이 맞지 않으면 예외가 발생합니다.

try-catch로 감싸서 안전하게 처리해야 합니다. 김개발 씨가 코드를 작성하고 테스트해봤습니다.

"와, 정말 잘 동작하네요! 앱을 껐다 켜도 장바구니가 그대로 있어요!" 박시니어 씨가 만족스러운 표정을 지었습니다.

실전 팁

💡 - Freezed와 json_serializable을 사용하면 직렬화 코드를 자동 생성할 수 있습니다

  • 복잡한 객체는 JSON으로 저장하되, 너무 큰 데이터는 SQLite를 고려하세요
  • 저장 실패에 대비해 try-catch를 사용하고 사용자에게 적절한 피드백을 주세요

6. 오프라인 우선 패턴

"마지막 주제입니다." 박시니어 씨가 말했습니다. "지금까지는 로컬에만 저장했는데, 실제 앱은 서버와도 동기화해야 합니다.

오프라인 우선 패턴을 배워봅시다."

오프라인 우선 패턴은 로컬 데이터를 먼저 사용하고 백그라운드에서 서버와 동기화하는 아키텍처입니다. 네트워크가 느리거나 없어도 앱이 즉시 반응하므로 사용자 경험이 좋아집니다.

변경사항을 로컬에 저장한 후 서버에 전송하고, 서버 응답으로 최종 상태를 업데이트합니다.

다음 코드를 살펴봅시다.

// 오프라인 우선 패턴 구현
@riverpod
class OfflineFirstCart extends _$OfflineFirstCart {
  static const _cartKey = 'offline_cart';

  @override
  Future<List<CartItem>> build() async {
    // 1. 로컬에서 먼저 불러오기
    final storage = ref.watch(storageProvider);
    final saved = await storage.load(_cartKey);

    List<CartItem> localCart = [];
    if (saved != null) {
      final jsonList = jsonDecode(saved) as List;
      localCart = jsonList.map((json) => CartItem.fromJson(json)).toList();
    }

    // 2. 백그라운드에서 서버와 동기화
    _syncWithServer(localCart);

    return localCart;
  }

  Future<void> addItem(CartItem item) async {
    // 1. 로컬에 즉시 반영 (낙관적 업데이트)
    final current = state.value ?? [];
    final updated = [...current, item];
    state = AsyncValue.data(updated);

    // 2. 로컬 스토리지에 저장
    await _saveLocal(updated);

    // 3. 서버에 전송 (백그라운드)
    _syncToServer(item);
  }

  Future<void> _saveLocal(List<CartItem> items) async {
    final storage = ref.watch(storageProvider);
    final jsonList = items.map((e) => e.toJson()).toList();
    await storage.save(_cartKey, jsonEncode(jsonList));
  }

  Future<void> _syncWithServer(List<CartItem> localCart) async {
    // 서버에서 최신 데이터 가져오기
    // 충돌 해결 로직 구현
  }

  Future<void> _syncToServer(CartItem item) async {
    // 서버에 변경사항 전송
    // 에러 처리 및 재시도 로직 구현
  }
}

김개발 씨는 지금까지 배운 내용을 정리했습니다. "로컬에 저장하는 건 이제 잘 알겠어요.

그런데 실제 앱은 서버도 있잖아요. 로컬과 서버 중 어느 걸 먼저 사용해야 하나요?" 박시니어 씨가 중요한 개념을 설명하기 시작했습니다.

"오프라인 우선 패턴입니다." 오프라인 우선 패턴이란 무엇일까요? 쉽게 비유하자면, 오프라인 우선은 마치 신용카드 사용과 비슷합니다.

가게에서 카드로 결제하면 즉시 영수증을 받습니다. 하지만 실제로 은행 계좌에서 돈이 빠져나가는 것은 나중입니다.

가게는 일단 결제를 승인하고, 나중에 카드사와 정산합니다. 사용자는 기다릴 필요가 없습니다.

오프라인 우선도 마찬가지입니다. 사용자가 액션을 하면 로컬에 즉시 반영합니다.

그리고 백그라운드에서 서버와 동기화합니다. 네트워크가 느리거나 없어도 앱은 즉시 반응합니다.

이 패턴이 왜 중요할까요? 모바일 환경은 네트워크가 불안정합니다.

지하철, 엘리베이터, 시골 지역에서는 인터넷이 끊깁니다. 서버가 느리면 앱도 느려집니다.

사용자는 앱이 느리면 금방 짜증을 냅니다. 구글, 페이스북, 인스타그램 같은 대형 앱들은 모두 오프라인 우선 패턴을 사용합니다.

인스타그램에서 좋아요를 누르면 즉시 하트가 빨개집니다. 서버 응답을 기다리지 않습니다.

이것이 오프라인 우선입니다. 위의 코드를 자세히 살펴봅시다.

build 메서드에서 로컬 데이터를 먼저 불러옵니다. 이것이 핵심입니다.

서버를 기다리지 않고 로컬 데이터로 즉시 화면을 보여줍니다. 그리고 _syncWithServer를 호출해서 백그라운드에서 서버와 동기화합니다.

서버에 최신 데이터가 있으면 로컬을 업데이트합니다. addItem 메서드도 같은 패턴입니다.

먼저 로컬 state를 즉시 업데이트합니다. 이것을 낙관적 업데이트라고 합니다.

서버 응답을 기다리지 않고 성공할 것이라고 낙관적으로 가정하고 UI를 먼저 업데이트하는 것입니다. 그리고 로컬 Storage에 저장합니다.

앱을 종료해도 데이터가 보존됩니다. 마지막으로 _syncToServer를 호출해서 서버에 전송합니다.

이것은 백그라운드에서 비동기로 실행됩니다. 사용자는 기다리지 않습니다.

만약 서버 전송이 실패하면 어떻게 할까요? 여러 전략이 있습니다.

가장 간단한 방법은 재시도입니다. 실패하면 몇 초 후에 다시 시도합니다.

여러 번 실패하면 로컬에 "동기화 대기중" 표시를 남겨두고, 나중에 네트워크가 복구되면 다시 시도합니다. 더 복잡한 앱에서는 동기화 큐를 만듭니다.

실패한 작업들을 큐에 저장해두고, 백그라운드에서 순서대로 재시도합니다. 충돌 해결도 중요합니다.

로컬에서 장바구니를 수정했는데, 서버에서도 다른 기기가 동시에 수정했다면? 누구의 변경사항을 우선할까요?

타임스탬프를 비교하거나, 버전 번호를 사용하거나, 사용자에게 선택하게 할 수 있습니다. 실제 프로덕션 앱에서는 이런 문제들을 신중하게 고려해야 합니다.

하지만 기본 원칙은 간단합니다. 로컬 먼저, 동기화는 나중에입니다.

김개발 씨가 감탄했습니다. "와, 이래서 인스타그램이 그렇게 빠른 거였군요!

로컬에 먼저 반영하고 나중에 서버로 보내는 거였네요." 박시니어 씨가 웃었습니다. "맞아요.

사용자는 기다리는 것을 싫어합니다. 오프라인 우선 패턴으로 즉각적인 반응을 제공하면 사용자 경험이 크게 향상됩니다." 이제 김개발 씨는 Offline 데이터 영속화를 완벽하게 이해했습니다.

Storage 인터페이스, SharedPreferences 구현, Riverpod Provider 등록, 설정 저장, 장바구니 영속화, 그리고 오프라인 우선 패턴까지 모두 배웠습니다. "이제 실전 프로젝트에 바로 적용할 수 있겠어요!" 김개발 씨가 자신감 있게 말했습니다.

실전 팁

💡 - 낙관적 업데이트로 즉각적인 UI 반응을 제공하되, 서버 실패 시 롤백 처리를 잊지 마세요

  • 동기화 큐를 구현하면 네트워크가 불안정해도 데이터 손실을 방지할 수 있습니다
  • 타임스탬프나 버전 번호로 충돌을 감지하고 적절한 해결 전략을 수립하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#Offline#SharedPreferences#DataPersistence#Storage#Flutter,Riverpod

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스