실생활 앱 (구독 관리)
매달 결제되는 구독 서비스를 등록하고, 결제 상태 검증, 갱신 알림, 클라우드 동기화까지 고려하는 앱을 만듭니다.
동작
실생활 앱 (구독 관리) 실행 화면
구독 이름과 월 요금을 입력해 새 구독을 추가합니다. 활성 구독만 합산해 이번 달 예상 구독료를 표시합니다. 결제 상태를 검증하고, 갱신일이 가까운 구독의 알림을 예약합니다. 구독 변경 내역을 클라우드에 동기화하고 감사 로그로 남깁니다.
예제 코드
구독 관리
import 'dart:async';
import 'package:flutter/material.dart';
class Subscription {
const Subscription({
required this.serverId,
required this.name,
required this.price,
required this.nextBillingDay,
this.active = true,
this.verified = false,
});
final String serverId;
final String name;
final int price;
final int nextBillingDay;
final bool active;
final bool verified;
Subscription copyWith({String? name, int? price, int? nextBillingDay, bool? active, bool? verified}) {
return Subscription(
serverId: serverId,
name: name ?? this.name,
price: price ?? this.price,
nextBillingDay: nextBillingDay ?? this.nextBillingDay,
active: active ?? this.active,
verified: verified ?? this.verified,
);
}
}
class SubscriptionManagerApp extends StatefulWidget {
const SubscriptionManagerApp({super.key});
@override
State<SubscriptionManagerApp> createState() => _SubscriptionManagerAppState();
}
class _SubscriptionManagerAppState extends State<SubscriptionManagerApp> {
final nameController = TextEditingController(text: '음악 스트리밍');
final priceController = TextEditingController(text: '9900');
var subscriptions = const [
Subscription(serverId: 'sub_cloud_01', name: '클라우드 저장소', price: 3300, nextBillingDay: 4),
Subscription(serverId: 'sub_video_02', name: '영상 스트리밍', price: 13500, nextBillingDay: 18),
];
var syncing = false;
var verifying = false;
var auditLog = const ['로그인 사용자 확인'];
int get monthlyTotal {
return subscriptions
.where((item) => item.active)
.fold(0, (sum, item) => sum + item.price);
}
void addSubscription() {
final name = nameController.text.trim();
final price = int.tryParse(priceController.text) ?? 0;
if (name.isEmpty || price <= 0) return;
setState(() {
subscriptions = [
...subscriptions,
Subscription(serverId: 'local_\${DateTime.now().millisecondsSinceEpoch}', name: name, price: price, nextBillingDay: 12),
];
auditLog = ['구독 추가: $name', ...auditLog];
nameController.clear();
priceController.clear();
});
}
Future<void> verifyPayments() async {
setState(() => verifying = true);
await Future.delayed(const Duration(seconds: 1));
setState(() {
subscriptions = [for (final item in subscriptions) item.copyWith(verified: true)];
auditLog = ['결제 상태 검증 완료', ...auditLog];
verifying = false;
});
}
Future<void> syncCloud() async {
setState(() => syncing = true);
await Future.delayed(const Duration(seconds: 1));
setState(() {
auditLog = ['클라우드 동기화 완료', ...auditLog];
syncing = false;
});
}
void scheduleRenewalAlerts() {
final count = subscriptions.where((item) => item.active && item.nextBillingDay <= 7).length;
setState(() => auditLog = ['갱신 알림 $count건 예약', ...auditLog]);
}
void toggleSubscription(int index) {
setState(() {
subscriptions = [
for (var i = 0; i < subscriptions.length; i++)
i == index ? subscriptions[i].copyWith(active: !subscriptions[i].active) : subscriptions[i],
];
});
}
void removeSubscription(int index) {
setState(() => subscriptions = [...subscriptions]..removeAt(index));
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(controller: nameController),
TextField(controller: priceController, keyboardType: TextInputType.number),
ElevatedButton(onPressed: addSubscription, child: const Text('구독 추가')),
Text('월 구독료: $monthlyTotal원'),
Wrap(children: [
ElevatedButton(onPressed: syncCloud, child: Text(syncing ? '동기화 중' : '클라우드 동기화')),
ElevatedButton(onPressed: verifyPayments, child: Text(verifying ? '검증 중' : '결제 검증')),
ElevatedButton(onPressed: scheduleRenewalAlerts, child: const Text('갱신 알림 예약')),
]),
...List.generate(subscriptions.length, (index) {
final item = subscriptions[index];
return ListTile(
title: Text(item.name),
subtitle: Text(item.active
? '\${item.price}원 · D-\${item.nextBillingDay} · \${item.verified ? '검증됨' : '미검증'}'
: '해지됨'),
trailing: Wrap(children: [
Switch(value: item.active, onChanged: (_) => toggleSubscription(index)),
IconButton(onPressed: () => removeSubscription(index), icon: const Icon(Icons.delete)),
]),
);
}),
...auditLog.map(Text.new),
],
);
}
}
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'구독 관리',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900),
),
const SizedBox(height: 16),
TextField(controller: nameController),
const SizedBox(height: 12),
FilledButton(onPressed: addSubscription, child: const Text('구독 추가')),
Text('월 구독료: ${totalPrice}원'),
...subscriptions.map((item) {
return ListTile(
title: Text(item.name),
subtitle: Text('${item.price}원'),
trailing: Switch(value: item.active, onChanged: (_) => toggleSubscription(item)),
);
})
],
),
),
)
예제 코드 기능별 설명
코드를 나누어 읽기
import
dart:async은 서버 동기화, 결제 검증, 알림 예약처럼 시간이 걸리는 작업을 Future로 다루기 위해 사용합니다. material.dart는 입력창, 버튼, 목록, 상태 배지 UI를 제공합니다. 실제 서비스에서는 결제 SDK, 인증, 서버 API, 로컬 알림 또는 푸시 알림 패키지가 추가됩니다.
import 'dart:async';
import 'package:flutter/material.dart';
Subscription 모델
Subscription은 구독 이름, 월 요금, 활성 상태뿐 아니라 serverId, nextBillingDay, verified를 함께 담습니다. serverId는 서버의 실제 구독 레코드와 연결하기 위해 필요하고, nextBillingDay는 갱신 알림 계산에 쓰이며, verified는 로컬 상태가 결제 서버와 확인되었는지 나타냅니다.
class Subscription {
const Subscription({required this.serverId, required this.name, required this.price, required this.nextBillingDay, this.active = true, this.verified = false});
final String serverId;
final int nextBillingDay;
final bool verified;
}
copyWith
구독 상태는 여러 필드 중 일부만 자주 바뀝니다. 해지하면 active만 바뀌고, 결제 검증을 하면 verified만 바뀝니다. copyWith를 두면 기존 데이터를 보존하면서 필요한 필드만 교체할 수 있어 상태 변경 코드가 짧고 안전해집니다.
Subscription copyWith({bool? active, bool? verified}) {
return Subscription(
serverId: serverId,
name: name,
price: price,
nextBillingDay: nextBillingDay,
active: active ?? this.active,
verified: verified ?? this.verified,
);
}
월 구독료 계산
monthlyTotal은 활성 구독만 골라 합산합니다. 해지된 항목은 목록에는 남아 있어도 이번 달 결제 예상액에는 포함하지 않습니다. 금액 합계는 별도 상태로 저장하기보다 subscriptions에서 계산하면 데이터 불일치를 줄일 수 있습니다.
int get monthlyTotal {
return subscriptions
.where((item) => item.active)
.fold(0, (sum, item) => sum + item.price);
}
결제 검증
결제 검증은 로컬 목록만 믿지 않고 서버의 billing API에서 실제 구독 상태를 다시 확인하는 과정입니다. 사용자가 이미 웹에서 구독을 해지했거나 결제가 실패했을 수 있기 때문입니다. 예제에서는 1초 지연 후 모든 항목의 verified를 true로 바꾸지만, 실제 앱에서는 serverId 목록을 서버에 보내 검증 결과를 받아옵니다.
setState(() => verifying = true);
final checked = await billingApi.verifySubscriptions(subscriptions);
setState(() {
subscriptions = checked;
verifying = false;
});
갱신 알림
구독 앱은 다음 결제일이 가까운 항목을 찾아 사용자에게 미리 알려야 합니다. 예제에서는 nextBillingDay가 7일 이하인 활성 구독을 세고, 갱신 알림 예약 로그를 남깁니다. 실제 앱에서는 이 결과를 flutter_local_notifications, Firebase Cloud Messaging, 서버 스케줄러 중 하나와 연결합니다.
final upcoming = subscriptions.where((item) => item.active && item.daysUntilBilling <= 7);
await notificationApi.scheduleRenewalReminders(upcoming);
클라우드 동기화와 감사 로그
고급 앱에서는 여러 기기에서 같은 구독 상태를 봐야 하므로 클라우드 동기화가 필요합니다. 또한 결제 검증, 해지, 알림 예약 같은 중요한 동작은 감사 로그로 남겨 문제 발생 시 추적할 수 있게 합니다. 감사 로그는 사용자가 언제 무엇을 바꿨는지 보여주는 고객지원 자료가 되기도 합니다.
await auth.requireLogin();
await cloudSync.push(subscriptions);
auditLog.add('billing verified at $now');
해지와 재개
구독을 해지할 때 곧바로 삭제하지 않고 active를 false로 바꾸면 이력을 유지할 수 있습니다. 사용자가 다시 재개하면 같은 serverId를 사용해 서버 구독 상태를 되살릴 수 있습니다. 완전 삭제는 더 이상 추적하지 않아도 되는 항목에만 사용합니다.
subscriptions = [
for (final item in subscriptions)
item.serverId == serverId ? item.copyWith(active: !item.active) : item,
];
상태 배지
결과 화면 상단의 동기화, 결제 검증, 갱신 알림 배지는 백엔드 작업이 어떤 상태인지 빠르게 보여줍니다. 고급 앱에서는 단순 목록보다 이런 운영 상태가 중요합니다. 사용자는 데이터가 최신인지, 결제가 검증되었는지, 알림이 예약되었는지를 한눈에 확인해야 합니다.
Text(syncing ? '동기화 중' : '완료')
Text(verifying ? '검증 중' : '검증 필요')
Text('$alertCount건')