Files
submanager/doc/SubManager_PRD.md
JiWoong Sul 8619e96739 Initial commit: SubManager Flutter App
주요 구현 완료 기능:
- 구독 관리 (추가/편집/삭제/카테고리 분류)
- 이벤트 할인 시스템 (기본값 자동 설정)
- SMS 자동 스캔 및 구독 정보 추출
- 알림 시스템 (타임존 처리 안정화)
- 환율 변환 지원 (KRW/USD)
- 반응형 UI 및 애니메이션
- 다국어 지원 (한국어/영어)

버그 수정:
- NotificationService tz.local 초기화 오류 해결
- MainScreenSummaryCard 레이아웃 오버플로우 수정
- 구독 추가 시 LateInitializationError 완전 해결

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 14:29:53 +09:00

70 KiB

SubManager - 향상된 제품 요구사항 정의서(PRD)

1. 제품 개요

1.1 앱 소개

SubManager는 사용자의 정기 구독 서비스를 효율적으로 관리하고, 지출을 추적하며 절약을 돕기 위한 모바일 앱입니다. 사용자들이 자신의 구독 서비스를 손쉽게 추가하고, 결제일을 기억하며, 총 지출을 한눈에 파악할 수 있도록 설계되었습니다.

1.2 핵심 가치

  • 서버 없는 구조: 모든 데이터는 로컬에 저장되어 프라이버시 보호
  • 자동 탐지: SMS 메시지 분석을 통한 구독 서비스 자동 탐지 기능
  • 직관적 UI/UX: Material 3 디자인의 카드 기반 사용자 인터페이스
  • 지출 분석: 다양한 차트와 통계를 통한 구독 지출 최적화 지원

1.3 타겟 사용자

  • 다수의 구독 서비스를 이용하는 디지털 소비자
  • 월간 지출 관리에 관심 있는 사용자
  • 결제일과 예산을 체계적으로 관리하고자 하는 사용자

2. 기술 스택

2.1 프레임워크 및 언어

  • Flutter 3.x: 크로스 플랫폼 모바일 애플리케이션 개발
  • Dart: 프로그래밍 언어, 널 안전성(null-safety) 활용

2.2 데이터 저장

  • Hive: 로컬 데이터베이스 (NoSQL 키-값 저장소)
    • 모든 데이터를 타입별 Box에 저장 (subscriptions, categories 등)
    • 데이터 모델에 @HiveType@HiveField 애노테이션 적용
  • flutter_secure_storage: 민감한 정보를 위한 암호화 저장소
    • 생체 인증 설정, 알림 설정 등 저장

2.3 상태 관리

  • Provider: 애플리케이션 상태 관리 패턴
    • ChangeNotifier를 상속한 Provider 클래스로 상태 관리
    • ConsumerProvider.of<T> 방식으로 상태 구독
    • 위젯 트리 최상단에 MultiProvider로 모든 Provider 등록

2.4 주요 패키지

  • flutter_local_notifications: 로컬 알림 관리
    • 구독 결제일 알림 스케줄링
    • 알림 채널 구성 및 권한 관리
  • fl_chart: 차트 및 그래프 시각화
    • BarChart, PieChart로 지출 데이터 시각화
  • intl: 국제화 및 날짜/숫자 포맷팅
    • 통화 형식, 날짜 포맷을 현지화하여 표시
  • local_auth: 생체 인증
    • 지문, 얼굴 인식을 통한 앱 잠금 관리
  • telephony/flutter_sms: SMS 메시지 접근
    • 구독 서비스 자동 탐지용 SMS 분석
  • http: API 요청
    • 환율 정보 가져오기 (ExchangeRate-API 사용)
  • url_launcher: 외부 URL 열기
    • 구독 서비스 웹사이트로 바로 이동 기능

3. 핵심 기능 명세

3.1 구독 관리

  • 구독 서비스 수동 추가/편집/삭제
    • 사용자가 직접 구독 정보 입력 및 관리
    • 모든 필드에 대한 유효성 검사 구현
  • 서비스명, 월 비용, 결제 주기, 결제일 관리
    • 주기 옵션: 월간, 연간, 주간 선택 가능
    • 결제일: 달력 UI로 날짜 선택
  • 카테고리별 구독 서비스 분류
    • 기본 제공 카테고리: OTT, 음악, AI, 프로그래밍/개발, 오피스/협업 툴, 기타
    • 사용자 정의 카테고리 추가/편집/삭제 가능
  • 중복 구독 확인 및 관리
    • 동일 서비스 중복 등록 시 알림
    • 서비스명 유사도 검사 알고리즘 구현

3.2 SMS 자동 탐지

  • SMS 메시지 기반 구독 서비스 자동 인식
    • 구독 관련 키워드 필터링 (구독, 결제, 청구, subscription 등)
    • 발신자 및 내용 패턴 분석
  • 반복 결제 패턴 분석
    • 동일 발신자, 유사 금액으로 2회 이상 메시지가 발견된 경우 구독으로 판단
    • 결제 주기 자동 감지 (월 단위, 연 단위)
  • 일회성 결제와 정기 구독 구분
    • 반복 결제 횟수(repeatCount) 추적
    • 구독 서비스 이름 매칭 로직으로 서비스 식별

3.3 결제일 알림

  • 결제 예정일 3일 전 알림
    • 알림 제목: "구독 결제 예정 알림"
    • 알림 내용: "[서비스명] 결제가 3일 후 예정되어 있습니다"
  • 미사용 서비스 알림 (2개월 이상)
    • 마지막 이용일 트래킹
    • 장기 미사용 감지 시 알림 발송
  • 맞춤형 알림 설정
    • 알림 활성화/비활성화 옵션
    • 알림 타입별 개별 설정 가능

3.4 분석 및 리포트

  • 월별 총 지출 차트
    • 막대 그래프로 월별 지출 추이 시각화
    • 최근 6개월 데이터 표시
  • 서비스별 지출 비율 차트
    • 파이 차트로 구독 서비스별 지출 비중 시각화
    • 차트 항목 터치 시 상세 정보 표시
  • 카테고리별 지출 분석
    • 카테고리별 총 지출 집계
    • 이전 달 대비 증감률 표시

3.5 다국어 및 환율 지원

  • 앱 다국어 지원 (한국어/영어)
    • app_ko.arb, app_en.arb 리소스 파일로 현지화
    • 시스템 언어 기반 자동 언어 설정 및 수동 변경 옵션
  • USD/KRW 환율 자동 변환 기능
    • 실시간 환율 정보 API 호출 (6시간 주기 캐시)
    • 달러 금액 입력 시 원화 환산 금액 동시 표시
  • 통화 단위 선택 옵션
    • 각 구독별 통화 단위(KRW, USD) 설정 가능
    • 통합 보기 시 단일 통화로 합산 표시

3.6 보안 기능

  • 생체 인증을 통한 앱 잠금
    • 지문 인식 또는 얼굴 인식으로 앱 접근 제어
    • 인증 실패 시 재시도 옵션 제공
  • 앱 백그라운드 전환 시 자동 잠금
    • 앱 라이프사이클 상태 감지 (paused → resumed)
    • 설정에 따라 자동 잠금 활성화/비활성화

4. 아키텍처 및 코드 구조

4.1 아키텍처 패턴

  • MVVM 패턴 응용
    • Model: Hive 데이터 모델 (SubscriptionModel, CategoryModel)
    • View: 화면 위젯 (StatelessWidget, StatefulWidget)
    • ViewModel: Provider 클래스 (ChangeNotifier)
  • 서비스 레이어 패턴
    • 비즈니스 로직을 서비스 클래스로 분리
    • 각 서비스는 단일 책임 원칙(SRP)을 따름

4.2 폴더 구조 및 파일 조직

/lib
 ├── main.dart                     # 앱 진입점
 ├── navigator_key.dart            # 전역 네비게이터 키
 ├── screens/                      # 화면 구성 파일
 │    ├── main_screen.dart         # 메인 화면
 │    ├── add_subscription_screen.dart  # 구독 추가 화면
 │    ├── detail_screen.dart       # 구독 상세 화면
 │    ├── analysis_screen.dart     # 분석 화면
 │    ├── settings_screen.dart     # 설정 화면
 │    ├── sms_scan_screen.dart     # SMS 스캔 화면
 │    ├── splash_screen.dart       # 스플래시 화면
 │    ├── app_lock_screen.dart     # 앱 잠금 화면
 │    └── category_management_screen.dart  # 카테고리 관리 화면
 ├── models/                       # 데이터 모델
 │    ├── subscription_model.dart  # 구독 모델
 │    ├── subscription_model.g.dart  # 자동 생성 코드
 │    ├── category_model.dart      # 카테고리 모델
 │    └── category_model.g.dart    # 자동 생성 코드
 ├── providers/                    # 상태 관리
 │    ├── subscription_provider.dart  # 구독 데이터 관리
 │    ├── category_provider.dart   # 카테고리 데이터 관리
 │    ├── app_lock_provider.dart   # 앱 잠금 관리
 │    ├── notification_provider.dart  # 알림 설정 관리
 │    └── locale_provider.dart     # 다국어 관리
 ├── services/                     # 서비스 클래스
 │    ├── notification_service.dart  # 알림 서비스
 │    ├── sms_scanner.dart         # SMS 스캔 로직
 │    ├── sms_service.dart         # SMS 접근 서비스
 │    ├── exchange_rate_service.dart  # 환율 서비스
 │    ├── currency_util.dart       # 통화 유틸리티
 │    └── subscription_url_matcher.dart  # URL 매칭 서비스
 ├── utils/                        # 유틸리티 함수
 │    ├── format_helper.dart       # 포맷팅 유틸리티
 │    ├── animation_controller_helper.dart  # 애니메이션 유틸리티
 │    └── subscription_category_helper.dart  # 카테고리 유틸리티
 ├── widgets/                      # 재사용 위젯
 │    ├── subscription_card.dart   # 구독 카드 위젯
 │    ├── main_summary_card.dart   # 메인 요약 카드
 │    ├── exchange_rate_widget.dart  # 환율 정보 위젯
 │    ├── website_icon.dart        # 웹사이트 아이콘 위젯
 │    ├── category_header_widget.dart  # 카테고리 헤더
 │    ├── subscription_list_widget.dart  # 구독 목록 위젯
 │    ├── animated_wave_background.dart  # 웨이브 배경 위젯
 │    ├── empty_state_widget.dart  # 빈 상태 위젯
 │    └── skeleton_loading.dart    # 로딩 스켈레톤 위젯
 ├── theme/                        # 테마 관련
 │    ├── app_theme.dart           # 앱 테마 정의
 │    └── app_colors.dart          # 색상 정의
 └── l10n/                         # 다국어 리소스
      ├── app_localizations.dart   # 다국어 클래스
      ├── app_en.arb               # 영어 리소스
      └── app_ko.arb               # 한국어 리소스

4.3 데이터 흐름

  1. 사용자 입력화면 위젯Provider 메서드 호출
  2. Provider데이터 처리Hive Box 저장/조회
  3. Provider상태 변경notifyListeners()UI 갱신

4.4 코딩 표준 및 가이드라인

  • 명명 규칙
    • 클래스: PascalCase (예: SubscriptionModel, MainScreen)
    • 변수/메서드: camelCase (예: monthlyCost, addSubscription())
    • 상수: SNAKE_CASE (예: MAX_SUBSCRIPTIONS)
    • 비공개 멤버: 언더스코어 접두사 (예: _subscriptions, _loadData())
  • 코드 구성
    • 각 파일은 한 가지 주요 클래스/위젯만 포함
    • 주석을 통한 코드 문서화 (특히 복잡한 로직)
    • 관련 기능은 같은 디렉토리에 그룹화
  • 코드 스타일
    • Dart 공식 스타일 가이드 준수
    • Lint 규칙 적용 (analysis_options.yaml)
    • 한 줄 최대 80자 제한

5. 데이터 모델

5.1 구독 모델 (SubscriptionModel)

@HiveType(typeId: 0)
class SubscriptionModel extends HiveObject {
  @HiveField(0)
  final String id; // UUID 형식의 고유 식별자

  @HiveField(1)
  String serviceName; // 구독 서비스명

  @HiveField(2)
  double monthlyCost; // 월 비용

  @HiveField(3)
  String billingCycle; // 결제 주기: '월간', '연간', '주간'

  @HiveField(4)
  DateTime nextBillingDate; // 다음 결제 예정일

  @HiveField(5)
  bool isAutoDetected; // SMS 자동 탐지 여부

  @HiveField(6)
  String? categoryId; // 카테고리 ID (외래 키)

  @HiveField(7)
  String? websiteUrl; // 서비스 웹사이트 URL

  @HiveField(8)
  int repeatCount; // 반복 결제 횟수 (SMS 탐지용)

  @HiveField(9)
  DateTime? lastPaymentDate; // 마지막 결제일

  @HiveField(10)
  String currency; // 통화 단위: 'KRW' 또는 'USD'

  // 생성자
  SubscriptionModel({
    required this.id,
    required this.serviceName,
    required this.monthlyCost,
    required this.billingCycle,
    required this.nextBillingDate,
    this.isAutoDetected = false,
    this.categoryId,
    this.websiteUrl,
    this.repeatCount = 1,
    this.lastPaymentDate,
    this.currency = 'KRW', // 기본값은 KRW
  });

  // 주기적 결제 여부 확인
  bool get isRecurring => repeatCount > 1;
}

5.2 카테고리 모델 (CategoryModel)

@HiveType(typeId: 1)
class CategoryModel extends HiveObject {
  @HiveField(0)
  String id; // UUID 형식의 고유 식별자

  @HiveField(1)
  String name; // 카테고리 이름

  @HiveField(2)
  String color; // 색상 (HEX 코드: '#3B82F6')

  @HiveField(3)
  String icon; // 아이콘 이름 (Material Icons)

  // 생성자
  CategoryModel({
    required this.id,
    required this.name,
    required this.color,
    required this.icon,
  });
}

5.3 Provider 모델 구현

5.3.1 구독 Provider (SubscriptionProvider)

class SubscriptionProvider extends ChangeNotifier {
  late Box<SubscriptionModel> _subscriptionBox;
  List<SubscriptionModel> _subscriptions = [];
  bool _isLoading = true;

  List<SubscriptionModel> get subscriptions => _subscriptions;
  bool get isLoading => _isLoading;

  double get totalMonthlyExpense {
    return _subscriptions.fold(
      0.0,
      (sum, subscription) => sum + subscription.monthlyCost,
    );
  }

  Future<void> init() async {
    try {
      _isLoading = true;
      notifyListeners();

      _subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
      await refreshSubscriptions();

      _isLoading = false;
      notifyListeners();
    } catch (e) {
      debugPrint('구독 초기화 중 오류 발생: $e');
      _isLoading = false;
      notifyListeners();
      rethrow;
    }
  }

  Future<void> refreshSubscriptions() async {
    try {
      _subscriptions = _subscriptionBox.values.toList()
        ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
      notifyListeners();
    } catch (e) {
      // 오류 처리
      rethrow;
    }
  }

  Future<void> addSubscription({
    required String serviceName,
    required double monthlyCost,
    required String billingCycle,
    required DateTime nextBillingDate,
    String? websiteUrl,
    String? categoryId,
    bool isAutoDetected = false,
    int repeatCount = 1,
    DateTime? lastPaymentDate,
    String currency = 'KRW',
  }) async {
    // 구현...
  }

  Future<void> updateSubscription(SubscriptionModel subscription) async {
    // 구현...
  }

  Future<void> deleteSubscription(String id) async {
    // 구현...
  }
}

5.3.2 카테고리 Provider (CategoryProvider)

class CategoryProvider extends ChangeNotifier {
  List<CategoryModel> _categories = [];
  late Box<CategoryModel> _categoryBox;

  List<CategoryModel> get categories => _categories;

  Future<void> init() async {
    _categoryBox = await Hive.openBox<CategoryModel>('categories');
    _categories = _categoryBox.values.toList();

    // 카테고리가 비어있으면 기본 카테고리 추가
    if (_categories.isEmpty) {
      await _initDefaultCategories();
    }

    notifyListeners();
  }

  Future<void> _initDefaultCategories() async {
    final defaultCategories = [
      {'name': 'OTT 서비스', 'color': '#3B82F6', 'icon': 'live_tv'},
      {'name': '음악 서비스', 'color': '#EC4899', 'icon': 'music_note'},
      {'name': 'AI 서비스', 'color': '#8B5CF6', 'icon': 'psychology'},
      {'name': '프로그래밍/개발', 'color': '#10B981', 'icon': 'code'},
      {'name': '오피스/협업 툴', 'color': '#F59E0B', 'icon': 'business_center'},
      {'name': '기타 서비스', 'color': '#6B7280', 'icon': 'more_horiz'},
    ];

    // 구현...
  }

  // 기타 메서드...
}

6. UI 디자인 및 레이아웃

6.1 디자인 시스템

6.1.1 색상 팔레트

  • 주 색상(Primary): #3B82F6 (파란색) - 버튼, 강조 텍스트, 앱바 등
    • Tailwind 매핑: blue-500
    • 밝은 변형: #60A5FA (blue-400), 어두운 변형: #2563EB (blue-600)
  • 보조 색상(Secondary): #EC4899 (분홍색) - 음악 카테고리, 부가 기능
    • Tailwind 매핑: pink-500
    • 밝은 변형: #F472B6 (pink-400), 어두운 변형: #DB2777 (pink-600)
  • 배경색(Background): #F8FAFC (밝은 회색) - 앱 배경
    • Tailwind 매핑: slate-50
  • 표면색(Surface): #FFFFFF (흰색) - 카드, 다이얼로그 배경
    • Tailwind 매핑: white
    • 대체 표면색: #F1F5F9 (slate-100)
  • 오류색(Error): #EF4444 (빨간색) - 경고, 삭제 버튼
    • Tailwind 매핑: red-500
  • 성공색(Success): #10B981 (녹색) - 성공 메시지
    • Tailwind 매핑: emerald-500
  • 카테고리 색상:
    • OTT 서비스: #3B82F6 (blue-500)
    • 음악 서비스: #EC4899 (pink-500)
    • AI 서비스: #8B5CF6 (violet-500)
    • 프로그래밍/개발: #10B981 (emerald-500)
    • 오피스/협업 툴: #F59E0B (amber-500)
    • 기타 서비스: #6B7280 (gray-500)
그라데이션 색상
  • 파란색 그라데이션: linear-gradient(135deg, #3B82F6 0%, #60A5FA 100%)
    • Tailwind 구현: bg-gradient-to-tr from-blue-500 to-blue-400
  • 분홍색 그라데이션: linear-gradient(135deg, #EC4899 0%, #F472B6 100%)
    • Tailwind 구현: bg-gradient-to-tr from-pink-500 to-pink-400
  • 보라색 그라데이션: linear-gradient(135deg, #8B5CF6 0%, #A78BFA 100%)
    • Tailwind 구현: bg-gradient-to-tr from-violet-500 to-violet-400
  • 요약 카드 그라데이션: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)
    • Tailwind 구현: bg-gradient-to-tr from-blue-500 to-blue-600

6.1.2 타이포그래피

  • 폰트 패밀리: Roboto (안드로이드), SF Pro (iOS), Noto Sans KR (한글)
    • Tailwind 매핑: font-sans
  • 크기 체계:
    • 타이틀 대: 24sp, 굵게(700) - text-2xl font-bold
    • 타이틀 중: 20sp, 굵게(700) - text-xl font-bold
    • 타이틀 소: 16sp, 굵게(700) - text-base font-bold
    • 본문: 14sp, 일반(400) - text-sm font-normal
    • 부제목: 12sp, 일반(400) - text-xs font-normal
    • 캡션: 10sp, 일반(400) - text-[10px] font-normal
  • 행간:
    • 타이틀: 1.2 - leading-tight
    • 본문: 1.5 - leading-normal
  • 색상:
    • 기본 텍스트: #0F172A (slate-900) - text-slate-900
    • 보조 텍스트: #64748B (slate-500) - text-slate-500
    • 비활성화 텍스트: #94A3B8 (slate-400) - text-slate-400
    • 반전 텍스트(어두운 배경): #FFFFFF (white) - text-white

6.1.3 그림자 및 입체감

  • 카드 그림자: box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06)
    • Tailwind 매핑: shadow-md
  • 버튼 그림자: box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
    • Tailwind 매핑: shadow-sm
  • 팝업 그림자: box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15)
    • Tailwind 매핑: shadow-lg
  • 입체 효과:
    • 약간 올라옴: shadow-sm hover:shadow-md transition-shadow
    • 확실히 올라옴: shadow-md hover:shadow-lg transition-shadow
    • 플로팅 버튼: shadow-lg

6.1.4 모서리 둥글기

  • 카드: BorderRadius.circular(16) - rounded-2xl
  • 버튼: BorderRadius.circular(12) - rounded-xl
  • 입력 필드: BorderRadius.circular(8) - rounded-lg
  • : BorderRadius.circular(24) - rounded-full
  • 팝업/다이얼로그: BorderRadius.circular(24) - rounded-3xl

6.1.5 아이콘 시스템

  • 아이콘 세트: Material Icons
  • 아이콘 크기:
    • 표준: 24dp - w-6 h-6
    • 작게: 18dp - w-4.5 h-4.5
    • 크게: 36dp - w-9 h-9
  • 아이콘 색상: 주로 색상 팔레트의 주 색상과 보조 색상 활용
    • 기본: text-slate-700
    • 강조: text-blue-500
    • 비활성화: text-slate-300

6.1.6 간격 및 여백 체계

  • 기본 여백(Margin): 16dp - m-4
  • 기본 패딩(Padding): 16dp - p-4
  • 섹션 간격: 24dp - gap-6
  • 항목 간격: 8dp - gap-2
  • 내부 패딩:
    • 카드: 16dp - p-4
    • 버튼: 수평 16dp, 수직 12dp - px-4 py-3
    • 입력 필드: 수평 12dp, 수직 12dp - px-3 py-3

6.1.7 애니메이션 및 전환 효과

  • 지속 시간:
    • 빠르게: 150ms - duration-150
    • 표준: 300ms - duration-300
    • 천천히: 500ms - duration-500
  • 이징 함수:
    • 기본: 'ease-in-out' - ease-in-out
    • 진입: 'ease-out' - ease-out
    • 퇴장: 'ease-in' - ease-in
  • 기본 애니메이션:
    • 페이드 인: opacity-0 animate-fadeIn
    • 슬라이드 업: translate-y-4 animate-slideUp
    • 확장: scale-95 animate-scaleUp

6.2 화면별 UI 레이아웃 상세

6.2.1 스플래시 화면

  • 배경: 그라데이션 배경 (시작: #3B82F6, 끝: #60A5FA)
  • 로고: 앱 로고 중앙 배치, 애니메이션 적용
    • 페이드 인 애니메이션 (0.0 → 1.0, 300ms)
    • 스케일 애니메이션 (0.8 → 1.0, 400ms)
  • 앱 이름: 로고 아래 Text 위젯, 흰색(#FFFFFF)
  • 애니메이션 효과:
    • 물결 파동 배경 (AnimatedWaveBackground 위젯)
    • 파티클 효과 (20개의 움직이는 작은 원형 위젯)

6.2.2 메인 화면

  • 앱바:
    • 제목: "SubManager" (좌측 정렬)
    • 설정 버튼 (우측 정렬)
    • 투명도 변화: 스크롤에 따라 0 → 1.0
  • 상단 요약 카드:
    • 파란색 그라데이션 배경
    • 총 구독 수 표시 (큰 숫자)
    • 총 지출 금액 표시 (중간 크기 텍스트)
    • 물결 애니메이션 배경
    • Card 형태, 모서리 둥글게
    • 터치 시 분석 화면으로 이동
  • 카테고리 헤더:
    • 카테고리 이름 (좌측)
    • 구독 수 및 화살표 아이콘 (우측)
  • 구독 카드 리스트:
    • 카테고리별로 그룹화
    • 각 구독은 Card 위젯으로 표현
    • 좌측: 웹사이트 아이콘/이미지
    • 중앙: 서비스명, 다음 결제일
    • 우측: 금액, 통화 단위
    • 스와이프 액션: 편집/삭제
  • 플로팅 액션 버튼:
    • 우측 하단 고정
      • 아이콘
    • 주 색상 배경
    • 누르면 구독 추가 화면으로 이동

6.2.3 구독 추가 화면

  • 앱바:
    • 제목: "구독 추가"
    • 뒤로 가기 버튼
    • SMS 스캔 버튼 (우측)
  • 입력 폼:
    • 각 필드는 Material 디자인의 OutlinedTextField
    • 에러 메시지는 필드 하단에 빨간색 텍스트
    • 필드 포커스 시 하이라이트 효과
  • 서비스명 필드:
    • 자동완성 기능 (인기 서비스 목록)
    • 입력 시 웹사이트 URL 추천
  • 금액 필드:
    • TextInputType.number 키보드
    • 통화 선택 드롭다운 (KRW/USD)
    • USD 선택 시 환율 정보 표시
  • 결제 주기 선택:
    • SegmentedButton 또는 DropdownButton
    • 옵션: 월간, 연간, 주간
  • 결제일 선택:
    • DatePicker 호출 아이콘 버튼
    • 선택된 날짜 표시
  • 카테고리 선택:
    • 카테고리 칩 목록 (가로 스크롤)
    • 각 칩은 카테고리 색상 적용
    • 선택된 카테고리 하이라이트
  • 웹사이트 URL 필드:
    • 선택적 입력 필드
    • URL 형식 검증
  • 저장 버튼:
    • 하단 고정
    • 넓은 버튼 (화면 폭의 80%)
    • 주 색상 배경
    • 저장 중에는 로딩 인디케이터 표시

6.2.4 구독 상세 화면

  • 헤더:
    • 서비스 아이콘/이미지 (큰 크기)
    • 서비스명 (큰 텍스트)
    • 웹사이트 방문 버튼
  • 정보 카드:
    • 각 정보는 카드 섹션으로 구분
    • 편집 모드 전환 버튼 (우측 상단)
  • 금액 정보:
    • 큰 글씨로 금액 표시
    • 통화 단위 표시
    • 환율 정보 (해당하는 경우)
  • 결제 주기 정보:
    • 주기 아이콘 및 텍스트
    • 다음 결제일까지 남은 일수
  • 카테고리 정보:
    • 카테고리 색상 칩
    • 카테고리 이름
  • 편집 모드 UI:
    • 각 필드가 편집 가능한 상태로 전환
    • 저장/취소 버튼 표시
  • 삭제 버튼:
    • 하단 배치
    • 빨간색 텍스트와 아이콘
    • 누르면 확인 다이얼로그 표시

6.2.5 분석 화면

  • 앱바:
    • 제목: "지출 분석"
    • 뒤로 가기 버튼
    • 기간 필터 옵션 (우측)
  • 총 지출 요약 카드:
    • 큰 숫자로 총액 표시
    • 이전 기간 대비 증감률 (화살표 아이콘)
    • 그라데이션 배경
  • 그래프 섹션:
    • 각 그래프는 카드 형태로 표시
    • 그래프 제목 및 설명
    • 인터랙티브 요소 (터치 피드백)
  • 막대 그래프 (월별 지출):
    • fl_chart BarChart 위젯
    • x축: 월 레이블
    • y축: 금액
    • 데이터 막대: 그라데이션 색상
  • 파이 차트 (서비스별 지출):
    • fl_chart PieChart 위젯
    • 각 섹션: 카테고리 색상
    • 터치 시 확대 효과
    • 레이블: 퍼센트 및 금액
  • 카테고리별 지출 목록:
    • 각 카테고리: 가로 막대형 진행 표시줄
    • 카테고리 아이콘, 이름, 금액, 퍼센트
    • 내림차순 정렬 (금액 기준)

6.2.6 SMS 스캔 화면

  • 헤더:
    • 제목: "구독 자동 탐지"
    • 설명 텍스트
  • 스캔 버튼:
    • 큰 원형 버튼
    • 스캔 아이콘
    • 주 색상 배경
  • 스캔 중 UI:
    • 원형 프로그레스 인디케이터
    • "스캔 중..." 텍스트
  • 결과 리스트:
    • 각 발견된 구독은 카드 형태
    • 서비스명, 금액, 반복 횟수 표시
    • 체크박스로 선택 가능
  • 추가 버튼:
    • 하단 고정
    • 선택된 항목 수 표시
    • 비활성화 조건: 선택 항목 없음

6.2.7 설정 화면

  • 앱바:
    • 제목: "설정"
    • 뒤로 가기 버튼
  • 설정 그룹:
    • 섹션 제목
    • 구분선으로 섹션 구분
  • 알림 설정:
    • 알림 마스터 스위치
    • 결제 예정 알림 스위치
    • 미사용 서비스 알림 스위치
  • 보안 설정:
    • 앱 잠금 마스터 스위치
    • 생체 인증 스위치
  • 언어 설정:
    • 라디오 버튼 목록
    • 현재 선택된 언어 표시
  • 데이터 관리:
    • 백업/복원 버튼
    • 데이터 초기화 버튼 (빨간색 경고 아이콘)
  • 앱 정보:
    • 앱 버전, 개발자 정보
    • 오픈소스 라이선스 링크

6.3 모션 디자인 시스템

6.3.1 기본 애니메이션 속성

  • 기본 지속 시간:
    • 컴포넌트 진입/퇴장: 300ms
    • 상태 변화: 200ms
    • 강조 효과: 150ms
    • 페이지 전환: 400ms
  • 이징 함수:
    • 기본: cubic-bezier(0.4, 0, 0.2, 1) - 부드러운 가속 및 감속 (ease-in-out)
    • 진입: cubic-bezier(0.0, 0, 0.2, 1) - 빠른 시작, 점진적 감속 (ease-out)
    • 퇴장: cubic-bezier(0.4, 0, 1, 1) - 점진적 가속, 빠른 종료 (ease-in)
    • 강조: cubic-bezier(0.2, 0, 0, 1) - 반동 효과 (spring)

6.3.2 화면 전환 효과

  • 기본 화면 전환 (일반 화면 간 이동):

    • 방식: 밀어내기 효과 (slide)
    • 새 화면: 오른쪽에서 왼쪽으로 슬라이드 인
    • 기존 화면: 왼쪽으로 밀려나가며 약간 투명해짐
    • 지속 시간: 300ms
    • 코드 구현:
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) => NewScreen(),
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          var begin = Offset(1.0, 0.0);
          var end = Offset.zero;
          var curve = Curves.easeOutCubic;
          var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
          return SlideTransition(position: animation.drive(tween), child: child);
        },
      )
      
  • 모달 화면 전환 (세부 정보, 설정 등):

    • 방식: 페이드 인 + 슬라이드 업
    • 새 화면: 아래에서 위로 슬라이드 인 + 불투명도 증가
    • 백드롭: 반투명 어두움으로 페이드 인
    • 지속 시간: 400ms
    • 코드 구현:
      showModalBottomSheet(
        context: context,
        isScrollControlled: true,
        backgroundColor: Colors.transparent,
        barrierColor: Colors.black54,
        transitionAnimationController: AnimationController(
          vsync: this,
          duration: Duration(milliseconds: 400),
        ),
        builder: (context) => /* 모달 내용 */,
      )
      
  • 팝업 전환 (알림, 확인 다이얼로그):

    • 방식: 스케일 + 페이드 인
    • 새 화면: 중앙에서 스케일 업(0.8 → 1.0) + 불투명도 증가
    • 백드롭: 반투명 어두움으로 페이드 인
    • 지속 시간: 250ms
    • 코드 구현:
      showDialog(
        context: context,
        barrierDismissible: true,
        builder: (BuildContext context) {
          return TweenAnimationBuilder<double>(
            duration: Duration(milliseconds: 250),
            curve: Curves.easeOutCubic,
            tween: Tween(begin: 0.8, end: 1.0),
            builder: (context, value, child) {
              return Transform.scale(
                scale: value,
                child: Opacity(
                  opacity: value,
                  child: AlertDialog(/* 다이얼로그 내용 */),
                ),
              );
            },
          );
        },
      )
      

6.3.3 스크롤 효과 및 동작

  • 목록 스크롤 효과:

    • 오버스크롤: 탄력적 바운스 효과 (iOS 스타일)
    • 스크롤바: 스크롤 중에만 표시되는 반투명 스크롤바
    • 목록 항목 애니메이션: 스크롤 시 항목별 지연된 페이드 인 효과
    • 코드 구현:
      ListView.builder(
        physics: BouncingScrollPhysics(),
        itemCount: items.length,
        itemBuilder: (context, index) {
          return AnimatedBuilder(
            animation: scrollController,
            builder: (context, child) {
              return AnimatedOpacity(
                duration: Duration(milliseconds: 300),
                opacity: index < scrollController.offset / 50 ? 1.0 : 0.0,
                child: child,
              );
            },
            child: ListItem(item: items[index]),
          );
        },
      )
      
  • 패럴랙스 효과 (주로 상세 화면):

    • 배경 이미지: 스크롤 시 더 느리게 이동하여 깊이감 생성
    • 카드 요소: 스크롤 속도에 따라 약간의 회전 및 이동
    • 코드 구현:
      ScrollController _scrollController = ScrollController();
      // ...
      return SingleChildScrollView(
        controller: _scrollController,
        child: Column(
          children: [
            AnimatedBuilder(
              animation: _scrollController,
              builder: (context, child) {
                return Container(
                  height: 200 - _scrollController.offset * 0.5,
                  transform: Matrix4.translationValues(0, _scrollController.offset * 0.3, 0),
                  child: Image.asset('assets/header_bg.png', fit: BoxFit.cover),
                );
              },
            ),
            // 나머지 콘텐츠
          ],
        ),
      )
      
  • 스크롤 감지 기반 UI 변화:

    • 앱바: 스크롤 다운 시 축소, 스크롤 업 시 확장
    • FAB(플로팅 액션 버튼): 스크롤 다운 시 숨김, 스크롤 업 시 표시
    • 코드 구현:
      ScrollController _scrollController = ScrollController();
      bool _isScrollingDown = false;
      bool _isAppBarExpanded = true;
      
      @override
      void initState() {
        super.initState();
        _scrollController.addListener(() {
          if (_scrollController.position.userScrollDirection == ScrollDirection.reverse) {
            if (!_isScrollingDown) {
              setState(() {
                _isScrollingDown = true;
                _isAppBarExpanded = false;
              });
            }
          }
          if (_scrollController.position.userScrollDirection == ScrollDirection.forward) {
            if (_isScrollingDown) {
              setState(() {
                _isScrollingDown = false;
                _isAppBarExpanded = true;
              });
            }
          }
        });
      }
      

6.3.4 마이크로 인터랙션 및 상태 변화

  • 버튼 상태 변화:

    • 누름 효과: 스케일 다운(0.96) + 그림자 감소
    • 호버 효과: 약간의 상승 + 그림자 증가
    • 비활성화: 불투명도 감소(0.7) + 회색조 효과
    • 코드 구현:
      ElevatedButton(
        onPressed: isEnabled ? () => handlePress() : null,
        style: ElevatedButton.styleFrom(
          padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
          elevation: 4,
        ).copyWith(
          foregroundColor: MaterialStateProperty.resolveWith<Color>((states) {
            if (states.contains(MaterialState.disabled)) return Colors.grey;
            return Colors.white;
          }),
          backgroundColor: MaterialStateProperty.resolveWith<Color>((states) {
            if (states.contains(MaterialState.disabled)) return Colors.grey[300]!;
            if (states.contains(MaterialState.pressed)) return Colors.blue[700]!;
            return Colors.blue[500]!;
          }),
          elevation: MaterialStateProperty.resolveWith<double>((states) {
            if (states.contains(MaterialState.disabled)) return 0;
            if (states.contains(MaterialState.pressed)) return 2;
            return 4;
          }),
          overlayColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.1)),
        ),
        child: Text('Button'),
      )
      
  • 필드 포커스 효과:

    • 포커스 진입: 테두리 색상 변경 + 약간의 확장(1.02)
    • 오류 상태: 좌우 흔들림 애니메이션 + 빨간색 테두리
    • 완료 상태: 페이드 인 체크 아이콘 + 녹색 테두리
    • 코드 구현:
      FocusNode _focusNode = FocusNode();
      bool _hasError = false;
      
      @override
      void initState() {
        super.initState();
        _focusNode.addListener(() {
          setState(() {});
        });
      }
      
      // 위젯 내부
      AnimatedContainer(
        duration: Duration(milliseconds: 200),
        curve: Curves.easeInOut,
        transform: _hasError 
            ? (Matrix4.identity()..translate(sin(DateTime.now().millisecondsSinceEpoch / 100) * 5.0, 0.0))
            : Matrix4.identity(),
        decoration: BoxDecoration(
          border: Border.all(
            color: _hasError 
                ? Colors.red 
                : (_focusNode.hasFocus ? Colors.blue : Colors.grey),
            width: _focusNode.hasFocus ? 2.0 : 1.0,
          ),
          borderRadius: BorderRadius.circular(8),
        ),
        child: TextField(
          focusNode: _focusNode,
          decoration: InputDecoration(
            border: InputBorder.none,
            contentPadding: EdgeInsets.all(12),
          ),
        ),
      )
      
  • 체크 박스/토글 효과:

    • 체크 표시: 페이드 인 + 스케일 업(0.5 → 1.0)
    • 토글 슬라이더: 부드러운 좌우 이동 + 배경색 변경
    • 코드 구현:
      AnimatedContainer(
        duration: Duration(milliseconds: 300),
        curve: Curves.easeInOut,
        width: 50,
        height: 30,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(15),
          color: isEnabled ? Colors.blue[500] : Colors.grey[300],
        ),
        child: Stack(
          children: [
            AnimatedPositioned(
              duration: Duration(milliseconds: 300),
              curve: Curves.easeInOut,
              left: isEnabled ? 20 : 0,
              top: 0,
              bottom: 0,
              child: Container(
                width: 30,
                height: 30,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: Colors.white,
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.1),
                      blurRadius: 4,
                      spreadRadius: 1,
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      )
      

6.3.5 특수 효과 및 고급 애니메이션

  • 물결 배경 효과 (메인 카드 및 스플래시 화면):

    • 구성: 여러 층의 곡선 Path를 사용한 파동 효과
    • 애니메이션: sin 함수 기반으로 연속적인 물결 모양 변형
    • 상호작용: 터치 위치에 따라 물결 진폭 변화
    • 코드 구현:
      class WaveBackground extends StatefulWidget {
        @override
        _WaveBackgroundState createState() => _WaveBackgroundState();
      }
      
      class _WaveBackgroundState extends State<WaveBackground> with SingleTickerProviderStateMixin {
        late AnimationController _controller;
      
        @override
        void initState() {
          super.initState();
          _controller = AnimationController(
            vsync: this,
            duration: Duration(seconds: 4),
          )..repeat();
        }
      
        @override
        void dispose() {
          _controller.dispose();
          super.dispose();
        }
      
        @override
        Widget build(BuildContext context) {
          return AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              return CustomPaint(
                painter: WavePainter(
                  animation: _controller,
                  waveColor: Colors.blue.withOpacity(0.3),
                ),
                child: Container(),
              );
            },
          );
        }
      }
      
      class WavePainter extends CustomPainter {
        final Animation<double> animation;
        final Color waveColor;
      
        WavePainter({required this.animation, required this.waveColor});
      
        @override
        void paint(Canvas canvas, Size size) {
          // 물결 그리기 로직...
        }
      
        @override
        bool shouldRepaint(WavePainter oldDelegate) => true;
      }
      
  • 파티클 효과 (달성, 저장 성공 시):

    • 구성: 랜덤한 크기, 색상, 방향으로 분산되는 작은 원형 파티클
    • 애니메이션: 중심점에서 밖으로 퍼져나가는 궤적 + 페이드 아웃
    • 코드 구현:
      class ParticleEffect extends StatefulWidget {
        @override
        _ParticleEffectState createState() => _ParticleEffectState();
      }
      
      class _ParticleEffectState extends State<ParticleEffect> with SingleTickerProviderStateMixin {
        late AnimationController _controller;
        late List<Particle> particles;
      
        @override
        void initState() {
          super.initState();
          _controller = AnimationController(
            vsync: this,
            duration: Duration(milliseconds: 1000),
          )..forward();
      
          particles = List.generate(30, (index) => Particle());
        }
      
        @override
        Widget build(BuildContext context) {
          return AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              return CustomPaint(
                painter: ParticlePainter(
                  animation: _controller,
                  particles: particles,
                ),
                child: Container(),
              );
            },
          );
        }
      }
      
      class Particle {
        late double speed;
        late double theta;
        late double radius;
        late Color color;
      
        Particle() {
          // 랜덤 초기화 로직...
        }
      }
      
      class ParticlePainter extends CustomPainter {
        // 구현...
      }
      
  • 카드 스택 효과 (메인 화면 구독 카드):

    • 구성: 스택에 쌓인 카드들이 3D 회전 및 깊이감 표현
    • 애니메이션: 카드 선택 시 카드가 앞으로 나오며 확대
    • 스와이프: 스와이프로 카드 스택 탐색
    • 코드 구현:
      class CardStack extends StatefulWidget {
        final List<Widget> cards;
        CardStack({required this.cards});
      
        @override
        _CardStackState createState() => _CardStackState();
      }
      
      class _CardStackState extends State<CardStack> {
        int _selectedIndex = 0;
      
        @override
        Widget build(BuildContext context) {
          return GestureDetector(
            onHorizontalDragEnd: (details) {
              if (details.primaryVelocity! > 0) {
                // 오른쪽 스와이프
                setState(() {
                  _selectedIndex = math.max(0, _selectedIndex - 1);
                });
              } else if (details.primaryVelocity! < 0) {
                // 왼쪽 스와이프
                setState(() {
                  _selectedIndex = math.min(widget.cards.length - 1, _selectedIndex + 1);
                });
              }
            },
            child: Stack(
              alignment: Alignment.center,
              children: List.generate(widget.cards.length, (index) {
                // 각 카드의 위치 및 크기 계산
                final offset = index - _selectedIndex;
                final isSelected = offset == 0;
      
                return AnimatedPositioned(
                  duration: Duration(milliseconds: 300),
                  curve: Curves.easeOutCubic,
                  top: isSelected ? 0 : 10 * offset.abs(),
                  right: isSelected ? 0 : offset < 0 ? -20 * offset.abs() : 0,
                  left: isSelected ? 0 : offset > 0 ? -20 * offset.abs() : 0,
                  child: AnimatedOpacity(
                    duration: Duration(milliseconds: 300),
                    opacity: offset.abs() < 3 ? 1.0 - (0.2 * offset.abs()) : 0,
                    child: AnimatedScale(
                      duration: Duration(milliseconds: 300),
                      scale: 1.0 - (0.05 * offset.abs()),
                      child: widget.cards[index],
                    ),
                  ),
                );
              }),
            ),
          );
        }
      }
      
  • 로딩 및 진행 애니메이션:

    • 스켈레톤 로딩: 펄스 애니메이션이 있는 그레이스케일 레이아웃
    • 원형 진행: 원형 프로그레스 인디케이터 + 숫자 증가 애니메이션
    • 성공 체크: 그리기 애니메이션으로 구현된 체크 마크
    • 코드 구현:
      // 스켈레톤 로딩
      class SkeletonLoading extends StatefulWidget {
        final Widget child;
      
        const SkeletonLoading({Key? key, required this.child}) : super(key: key);
      
        @override
        _SkeletonLoadingState createState() => _SkeletonLoadingState();
      }
      
      class _SkeletonLoadingState extends State<SkeletonLoading> with SingleTickerProviderStateMixin {
        late AnimationController _controller;
        late Animation<double> _animation;
      
        @override
        void initState() {
          super.initState();
          _controller = AnimationController(
            vsync: this,
            duration: Duration(milliseconds: 1500),
          )..repeat(reverse: true);
      
          _animation = Tween<double>(begin: 0.4, end: 0.9).animate(_controller);
        }
      
        @override
        Widget build(BuildContext context) {
          return AnimatedBuilder(
            animation: _animation,
            builder: (context, child) {
              return ShaderMask(
                shaderCallback: (rect) {
                  return LinearGradient(
                    colors: [
                      Colors.grey[300]!,
                      Colors.grey[100]!,
                      Colors.grey[300]!,
                    ],
                    stops: [
                      0.0,
                      _animation.value,
                      1.0,
                    ],
                  ).createShader(rect);
                },
                child: Container(
                  color: Colors.white,
                  child: widget.child,
                ),
              );
            },
          );
        }
      }
      

7. 핵심 알고리즘 및 기능 구현

7.1 SMS 기반 구독 탐지 알고리즘

// sms_scanner.dart 내부 구현 예시
const platform = MethodChannel('com.submanager/sms');
try {
  smsList = await platform.invokeMethod('scanSubscriptions');
  print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
} catch (e) {
  print('SmsScanner: 네이티브 호출 실패: $e');
  // 오류 발생 시 빈 목록 반환
  return [];
}

7.2 구독 서비스 자동 카테고리 분류 알고리즘

// subscription_category_helper.dart 내부 구현
void autoDetectCategory(String serviceName) {
  // OTT 서비스 확인
  if (SubscriptionUrlMatcher.ottServices.keys.any((key) =>
      serviceName.toLowerCase().contains(key.toLowerCase()) ||
      key.toLowerCase().contains(serviceName.toLowerCase()))) {
    try {
      final ottCategory = categoryProvider.categories.firstWhere(
        (cat) => cat.name.contains('OTT') || cat.name.contains('스트리밍'),
      );
      _selectedCategoryId = ottCategory.id;
      return;
    } catch (_) {
      // 카테고리를 찾지 못한 경우 기본 처리
    }
  }

  // 음악 서비스 확인
  if (SubscriptionUrlMatcher.musicServices.keys.any((key) =>
      serviceName.toLowerCase().contains(key.toLowerCase()) ||
      key.toLowerCase().contains(serviceName.toLowerCase()))) {
    try {
      final musicCategory = categoryProvider.categories.firstWhere(
        (cat) => cat.name.contains('음악') || cat.name.contains('스트리밍'),
      );
      _selectedCategoryId = musicCategory.id;
      return;
    } catch (_) {
      // 카테고리를 찾지 못한 경우 기본 처리
    }
  }

  // AI 서비스, 프로그래밍/개발, 오피스/협업 툴 등 확인 로직 생략...

  // 기타 서비스 처리
  try {
    final otherCategory = categoryProvider.categories.firstWhere(
      (cat) => cat.name.contains('기타'),
    );
    _selectedCategoryId = otherCategory.id;
  } catch (_) {
    // 기타 카테고리도 없는 경우 첫 번째 카테고리 선택
    if (categoryProvider.categories.isNotEmpty) {
      _selectedCategoryId = categoryProvider.categories.first.id;
    }
  }
}

7.3 환율 변환 및 캐싱 알고리즘

// exchange_rate_service.dart 내부 구현
class ExchangeRateService {
  // 싱글톤 인스턴스
  static final ExchangeRateService _instance = ExchangeRateService._internal();
  factory ExchangeRateService() => _instance;
  ExchangeRateService._internal();

  // 캐싱된 환율 정보
  double? _usdToKrwRate;
  DateTime? _lastUpdated;
  final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';

  Future<double?> getUsdToKrwRate() async {
    // 캐싱된 데이터 있고 6시간 이내면 캐싱된 데이터 반환
    if (_usdToKrwRate != null && _lastUpdated != null) {
      final difference = DateTime.now().difference(_lastUpdated!);
      if (difference.inHours < 6) {
        return _usdToKrwRate;
      }
    }

    try {
      // API 요청
      final response = await http.get(Uri.parse(_apiUrl));

      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        _usdToKrwRate = data['rates']['KRW'].toDouble();
        _lastUpdated = DateTime.now();
        return _usdToKrwRate;
      } else {
        // 실패 시 캐싱된 값이라도 반환
        return _usdToKrwRate;
      }
    } catch (e) {
      // 오류 발생 시 캐싱된 값이라도 반환
      return _usdToKrwRate;
    }
  }

  // USD 금액을 KRW로 변환
  Future<double?> convertUsdToKrw(double usdAmount) async {
    final rate = await getUsdToKrwRate();
    if (rate != null) {
      return usdAmount * rate;
    }
    return null;
  }
}

7.4 결제일 알림 스케줄링 알고리즘

// notification_service.dart 내부 구현
static Future<void> schedulePaymentReminder({
  required int id,
  required String serviceName,
  required double amount,
  required DateTime billingDate,
}) async {
  // 결제 3일 전 오전 10시에 알림 설정
  final scheduledDate = billingDate
      .subtract(const Duration(days: 3))
      .copyWith(hour: 10, minute: 0, second: 0, millisecond: 0, microsecond: 0);

  await _notifications.zonedSchedule(
    id,
    '구독 결제 예정 알림',
    '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 3일 후 결제 예정입니다.',
    tz.TZDateTime.from(scheduledDate, tz.local),
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'subscription_channel',
        'Subscription Notifications',
        channelDescription: 'Channel for subscription reminders',
        importance: Importance.high,
        priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
  );
}

7.5 구독료 통계 계산 알고리즘

Future<void> _calculateTotalExpense() async {
  setState(() => _isLoading = true);

  try {
    final provider = Provider.of<SubscriptionProvider>(context, listen: false);
    final subscriptions = provider.subscriptions;

    if (subscriptions.isEmpty) {
      setState(() {
        _totalExpense = 0.0;
        _isLoading = false;
      });
      return;
    }

    // 모든 구독의 월 비용을 원화로 환산하여 계산
    final total = await CurrencyUtil.calculateTotalMonthlyExpense(subscriptions);

    setState(() {
      _totalExpense = total;
      _isLoading = false;
    });
  } catch (e) {
    debugPrint('총 지출 계산 오류: $e');
    setState(() => _isLoading = false);
  }
}

7.6 서비스 데이터베이스 구현

구독 서비스의 URL 및 해지 페이지를 관리하기 위한 데이터베이스를 구현합니다. 사용자에게 서비스 관련 정보를 쉽게 제공하기 위해 다음과 같은 데이터 구조를 사용합니다:

// 서비스 URL 매핑
static final Map<String, String> serviceUrls = {
  'netflix': 'https://www.netflix.com',
  '넷플릭스': 'https://www.netflix.com',
  // ...많은 서비스 매핑...
};

// 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들)
static final Map<String, String> cancellationUrls = {
  'netflix': 'https://help.netflix.com/ko/node/407',
  '넷플릭스': 'https://help.netflix.com/ko/node/407',
  // ...많은 서비스 해지 페이지 매핑...
};

이 데이터는 SubscriptionUrlMatcher 서비스 클래스에서 관리되며, 서비스명으로 URL을 검색하거나 해지 페이지를 안내하는 기능을 제공합니다.

8. 테스트 및 품질 보증 계획

8.1 테스트 전략

8.1.1 단위 테스트

  • 대상: Provider 클래스, 서비스 클래스, 유틸리티 함수
  • 도구: Flutter Test 프레임워크
  • 테스트 사례 예시:
    void main() {
      late SubscriptionProvider provider;
      late MockHiveBox mockBox;
    
      setUp(() {
        mockBox = MockHiveBox();
        provider = SubscriptionProvider();
        // 의존성 주입
      });
    
      test('새 구독 추가 시 목록에 추가되어야 함', () async {
        // Given
        final subscription = SubscriptionModel(
          id: 'test-id',
          serviceName: 'Test Service',
          monthlyCost: 9900,
          billingCycle: '월간',
          nextBillingDate: DateTime.now().add(Duration(days: 30)),
        );
    
        // When
        await provider.addSubscription(
          serviceName: subscription.serviceName,
          monthlyCost: subscription.monthlyCost,
          billingCycle: subscription.billingCycle,
          nextBillingDate: subscription.nextBillingDate,
        );
    
        // Then
        expect(provider.subscriptions.length, 1);
        expect(provider.subscriptions.first.serviceName, 'Test Service');
      });
    
      test('총 지출 계산이 정확해야 함', () {
        // Given
        final subscription1 = SubscriptionModel(/*...*/);
        final subscription2 = SubscriptionModel(/*...*/);
        provider.subscriptions = [subscription1, subscription2];
    
        // When
        final total = provider.totalMonthlyExpense;
    
        // Then
        expect(total, subscription1.monthlyCost + subscription2.monthlyCost);
      });
    }
    

8.1.2 통합 테스트

  • 대상: 화면 간 이동, 데이터 흐름, 상태 관리
  • 도구: Flutter Integration Test
  • 테스트 사례 예시:
    void main() {
      IntegrationTestWidgetsFlutterBinding.ensureInitialized();
    
      testWidgets('구독 추가 후 메인 화면에 표시되는지 확인', (tester) async {
        // 앱 시작
        app.main();
        await tester.pumpAndSettle();
    
        // 구독 추가 화면으로 이동
        await tester.tap(find.byIcon(Icons.add));
        await tester.pumpAndSettle();
    
        // 구독 정보 입력
        await tester.enterText(
          find.byKey(Key('service_name_field')), 
          'Test Service'
        );
        await tester.enterText(
          find.byKey(Key('monthly_cost_field')), 
          '9900'
        );
        // 나머지 필드 입력...
    
        // 저장 버튼 클릭
        await tester.tap(find.text('저장하기'));
        await tester.pumpAndSettle();
    
        // 메인 화면에 추가된 구독이 표시되는지 확인
        expect(find.text('Test Service'), findsOneWidget);
        expect(find.text('₩9,900'), findsOneWidget);
      });
    }
    

8.1.3 UI/UX 테스트

  • 대상: 화면 레이아웃, 애니메이션, 반응성
  • 방법: 다양한 화면 크기 및 기기에서 수동 테스트
  • 체크리스트:
    • 모든 화면이 다양한 기기 크기에서 적절히 렌더링됨
    • 애니메이션이 부드럽게 실행됨
    • 터치 영역이 충분히 큼
    • 색상 대비가 접근성 기준 충족
    • 키보드 네비게이션 가능

8.2 성능 테스트

8.2.1 메모리 사용량

  • 목표: 앱 사용 중 메모리 누수 없음
  • 측정 방법: DevTools Memory 프로파일러
  • 허용 기준: 장시간 사용 후에도 비정상적 메모리 증가 없음

8.2.2 응답성

  • 목표: 사용자 인터랙션에 빠르게 반응
  • 측정 방법: Flutter Performance 오버레이
  • 허용 기준:
    • UI 스레드 지연 < 16ms (60fps 유지)
    • 화면 전환 < 300ms
    • 데이터 로딩 시 적절한 로딩 인디케이터 표시

8.2.3 배터리 사용량

  • 목표: 최소한의 배터리 소모
  • 측정 방법: OS 배터리 사용량 통계
  • 허용 기준: 백그라운드에서 최소한의 배터리 사용

8.3 호환성 테스트

8.3.1 OS 버전 호환성

  • Android: API 레벨 21 (Android 5.0) 이상
  • iOS: iOS 12.0 이상
  • 테스트 기기 목록:
    • Android: Pixel 3, Samsung Galaxy S10, Galaxy Tab S7
    • iOS: iPhone 8, iPhone 12, iPad 6세대

8.3.2 화면 크기 호환성

  • 작은 화면: 4-5인치 스마트폰
  • 중간 화면: 5.5-6.5인치 스마트폰
  • 큰 화면: 태블릿 (7-10인치)

8.4 보안 테스트

8.4.1 데이터 저장 보안

  • 테스트 항목: 민감 데이터가 암호화되어 저장되는지 확인
  • 방법: 기기 파일 시스템 검사, 앱 데이터 추출 시도

8.4.2 권한 관리

  • 테스트 항목: 필요한 최소한의 권한만 요청하는지 확인
  • 방법: 앱 매니페스트 검사, 런타임 권한 요청 검증

9. 구현 가이드라인

9.1 코딩 표준

9.1.1 명명 규칙

  • 클래스명: PascalCase (예: SubscriptionModel, MainScreen)
  • 변수/메서드명: camelCase (예: monthlyCost, addSubscription())
  • 상수명: SNAKE_CASE (예: MAX_SUBSCRIPTIONS)
  • 비공개 멤버: 언더스코어 접두사 (예: _subscriptions, _loadData())
  • 파일명: snake_case (예: subscription_model.dart, main_screen.dart)

9.1.2 코드 구성

  • 클래스 멤버 순서:
    1. 클래스 변수 (static)
    2. 인스턴스 변수
    3. 생성자
    4. getter/setter
    5. 메서드 (private 메서드는 호출되는 public 메서드 직후)
  • 주석 형식:
    • 클래스: 기능 및 역할 설명
    • 메서드: 파라미터, 반환값, 예외 설명
    • 복잡한 로직: 알고리즘 설명
  • import 구성:
    1. Dart 기본 라이브러리
    2. Flutter 라이브러리
    3. 외부 패키지
    4. 프로젝트 내 패키지 (상대 경로)

9.1.3 SOLID 원칙 적용

  • 단일 책임 원칙(SRP): 각 클래스는 하나의 책임만 가짐
    • SubscriptionProvider는 구독 데이터 CRUD 작업 처리만 담당
    • NotificationService는 알림 관리만 담당
  • 개방-폐쇄 원칙(OCP): 확장에 열려있고 수정에 닫혀있음
    • 새 카테고리 추가 시 기존 코드 수정 없이 가능
  • 리스코프 치환 원칙(LSP): 하위 클래스는 상위 클래스를 대체 가능
  • 인터페이스 분리 원칙(ISP): 하나의 일반 인터페이스보다 특정 인터페이스 선호
  • 의존성 역전 원칙(DIP): 추상화에 의존, 구체화에 의존하지 않음

9.1.4 성능 최적화

  • 상태 관리:
    • setState() 대신 ProvidernotifyListeners() 사용
    • 필요한 부분만 갱신되도록 Consumer 위젯 적절히 배치
  • 메모리 관리:
    • 큰 이미지는 캐싱 및 메모리 최적화
    • 리스트는 ListView.builder로 화면에 표시된 항목만 빌드
  • 애니메이션:
    • 복잡한 애니메이션은 AnimationController 활용
    • dispose() 메서드에서 컨트롤러 해제

9.2 프로젝트 디렉토리 구조 가이드

/lib
 ├── main.dart                     # 앱 진입점
 ├── navigator_key.dart            # 전역 네비게이터 키
 ├── screens/                      # 화면 구성 파일
 │    ├── main_screen.dart         # 메인 화면
 │    ├── add_subscription_screen.dart  # 구독 추가 화면
 │    ├── detail_screen.dart       # 구독 상세 화면
 │    ├── analysis_screen.dart     # 분석 화면
 │    ├── settings_screen.dart     # 설정 화면
 │    ├── sms_scan_screen.dart     # SMS 스캔 화면
 │    ├── splash_screen.dart       # 스플래시 화면
 │    ├── app_lock_screen.dart     # 앱 잠금 화면
 │    └── category_management_screen.dart  # 카테고리 관리 화면
 ├── models/                       # 데이터 모델
 │    ├── subscription_model.dart  # 구독 모델
 │    ├── subscription_model.g.dart  # 자동 생성 코드
 │    ├── category_model.dart      # 카테고리 모델
 │    └── category_model.g.dart    # 자동 생성 코드
 ├── providers/                    # 상태 관리
 │    ├── subscription_provider.dart  # 구독 데이터 관리
 │    ├── category_provider.dart   # 카테고리 데이터 관리
 │    ├── app_lock_provider.dart   # 앱 잠금 관리
 │    ├── notification_provider.dart  # 알림 설정 관리
 │    └── locale_provider.dart     # 다국어 관리
 ├── services/                     # 서비스 클래스
 │    ├── notification_service.dart  # 알림 서비스
 │    ├── sms_scanner.dart         # SMS 스캔 로직
 │    ├── sms_service.dart         # SMS 접근 서비스
 │    ├── exchange_rate_service.dart  # 환율 서비스
 │    ├── currency_util.dart       # 통화 유틸리티
 │    └── subscription_url_matcher.dart  # URL 매칭 서비스
 ├── utils/                        # 유틸리티 함수
 │    ├── format_helper.dart       # 포맷팅 유틸리티
 │    ├── animation_controller_helper.dart  # 애니메이션 유틸리티
 │    └── subscription_category_helper.dart  # 카테고리 유틸리티
 ├── widgets/                      # 재사용 위젯
 │    ├── subscription_card.dart   # 구독 카드 위젯
 │    ├── main_summary_card.dart   # 메인 요약 카드
 │    ├── exchange_rate_widget.dart  # 환율 정보 위젯
 │    ├── website_icon.dart        # 웹사이트 아이콘 위젯
 │    ├── category_header_widget.dart  # 카테고리 헤더
 │    ├── subscription_list_widget.dart  # 구독 목록 위젯
 │    ├── animated_wave_background.dart  # 웨이브 배경 위젯
 │    ├── empty_state_widget.dart  # 빈 상태 위젯
 │    └── skeleton_loading.dart    # 로딩 스켈레톤 위젯
 ├── theme/                        # 테마 관련
 │    ├── app_theme.dart           # 앱 테마 정의
 │    └── app_colors.dart          # 색상 정의
 └── l10n/                         # 다국어 리소스
      ├── app_localizations.dart   # 다국어 클래스
      ├── app_en.arb               # 영어 리소스
      └── app_ko.arb               # 한국어 리소스

9.3 플랫폼별 구현 가이드

9.3.1 Android 구현 가이드

  • targetSdkVersion: 33 (Android 13)
  • minSdkVersion: 21 (Android 5.0)
  • 권한 요청:
    <!-- AndroidManifest.xml -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_SMS" />
    <uses-permission android:name="android.permission.RECEIVE_SMS" />
    <uses-permission android:name="android.permission.USE_BIOMETRIC" />
    <uses-permission android:name="android.permission.VIBRATE" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
    
  • 알림 채널 설정:
    • 구독 알림: "subscription_channel"
    • 결제 알림: "payment_channel"
  • 네이티브 코드:
    • SMS 스캐너 (코틀린): MainActivity.kt에 MethodChannel 구현

9.3.2 iOS 구현 가이드

  • iOS 최소 버전: 12.0
  • 권한 요청:
    <!-- Info.plist -->
    <key>NSFaceIDUsageDescription</key>
    <string>앱 잠금 해제를 위해 Face ID 사용</string>
    <key>NSUserNotificationsUsageDescription</key>
    <string>구독 결제 예정 알림을 위해 사용</string>
    
  • 알림 설정:
    • 권한 요청 시 UNUserNotificationCenter 사용
    • PushKit은 사용하지 않음 (로컬 알림만 사용)
  • 앱 백그라운드 모드:
    • "Background Fetch" 활성화

9.4 배포 준비 체크리스트

9.4.1 앱 매니페스트 파일

  • 앱 이름 설정
  • 앱 아이콘 설정
  • 버전 코드 및 버전명 설정
  • 필요한 권한만 요청
  • 심사 대비 사유 설명 준비 (SMS 접근 등)

9.4.2 앱 서명 및 인증서

  • Android: Keystore 파일 생성 및 보관
  • iOS: 개발자 계정 및 인증서 설정

9.4.3 스토어 등록 준비

  • 앱 설명 작성
  • 스크린샷 준비 (다양한 기기)
  • 개인정보 처리방침 준비
  • 마케팅 이미지 준비

10. 결론 및 요약

10.1 주요 기능 요약

SubManager 앱은 사용자가 구독 서비스를 쉽게 관리하고 지출을 최적화할 수 있는 도구입니다. 주요 기능은 다음과 같습니다:

  1. 구독 관리: 구독 서비스의 추가, 수정, 삭제 및 카테고리별 분류
  2. SMS 자동 탐지: 문자 메시지를 분석하여 구독 서비스 자동 인식
  3. 결제일 알림: 결제 예정일 3일 전 알림으로 예상치 못한 결제 방지
  4. 분석 및 리포트: 월별 지출, 서비스별 비율 등 시각화된 분석 제공
  5. 환율 지원: 원화/달러 간 자동 변환으로 해외 서비스 관리 용이
  6. 보안 기능: 생체 인증을 통한 앱 잠금

10.2 개발 철학

SubManager 앱 개발의 핵심 철학은 다음과 같습니다:

  1. 사용자 중심: 사용자의 구독 관리 경험을 최우선으로 고려
  2. 오프라인 우선: 서버 없이 로컬에서 모든 데이터 처리
  3. 단순함: 복잡한 설정 없이 직관적인 UI/UX 제공
  4. 개인정보 보호: 민감한 금융 정보를 안전하게 보호

10.3 개발 로드맵

SubManager 앱의 개발 로드맵은 다음과 같습니다:

  1. 1단계 (1개월):

    • 기본 구독 관리 기능 구현
    • UI/UX 기본 프레임워크 구축
    • Hive DB 초기 설정
  2. 2단계 (1개월):

    • SMS 스캔 기능 추가
    • 알림 시스템 구현
    • 분석 및 리포트 화면 개발
  3. 3단계 (2주):

    • 다국어 지원 추가
    • 환율 변환 기능 구현
    • 성능 최적화 및 버그 수정
  4. 4단계 (2주):

    • 베타 테스트 진행
    • 사용자 피드백 반영
    • 출시 준비 및 스토어 등록

10.4 최종 산출물

이 PRD를 통해 개발될 최종 산출물은 다음과 같습니다:

  1. Flutter 앱: Android 및 iOS 플랫폼 지원
  2. 소스 코드: 체계적으로 구조화된 확장 가능한 코드베이스
  3. 사용자 문서: 앱 사용 가이드 및 FAQ
  4. 개발자 문서: API 문서 및 아키텍처 설명

이 향상된 PRD 문서는 SubManager 앱을 성공적으로 개발하기 위한 포괄적인 청사진을 제공합니다. 개발자는 이 문서를 통해 앱의 모든 측면을 이해하고, 품질 높은 애플리케이션을 구현할 수 있을 것입니다.

11. 아키텍처 및 유지보수 권장사항

품질 높은 코드베이스를 유지하고 지속적인 개발을 용이하게 하기 위해 다음과 같은 아키텍처 및 코드 구조화 방식을 권장합니다.

11.1 Clean Architecture 도입

데이터, 도메인, 프레젠테이션 계층을 명확하게 분리하여 코드의 의존성 방향을 제어하고 테스트 용이성을 높입니다.

현재 9.2절에 설명된 프로젝트 구조에서 발전하여, 더 체계적으로 관심사를 분리하는 구조로 점진적으로 마이그레이션할 수 있습니다. 기존 구성요소는 다음과 같이 매핑됩니다:

  • models/data/models/
  • services/data/datasources/data/repositories/
  • providers/presentation/providers/
  • screens/presentation/screens/
  • widgets/presentation/widgets/
/lib
 ├── data/                              # 데이터 계층
 │    ├── datasources/                  # 데이터 소스 (Hive, API 등)
 │    ├── models/                       # 데이터 모델 (JSON 직렬화 포함)
 │    └── repositories/                 # 리포지토리 구현체
 ├── domain/                            # 도메인 계층
 │    ├── entities/                     # 비즈니스 엔티티
 │    ├── repositories/                 # 리포지토리 인터페이스
 │    └── usecases/                     # 비즈니스 유스케이스
 └── presentation/                      # 표현 계층
      ├── providers/                    # 상태 관리
      ├── screens/                      # 화면 위젯
      └── widgets/                      # 재사용 위젯

장점:

  • 비즈니스 로직이 UI나 데이터 소스 구현에 의존하지 않음
  • 각 계층별 단위 테스트 용이
  • 확장성 및 유지보수성 향상

11.2 단일 책임 원칙 강화

현재 여러 책임을 가진 클래스들을 더 작고 집중된 컴포넌트로 분리합니다.

기존 코드:

// 데이터 관리와 알림 관리가 혼합된 Provider
class SubscriptionProvider extends ChangeNotifier {
  // 구독 CRUD 로직
  Future<void> addSubscription(...) async { ... }
  
  // 알림 관리 로직
  Future<void> _scheduleNotifications() async { ... }
}

개선된 코드:

// 데이터 관리만 담당
class SubscriptionRepository {
  Future<void> addSubscription(...) async { ... }
}

// 알림 관리만 담당
class SubscriptionNotificationManager {
  Future<void> scheduleNotification(...) async { ... }
}

// 상태 관리 및 UI 연결만 담당
class SubscriptionProvider extends ChangeNotifier {
  final SubscriptionRepository _repository;
  final SubscriptionNotificationManager _notificationManager;
  
  SubscriptionProvider(this._repository, this._notificationManager);
  // ...
}

장점:

  • 코드 가독성 및 유지보수성 향상
  • 단위 테스트 용이성 증가
  • 컴포넌트 재사용 가능성 향상

11.3 재사용 가능한 UI 컴포넌트

애니메이션, 상태 관리, 화면 전환 등의 공통 기능을 재사용 가능한 위젯으로 추출합니다.

// 애니메이션된 카드 위젯
class AnimatedCard extends StatelessWidget {
  final Widget child;
  final Duration duration;
  final bool animate;
  
  const AnimatedCard({
    Key? key,
    required this.child,
    this.duration = const Duration(milliseconds: 300),
    this.animate = true,
  }) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return TweenAnimationBuilder<double>(
      tween: Tween(begin: animate ? 0.0 : 1.0, end: 1.0),
      duration: duration,
      curve: Curves.easeOut,
      builder: (context, value, child) {
        return Opacity(
          opacity: value,
          child: Transform.translate(
            offset: Offset(0, 20 * (1 - value)),
            child: child,
          ),
        );
      },
      child: child,
    );
  }
}

장점:

  • 코드 중복 감소
  • UI 일관성 향상
  • 유지보수 용이성 증가

11.4 의존성 주입 패턴 적용

컴포넌트 간 의존성을 명시적으로 관리하여 테스트 용이성을 향상시킵니다.

// 의존성 주입 컨테이너 설정
final GetIt sl = GetIt.instance;

void setupDependencies() {
  // 서비스 등록
  sl.registerLazySingleton<ExchangeRateService>(() => ExchangeRateServiceImpl());
  sl.registerLazySingleton<NotificationService>(() => NotificationServiceImpl());
  
  // 리포지토리 등록
  sl.registerLazySingleton<SubscriptionRepository>(
    () => SubscriptionRepositoryImpl(sl<HiveDatabase>())
  );
  
  // 상태 관리 Provider 등록
  sl.registerFactory<SubscriptionProvider>(
    () => SubscriptionProvider(
      sl<SubscriptionRepository>(),
      sl<NotificationService>(),
    )
  );
}

장점:

  • 명확한 의존성 관계
  • 테스트 시 의존성 모킹 용이
  • 컴포넌트 교체 용이성

11.5 데이터와 비즈니스 로직 분리

하드코딩된 데이터와 이를 처리하는 로직을 분리합니다.

기존 코드:

class SubscriptionUrlMatcher {
  static final Map<String, String> ottServices = {
    'netflix': 'https://www.netflix.com',
    '넷플릭스': 'https://www.netflix.com',
    // ... 더 많은 서비스 매핑
  };
  
  static String? findMatchingUrl(String serviceName) {
    // 매칭 로직
  }
}

개선된 코드:

// 데이터만 포함
class ServiceData {
  static const Map<String, String> ottServices = { ... };
  static const Map<String, String> musicServices = { ... };
  // ...
}

// 로직만 처리
class ServiceMatcher {
  final Map<String, Map<String, String>> allServices;
  
  ServiceMatcher(this.allServices);
  
  String? findMatchingUrl(String serviceName) {
    // 매칭 로직
  }
}

장점:

  • 데이터 업데이트 용이성
  • 비즈니스 로직 테스트 용이성
  • 관심사 분리로 인한 유지보수성 향상

이러한 아키텍처 및 설계 원칙을 적용함으로써, SubManager 앱은 확장 가능하고 유지보수하기 쉬운 코드베이스를 갖추게 되어 장기적인 개발과 기능 추가가 용이해질 것입니다.

11.6 구현 이행 전략

기존 코드베이스에서 제안된 아키텍처로 마이그레이션하기 위한 단계적 접근 방식은 다음과 같습니다:

  1. 점진적 리팩토링:

    • 모든 코드를 한 번에 변경하지 않고, 기능별로 점진적으로 리팩토링
    • 특히 CRUD 작업과 비즈니스 로직이 혼합된 Provider 클래스부터 분리 시작
  2. 어댑터 패턴 활용:

    • 리포지토리 인터페이스를 도입하고 기존 Provider에서 이를 구현하는 방식으로 전환
    • 점진적으로 데이터 액세스 로직을 분리하여 리포지토리 구현체로 이동
  3. 테스트 우선 접근방식:

    • 변경 전후의 동일한 동작을 보장하기 위해 단위 테스트 및 통합 테스트 작성
    • 리팩토링 후 위젯 테스트로 UI 일관성 검증
  4. 새 기능에 새 구조 적용:

    • 신규 기능은 처음부터 제안된 아키텍처로 개발
    • 기존 코드는 점진적으로 새 구조에 맞게 업데이트

이러한 접근 방식을 통해 앱의 기능을 중단 없이 유지하면서도 코드 품질을 지속적으로 개선할 수 있습니다.

해지 안내 페이지 URL 목록은 이제 위의 7.6절 참조