티스토리 뷰

DOWNLOAD

뻥이야 다운로드

 

0123456

 

Android iOS


SUMMARY

뻥이야 (Kxcuse) - AI 핑계 생성 Flutter 앱

MZ세대를 위한 AI 기반 창의적 핑계/변명 생성 모바일 앱 개발기입니다.

Clean Architecture + MVVM + Riverpod 기반 237개 Dart 파일, 약 116,000줄 규모.

GoRouter + KRouter 선언형 라우팅, Hive 4개 DB 차등 라이프사이클 관리.

주요 기술적 도전: AbusePrevention 영구 DB, 자동 토큰 갱신 시스템, 크로스 페이지 데이터 동기화


TABLE OF CONTENTS

목차

왜 이 앱을 만들었나? 아키텍처 설계 주요 기능 기술적 도전과 해결 핵심 코드


WHY

왜 이 앱을 만들었나?

"오늘 왜 늦었어?", "왜 약속에 못 와?" - 일상에서 마주하는 순간마다 재치 있는 핑계가 필요할 때가 있습니다.

단순한 핑계 생성기가 아닌, AI가 상황을 분석하고 캐릭터 페르소나말투(Tone)를 반영한 창의적인 핑계를 만들어주는 앱을 기획했습니다.

기술적으로는 Clean Architecture의 레이어 분리, 보안 데이터의 영구 보존, 앱 라이프사이클과 연동된 토큰 관리 등 실서비스 수준의 아키텍처를 구현하고 싶었습니다.

그 결과, 237개 Dart 파일, 약 116,000줄 규모의 Flutter 앱이 탄생했습니다.


ARCHITECTURE

아키텍처 설계

Clean Architecture + MVVM + Riverpod

Presentation Layer — pages(18+), viewmodels(Riverpod ViewModel), widgets(42+), styles(KColors 테마)

Domain Layer — models(불변 엔티티), repositories(인터페이스), usecases(32+ 단일 책임)

Data Layer — repositories(구현체), services(API/Token/Background), dbs(Hive 4개), configs(Dio 인터셉터)


GetIt + Injectable 의존성 주입

코드 생성 기반 DI — build_runner로 DI 코드 자동 생성, 런타임 오류 방지

싱글톤/팩토리 패턴 — Service는 싱글톤, ViewModel은 팩토리로 생명주기 관리


GoRouter + KRouter 네비게이션 시스템

GoRouter 선언형 라우팅 — Scheme enum 기반 타입 안전 라우트, go_transitions 애니메이션

KRouter 래퍼 — 인증 가드(isUseLogin), 다이얼로그 상태 추적, 중복 라우트 방지, Kakao 딥링크 필터링


FEATURES

주요 기능

TokenManagerService - 자동 토큰 갱신

선제적 갱신 — 만료 24시간 전 자동 갱신, 30분 주기 Timer 기반 검증

라이프사이클 연동 — 앱 resume 시 토큰 유효성 즉시 재검증

Dio 인터셉터 — 모든 API 요청에 자동 토큰 주입


Hive 4개 DB - 차등 라이프사이클 관리

UserSettingsDatabase — 토큰, 테마 설정 (로그아웃 시 초기화)

CacheDatabase — API 응답 캐시 (로그아웃 시 초기화)

MemorialDatabase — 핑계 목록 로컬 저장

AbusePreventionDatabase — 가입 보너스/출석 추적 (영구 보존, 탈퇴 시에도 삭제 금지)


BackgroundUpdateService + CRUD Sync Event

백그라운드 동기화 — 비활성 페이지 데이터 자동 갱신 (활성 시 취소)

CRUD Sync Event — BaseViewModel.syncCRUDEvent()로 페이지 간 데이터 일관성

PointProviders — Riverpod 반응형 포인트 상태 전역 관리


KColors 동적 테마 시스템

3가지 테마 모드 — 라이트 / 다크 / 시스템 자동 감지

KColors() 싱글톤 — isDarkTheme 플래그 기반 동적 색상 반환

Pretendard 커스텀 폰트 — 9웨이트 (Thin 100 ~ Black 900)


TROUBLESHOOTING

기술적 도전과 해결

PROBLEM 01

로그아웃/탈퇴 시 보안 데이터와 사용자 데이터의 차등 초기화

로그아웃 시 토큰과 캐시는 삭제해야 하지만, 가입 보너스 악용 방지를 위한 AbusePrevention 데이터는 영구 보존해야 했습니다. 계정 탈퇴 후 재가입해도 보너스를 중복 수령하면 안 됩니다.

문제 상황

단순한 전체 DB clear()로는 보안 데이터까지 삭제되어, 재가입 시 200pt 가입 보너스를 무한 수령할 수 있는 취약점 발생.

SOLUTION - Hive DB 4개 분리 + 차등 초기화 로직

// KRouter.offAllWithLogout() 차등 초기화
Future<void> offAllWithLogout() async {
  // 사용자 데이터 초기화 (세션 종료)
  await UserSettingsDatabase.clear();
  await CacheDatabase.clear();

  // AbusePrevention은 절대 clear() 호출 금지!
  // 가입 보너스, 출석 체크 등 악용 방지 데이터 영구 보존

  clearAllViewModels();
  forceCloseAllDialogs();
  go(Scheme.login);
}

PROBLEM 02

페이지 간 데이터 불일치 (크로스 페이지 동기화)

핑계 생성 후 목록 페이지로 돌아갔을 때 새 핑계가 표시되지 않거나, 포인트 차감이 다른 페이지에 반영되지 않는 문제가 발생했습니다.

문제 상황

각 페이지의 ViewModel이 독립적으로 데이터를 로드/캐싱하므로, 한 페이지에서의 CRUD 변경이 다른 페이지에 자동 전파되지 않음.

SOLUTION - BaseViewModel CRUD Sync Event 패턴

// BaseViewModel.dart
abstract class BaseViewModel {
  // 페이지 간 CRUD 이벤트 동기화
  void syncCRUDEvent(CRUDEventType type, dynamic data) {
    // 모든 활성 ViewModel에 이벤트 전파
    _registeredViewModels.forEach((vm) => vm.onCRUDEvent(type, data));
  }
}

// KRouter.back() 호출 시 자동 동기화
Future<void> back() async {
  await syncWithMemorialDb();  // 로컬 DB 동기화
  _notifyActiveViewModels();   // ViewModel 갱신 트리거
  GoRouter.of(context).pop();
}

PROBLEM 03

토큰 만료로 인한 갑작스러운 로그아웃

사용자가 앱을 사용하던 중 토큰이 만료되어 갑자기 로그인 화면으로 이동하는 UX 문제가 발생했습니다. 특히 백그라운드에서 앱이 오래 있다가 포그라운드로 돌아올 때 자주 발생했습니다.

SOLUTION - TokenManagerService 선제적 갱신 + 라이프사이클 연동

// TokenManagerService.dart
class TokenManagerService {
  Timer? _refreshTimer;

  void startAutoRefresh() {
    // 30분마다 토큰 상태 검증
    _refreshTimer = Timer.periodic(Duration(minutes: 30), (_) {
      _checkAndRefreshIfNeeded();
    });
  }

  Future<void> _checkAndRefreshIfNeeded() async {
    final expiresAt = await _db.getTokenExpiry();
    final remaining = expiresAt.difference(DateTime.now());

    // 만료 24시간 전에 선제적 갱신
    if (remaining < Duration(hours: 24)) {
      await refreshToken();
    }
  }
}

// main.dart - 앱 라이프사이클 훅
WidgetsBinding.instance.addObserver(
  LifecycleEventHandler(
    onResume: () => TokenManagerService.validateAndRefresh(),
  ),
);

CORE CODE

핵심 코드

1. KRouter 네비게이션 래퍼

// KRouter.dart - 인증 가드 + 다이얼로그 관리
class KRouter {
  static final Set<Dialog> _activeDialogs = {};

  static Future<void> to(Scheme scheme, {Object? extra}) async {
    // 인증 필요 라우트 체크
    if (scheme.isUseLogin && !await _isLoggedIn()) {
      return go(Scheme.login);
    }

    // Kakao OAuth 딥링크 필터링
    if (_isKakaoDeepLink(scheme)) return;

    // 중복 라우트 방지
    if (_currentRoute == scheme.path) return;

    GoRouter.of(_context).push(scheme.path, extra: extra);
  }

  static void forceCloseAllDialogs() {
    for (final dialog in _activeDialogs.toList()) {
      Navigator.of(_context).pop();
    }
    _activeDialogs.clear();
  }
}

2. Riverpod ViewModel Provider

// providers/excuse_providers.dart
@riverpod
class ExcuseListViewModel extends _$ExcuseListViewModel {
  @override
  AsyncValue<List<Excuse>> build() => const AsyncValue.loading();

  Future<void> loadExcuses() async {
    state = const AsyncValue.loading();
    try {
      final useCase = ref.read(getExcuseListUseCaseProvider);
      final excuses = await useCase.execute();
      state = AsyncValue.data(excuses);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }

  // CRUD Sync Event 수신
  void onCRUDEvent(CRUDEventType type, Excuse excuse) {
    if (type == CRUDEventType.created) {
      state = state.whenData((list) => [excuse, ...list]);
    }
  }
}

3. KColors 동적 테마

// styles/KColors.dart
class KColors {
  static final KColors _instance = KColors._();
  factory KColors() => _instance;
  KColors._();

  bool isDarkTheme = false;

  Color get primary => isDarkTheme ? Color(0xFF667EEA) : Color(0xFF5A67D8);
  Color get background => isDarkTheme ? Color(0xFF1A1A2E) : Color(0xFFF7FAFC);
  Color get textPrimary => isDarkTheme ? Color(0xFFE2E8F0) : Color(0xFF2D3748);

  void updateTheme(AppThemeTypes type) {
    switch (type) {
      case AppThemeTypes.light: isDarkTheme = false;
      case AppThemeTypes.dark: isDarkTheme = true;
      case AppThemeTypes.system:
        isDarkTheme = WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark;
    }
  }
}

TECH STACK

기술 스택

Core Framework

Flutter 3.32.1 Dart SDK ^3.5.4

State Management & DI

flutter_riverpod 2.6.1 riverpod_annotation 2.6.1 get_it 8.0.3 injectable 2.5.0

Routing & Navigation

go_router 15.1.2 go_transitions 0.8.1

Local Storage & Network

hive 2.2.3 dio 5.8.0

Authentication

kakao_flutter_sdk 1.9.7 sign_in_with_apple 7.0.1

Firebase & Analytics

firebase_core 3.4.0 firebase_crashlytics 4.3.6 firebase_remote_config

Animation & UI

flutter_animate 4.5.2 lottie 3.2.0 shimmer 3.0.0 Pretendard 9 weights



CONCLUSION

결론

뻥이야는 단순한 핑계 생성 앱을 넘어, 실서비스 수준의 아키텍처를 구현한 Flutter 프로젝트입니다.

Clean Architecture의 엄격한 레이어 분리, Hive DB의 차등 라이프사이클 관리, TokenManagerService의 선제적 토큰 갱신, KRouter의 인증 가드와 다이얼로그 관리 등 실무에서 마주하는 복잡한 요구사항들을 해결했습니다.

특히 AbusePrevention 영구 DB를 통한 보안 데이터 보호와 CRUD Sync Event 패턴을 통한 크로스 페이지 데이터 일관성은 이 프로젝트의 핵심 설계입니다.

237개 Dart 파일, 약 116,000줄 규모의 이 프로젝트를 통해 Flutter 앱 개발의 전반적인 아키텍처 설계 역량을 쌓을 수 있었습니다.


DOWNLOAD

뻥이야 다운로드

 

0123456

 

Android iOS


긴 글을 읽어주셔서 감사합니다!

처음 이 앱을 기획했을 때는 "재미있는 핑계 생성기"를 만들고 싶었습니다.

하지만 개발을 진행하면서 토큰 만료 문제, 보안 데이터 보호, 페이지 간 데이터 동기화 등 예상치 못한 도전들을 마주하게 되었습니다.

특히 "로그아웃해도 삭제되면 안 되는 데이터"와 "삭제되어야 하는 데이터"를 어떻게 분리할 것인가에 대한 고민은 Hive 4개 DB 차등 라이프사이클 관리라는 해결책으로 이어졌습니다.

이 프로젝트가 Flutter로 실서비스를 개발하시는 분들께 작은 도움이 되었으면 합니다.

다음 포스트에서는 뻥이야의 AWS Serverless 백엔드 (Lambda + DynamoDB + OpenAI GPT)에 대해 다루겠습니다!