timsquad 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +288 -0
- package/README.md +158 -151
- package/dist/commands/compile.d.ts +3 -0
- package/dist/commands/compile.d.ts.map +1 -0
- package/dist/commands/compile.js +170 -0
- package/dist/commands/compile.js.map +1 -0
- package/dist/commands/daemon.d.ts.map +1 -1
- package/dist/commands/daemon.js +95 -5
- package/dist/commands/daemon.js.map +1 -1
- package/dist/commands/full.js +1 -0
- package/dist/commands/full.js.map +1 -1
- package/dist/commands/git/pr.js +6 -5
- package/dist/commands/git/pr.js.map +1 -1
- package/dist/commands/git/release.js +2 -7
- package/dist/commands/git/release.js.map +1 -1
- package/dist/commands/improve.js +2 -2
- package/dist/commands/improve.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +12 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/log.d.ts.map +1 -1
- package/dist/commands/log.js +2 -2
- package/dist/commands/log.js.map +1 -1
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +6 -2
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/retro.js +8 -8
- package/dist/commands/retro.js.map +1 -1
- package/dist/commands/session.js +3 -3
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/skills.d.ts +12 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +228 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/status.js +1 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +23 -1
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/daemon/entry.js +3 -3
- package/dist/daemon/entry.js.map +1 -1
- package/dist/daemon/event-queue.d.ts.map +1 -1
- package/dist/daemon/event-queue.js +2 -2
- package/dist/daemon/event-queue.js.map +1 -1
- package/dist/daemon/index.d.ts +4 -2
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +214 -52
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jsonl-watcher.d.ts +1 -0
- package/dist/daemon/jsonl-watcher.d.ts.map +1 -1
- package/dist/daemon/jsonl-watcher.js.map +1 -1
- package/dist/daemon/meta-cache.d.ts +1 -0
- package/dist/daemon/meta-cache.d.ts.map +1 -1
- package/dist/daemon/meta-cache.js +9 -0
- package/dist/daemon/meta-cache.js.map +1 -1
- package/dist/daemon/session-notes.d.ts +33 -0
- package/dist/daemon/session-notes.d.ts.map +1 -0
- package/dist/daemon/session-notes.js +74 -0
- package/dist/daemon/session-notes.js.map +1 -0
- package/dist/daemon/session-state.d.ts +27 -0
- package/dist/daemon/session-state.d.ts.map +1 -0
- package/dist/daemon/session-state.js +165 -0
- package/dist/daemon/session-state.js.map +1 -0
- package/dist/daemon/shutdown.d.ts.map +1 -1
- package/dist/daemon/shutdown.js +9 -1
- package/dist/daemon/shutdown.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/agent-generator.d.ts +4 -0
- package/dist/lib/agent-generator.d.ts.map +1 -1
- package/dist/lib/agent-generator.js +52 -3
- package/dist/lib/agent-generator.js.map +1 -1
- package/dist/lib/compile-rules.d.ts +66 -0
- package/dist/lib/compile-rules.d.ts.map +1 -0
- package/dist/lib/compile-rules.js +114 -0
- package/dist/lib/compile-rules.js.map +1 -0
- package/dist/lib/compiler.d.ts +105 -0
- package/dist/lib/compiler.d.ts.map +1 -0
- package/dist/lib/compiler.js +368 -0
- package/dist/lib/compiler.js.map +1 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +8 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/project.d.ts.map +1 -1
- package/dist/lib/project.js +8 -3
- package/dist/lib/project.js.map +1 -1
- package/dist/lib/skill-generator.d.ts.map +1 -1
- package/dist/lib/skill-generator.js +22 -1
- package/dist/lib/skill-generator.js.map +1 -1
- package/dist/lib/template.d.ts.map +1 -1
- package/dist/lib/template.js +6 -0
- package/dist/lib/template.js.map +1 -1
- package/dist/types/config.d.ts +1 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +12 -1
- package/dist/types/config.js.map +1 -1
- package/dist/types/project.d.ts +1 -1
- package/dist/types/project.d.ts.map +1 -1
- package/dist/types/project.js +2 -0
- package/dist/types/project.js.map +1 -1
- package/package.json +4 -4
- package/templates/base/agents/base/tsq-architect.md +2 -2
- package/templates/base/agents/overlays/domain/mobile/_common.md +13 -0
- package/templates/base/knowledge/checklists/plan-quality.md +31 -0
- package/templates/base/knowledge/checklists/stability-verification.md +14 -0
- package/templates/base/skills/controller/SKILL.md +111 -0
- package/templates/base/skills/controller/references/README.md +35 -0
- package/templates/base/skills/controller/rules/README.md +18 -0
- package/templates/base/skills/mobile/dart/SKILL.md +69 -0
- package/templates/base/skills/mobile/dart/rules/async-patterns.md +112 -0
- package/templates/base/skills/mobile/dart/rules/code-style.md +96 -0
- package/templates/base/skills/mobile/dart/rules/null-safety.md +84 -0
- package/templates/base/skills/mobile/dart/rules/type-system.md +111 -0
- package/templates/base/skills/mobile/flutter/SKILL.md +89 -0
- package/templates/base/skills/mobile/flutter/ci-cd/SKILL.md +82 -0
- package/templates/base/skills/mobile/flutter/ci-cd/references/ci-cd-pipeline.md +314 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/code-signing.md +106 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/codemagic-setup.md +116 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/fastlane-setup.md +105 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/github-actions.md +112 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/store-deployment.md +106 -0
- package/templates/base/skills/mobile/flutter/ci-cd/rules/versioning.md +107 -0
- package/templates/base/skills/mobile/flutter/i18n/SKILL.md +78 -0
- package/templates/base/skills/mobile/flutter/i18n/references/i18n-architecture.md +225 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/arb-files.md +182 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/locale-switching.md +226 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/localization-setup.md +137 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/plural-gender.md +159 -0
- package/templates/base/skills/mobile/flutter/i18n/rules/text-direction.md +199 -0
- package/templates/base/skills/mobile/flutter/monitoring/SKILL.md +81 -0
- package/templates/base/skills/mobile/flutter/monitoring/references/monitoring-architecture.md +269 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/analytics.md +227 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/crashlytics-setup.md +195 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/logging.md +258 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/performance-monitoring.md +248 -0
- package/templates/base/skills/mobile/flutter/monitoring/rules/sentry-integration.md +249 -0
- package/templates/base/skills/mobile/flutter/networking/SKILL.md +88 -0
- package/templates/base/skills/mobile/flutter/networking/references/api-client-architecture.md +305 -0
- package/templates/base/skills/mobile/flutter/networking/rules/caching.md +212 -0
- package/templates/base/skills/mobile/flutter/networking/rules/connectivity.md +213 -0
- package/templates/base/skills/mobile/flutter/networking/rules/dio-setup.md +159 -0
- package/templates/base/skills/mobile/flutter/networking/rules/error-handling.md +209 -0
- package/templates/base/skills/mobile/flutter/networking/rules/interceptors.md +205 -0
- package/templates/base/skills/mobile/flutter/networking/rules/retrofit-patterns.md +194 -0
- package/templates/base/skills/mobile/flutter/push-notifications/SKILL.md +87 -0
- package/templates/base/skills/mobile/flutter/push-notifications/references/notification-architecture.md +340 -0
- package/templates/base/skills/mobile/flutter/push-notifications/references/platform-setup.md +286 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/background-processing.md +308 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/deep-linking.md +217 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/fcm-setup.md +164 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/local-notifications.md +262 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/notification-handling.md +210 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/notification-permissions.md +246 -0
- package/templates/base/skills/mobile/flutter/push-notifications/rules/rich-notifications.md +320 -0
- package/templates/base/skills/mobile/flutter/references/freezed-patterns.md +162 -0
- package/templates/base/skills/mobile/flutter/references/project-structure.md +170 -0
- package/templates/base/skills/mobile/flutter/rules/animations.md +112 -0
- package/templates/base/skills/mobile/flutter/rules/architecture.md +121 -0
- package/templates/base/skills/mobile/flutter/rules/navigation-routing.md +117 -0
- package/templates/base/skills/mobile/flutter/rules/performance.md +112 -0
- package/templates/base/skills/mobile/flutter/rules/platform-adaptive.md +126 -0
- package/templates/base/skills/mobile/flutter/rules/state-management.md +110 -0
- package/templates/base/skills/mobile/flutter/rules/testing.md +131 -0
- package/templates/base/skills/mobile/flutter/rules/widget-conventions.md +122 -0
- package/templates/base/skills/mobile/flutter/security/SKILL.md +86 -0
- package/templates/base/skills/mobile/flutter/security/references/mobile-security-checklist.md +168 -0
- package/templates/base/skills/mobile/flutter/security/rules/api-key-protection.md +206 -0
- package/templates/base/skills/mobile/flutter/security/rules/authentication.md +248 -0
- package/templates/base/skills/mobile/flutter/security/rules/data-protection.md +271 -0
- package/templates/base/skills/mobile/flutter/security/rules/obfuscation.md +213 -0
- package/templates/base/skills/mobile/flutter/security/rules/secure-storage.md +171 -0
- package/templates/base/skills/mobile/flutter/security/rules/ssl-pinning.md +197 -0
- package/templates/base/skills/stability-verification/SKILL.md +64 -0
- package/templates/base/skills/stability-verification/references/release-checklist.md +34 -0
- package/templates/base/skills/stability-verification/references/security-fix-patterns.md +112 -0
- package/templates/base/skills/stability-verification/rules/verification-layers.md +67 -0
- package/templates/base/skills/stability-verification/rules/verification-workflow.md +69 -0
- package/templates/base/skills/stability-verification/scripts/verify.sh +294 -0
- package/templates/platforms/claude-code/CLAUDE.md.template +25 -0
- package/templates/platforms/claude-code/rules/build-gate.md +28 -0
- package/templates/platforms/claude-code/rules/completion-verification.md +30 -0
- package/templates/platforms/claude-code/rules/context-monitor.md +23 -0
- package/templates/platforms/claude-code/rules/plan-review.md +45 -0
- package/templates/platforms/claude-code/rules/quality-guards.md +43 -0
- package/templates/platforms/claude-code/rules/session-notes.md +18 -0
- package/templates/platforms/claude-code/rules/skill-suggest.md +27 -0
- package/templates/platforms/claude-code/scripts/build-gate.sh +73 -0
- package/templates/platforms/claude-code/scripts/completion-guard.sh +93 -0
- package/templates/platforms/claude-code/scripts/phase-guard.sh +79 -0
- package/templates/platforms/claude-code/scripts/safe-guard.sh +83 -0
- package/templates/platforms/claude-code/scripts/skill-rules.json +85 -0
- package/templates/platforms/claude-code/scripts/skill-suggest.sh +105 -0
- package/templates/platforms/claude-code/settings.json +111 -3
- package/templates/project-types/mobile-app/config.yaml +123 -0
- package/templates/project-types/mobile-app/process/workflow.xml +191 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Connectivity & Offline Queue
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: "오프라인 미처리 → 요청 유실, 무한 로딩, 사용자 혼란"
|
|
5
|
+
tags: connectivity, offline, queue, retry, connectivity-plus
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Connectivity & Offline Queue
|
|
9
|
+
|
|
10
|
+
**Impact: HIGH (오프라인 미처리 → 요청 유실, 무한 로딩, 사용자 혼란)**
|
|
11
|
+
|
|
12
|
+
connectivity_plus로 네트워크 상태 실시간 감지.
|
|
13
|
+
오프라인 시 요청 큐잉, 연결 복구 시 자동 재시도.
|
|
14
|
+
|
|
15
|
+
### 의존성
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
# pubspec.yaml
|
|
19
|
+
dependencies:
|
|
20
|
+
connectivity_plus: ^6.1.0
|
|
21
|
+
flutter_riverpod: ^2.6.0
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### ConnectivityNotifier
|
|
25
|
+
|
|
26
|
+
**Incorrect (단발 체크만):**
|
|
27
|
+
```dart
|
|
28
|
+
Future<bool> isOnline() async {
|
|
29
|
+
final result = await Connectivity().checkConnectivity();
|
|
30
|
+
return result != ConnectivityResult.none;
|
|
31
|
+
// → 상태 변화 미감지, UI 갱신 없음
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Correct (Riverpod + 실시간 스트림):**
|
|
36
|
+
```dart
|
|
37
|
+
/// 연결 상태 열거형
|
|
38
|
+
enum NetworkStatus { online, offline }
|
|
39
|
+
|
|
40
|
+
/// 연결 상태 Provider — 앱 전역 스트림
|
|
41
|
+
final connectivityProvider =
|
|
42
|
+
StreamNotifierProvider<ConnectivityNotifier, NetworkStatus>(
|
|
43
|
+
ConnectivityNotifier.new,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
class ConnectivityNotifier extends StreamNotifier<NetworkStatus> {
|
|
47
|
+
@override
|
|
48
|
+
Stream<NetworkStatus> build() {
|
|
49
|
+
return Connectivity().onConnectivityChanged.map((results) {
|
|
50
|
+
final hasConnection = results.any((r) => r != ConnectivityResult.none);
|
|
51
|
+
return hasConnection ? NetworkStatus.online : NetworkStatus.offline;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// 현재 온라인 여부 (동기 체크용)
|
|
57
|
+
final isOnlineProvider = Provider<bool>((ref) {
|
|
58
|
+
final status = ref.watch(connectivityProvider).valueOrNull;
|
|
59
|
+
return status == NetworkStatus.online;
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 오프라인 배너 UI
|
|
64
|
+
|
|
65
|
+
```dart
|
|
66
|
+
/// 오프라인 상태 배너 — 앱 최상단에 배치
|
|
67
|
+
class OfflineBanner extends ConsumerWidget {
|
|
68
|
+
const OfflineBanner({super.key});
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
72
|
+
final isOnline = ref.watch(isOnlineProvider);
|
|
73
|
+
|
|
74
|
+
if (isOnline) return const SizedBox.shrink();
|
|
75
|
+
|
|
76
|
+
return MaterialBanner(
|
|
77
|
+
content: const Text('인터넷 연결이 끊어졌습니다'),
|
|
78
|
+
leading: const Icon(Icons.wifi_off, color: Colors.white),
|
|
79
|
+
backgroundColor: Colors.red.shade700,
|
|
80
|
+
actions: [
|
|
81
|
+
TextButton(
|
|
82
|
+
onPressed: () async {
|
|
83
|
+
// 수동 재시도
|
|
84
|
+
final result = await Connectivity().checkConnectivity();
|
|
85
|
+
if (result.any((r) => r != ConnectivityResult.none)) {
|
|
86
|
+
ref.invalidate(connectivityProvider);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
child: const Text('재시도', style: TextStyle(color: Colors.white)),
|
|
90
|
+
),
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 오프라인 요청 큐
|
|
98
|
+
|
|
99
|
+
```dart
|
|
100
|
+
/// 오프라인 시 보류된 요청
|
|
101
|
+
class PendingRequest {
|
|
102
|
+
final String id;
|
|
103
|
+
final RequestOptions options;
|
|
104
|
+
final DateTime createdAt;
|
|
105
|
+
final int retryCount;
|
|
106
|
+
|
|
107
|
+
const PendingRequest({
|
|
108
|
+
required this.id,
|
|
109
|
+
required this.options,
|
|
110
|
+
required this.createdAt,
|
|
111
|
+
this.retryCount = 0,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
bool get isExpired =>
|
|
115
|
+
DateTime.now().difference(createdAt) > const Duration(hours: 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// 오프라인 큐 매니저
|
|
119
|
+
class OfflineQueueManager {
|
|
120
|
+
final Dio _dio;
|
|
121
|
+
final List<PendingRequest> _queue = [];
|
|
122
|
+
bool _isProcessing = false;
|
|
123
|
+
|
|
124
|
+
OfflineQueueManager({required Dio dio}) : _dio = dio;
|
|
125
|
+
|
|
126
|
+
/// 오프라인 시 큐에 추가
|
|
127
|
+
void enqueue(RequestOptions options) {
|
|
128
|
+
_queue.add(PendingRequest(
|
|
129
|
+
id: const Uuid().v4(),
|
|
130
|
+
options: options,
|
|
131
|
+
createdAt: DateTime.now(),
|
|
132
|
+
));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// 연결 복구 시 큐 처리
|
|
136
|
+
Future<void> processQueue() async {
|
|
137
|
+
if (_isProcessing || _queue.isEmpty) return;
|
|
138
|
+
_isProcessing = true;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// 만료된 요청 제거
|
|
142
|
+
_queue.removeWhere((r) => r.isExpired);
|
|
143
|
+
|
|
144
|
+
final pending = List<PendingRequest>.from(_queue);
|
|
145
|
+
for (final request in pending) {
|
|
146
|
+
try {
|
|
147
|
+
await _dio.fetch(request.options);
|
|
148
|
+
_queue.remove(request);
|
|
149
|
+
} on DioException {
|
|
150
|
+
// 실패 → 큐에 유지, 다음 복구 시 재시도
|
|
151
|
+
break; // 첫 실패 시 중단 (아직 불안정할 수 있음)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
_isProcessing = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
int get pendingCount => _queue.length;
|
|
160
|
+
bool get hasPending => _queue.isNotEmpty;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Provider
|
|
164
|
+
final offlineQueueProvider = Provider<OfflineQueueManager>((ref) {
|
|
165
|
+
final queue = OfflineQueueManager(dio: ref.watch(dioProvider));
|
|
166
|
+
|
|
167
|
+
// 온라인 복구 시 자동 처리
|
|
168
|
+
ref.listen(connectivityProvider, (prev, next) {
|
|
169
|
+
if (next.valueOrNull == NetworkStatus.online) {
|
|
170
|
+
queue.processQueue();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return queue;
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Connectivity Interceptor
|
|
179
|
+
|
|
180
|
+
```dart
|
|
181
|
+
/// Dio 인터셉터로 오프라인 감지 (선택적)
|
|
182
|
+
class ConnectivityInterceptor extends Interceptor {
|
|
183
|
+
final OfflineQueueManager _queueManager;
|
|
184
|
+
final Ref _ref;
|
|
185
|
+
|
|
186
|
+
ConnectivityInterceptor({
|
|
187
|
+
required OfflineQueueManager queueManager,
|
|
188
|
+
required Ref ref,
|
|
189
|
+
}) : _queueManager = queueManager,
|
|
190
|
+
_ref = ref;
|
|
191
|
+
|
|
192
|
+
@override
|
|
193
|
+
void onError(DioException err, ErrorInterceptorHandler handler) {
|
|
194
|
+
if (err.type == DioExceptionType.connectionError) {
|
|
195
|
+
// POST/PUT 요청을 큐에 추가 (GET은 캐시 fallback)
|
|
196
|
+
if (err.requestOptions.method != 'GET') {
|
|
197
|
+
_queueManager.enqueue(err.requestOptions);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
handler.next(err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 규칙
|
|
206
|
+
|
|
207
|
+
- `connectivity_plus` 스트림으로 실시간 감지 — 단발 체크 금지
|
|
208
|
+
- 오프라인 배너를 앱 최상단에 항상 표시
|
|
209
|
+
- 오프라인 큐: POST/PUT/DELETE 요청만 큐잉 (GET은 캐시 fallback)
|
|
210
|
+
- 만료된 큐 요청 자동 제거 (1시간 기본)
|
|
211
|
+
- 연결 복구 시 큐 자동 처리 — `ref.listen(connectivityProvider)` 활용
|
|
212
|
+
- 큐 처리 중 실패 시 중단 (네트워크 아직 불안정할 수 있음)
|
|
213
|
+
- Wi-Fi 연결 ≠ 인터넷 접속 — 실제 HTTP 핑으로 검증 고려
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Dio Setup & Configuration
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: "잘못된 Dio 설정 → 타임아웃 누락, 인스턴스 중복 생성, 메모리 낭비"
|
|
5
|
+
tags: dio, http, setup, riverpod, baseOptions
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Dio Setup & Configuration
|
|
9
|
+
|
|
10
|
+
**Impact: CRITICAL (잘못된 Dio 설정 → 타임아웃 누락, 인스턴스 중복 생성, 메모리 낭비)**
|
|
11
|
+
|
|
12
|
+
Dio 싱글톤 인스턴스 관리, BaseOptions 설정, 환경별 분리.
|
|
13
|
+
모든 네트워크 통신의 기반이 되는 핵심 인프라.
|
|
14
|
+
|
|
15
|
+
### 의존성
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
# pubspec.yaml
|
|
19
|
+
dependencies:
|
|
20
|
+
dio: ^5.7.0
|
|
21
|
+
flutter_riverpod: ^2.6.0
|
|
22
|
+
# 또는 riverpod (non-Flutter)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Dio 인스턴스 관리
|
|
26
|
+
|
|
27
|
+
**Incorrect (매번 새 인스턴스 생성):**
|
|
28
|
+
```dart
|
|
29
|
+
class UserRepository {
|
|
30
|
+
Future<User> getUser(String id) async {
|
|
31
|
+
// 매 호출마다 Dio 생성 → 인터셉터 미적용, 커넥션 풀 미활용
|
|
32
|
+
final dio = Dio();
|
|
33
|
+
final response = await dio.get('https://api.example.com/users/$id');
|
|
34
|
+
return User.fromJson(response.data);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Correct (Riverpod Provider로 싱글톤 관리):**
|
|
40
|
+
```dart
|
|
41
|
+
/// 환경 설정
|
|
42
|
+
enum AppEnvironment { dev, staging, prod }
|
|
43
|
+
|
|
44
|
+
class AppConfig {
|
|
45
|
+
final AppEnvironment environment;
|
|
46
|
+
final String baseUrl;
|
|
47
|
+
|
|
48
|
+
const AppConfig({required this.environment, required this.baseUrl});
|
|
49
|
+
|
|
50
|
+
static const dev = AppConfig(
|
|
51
|
+
environment: AppEnvironment.dev,
|
|
52
|
+
baseUrl: 'https://dev-api.example.com/v1',
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
static const staging = AppConfig(
|
|
56
|
+
environment: AppEnvironment.staging,
|
|
57
|
+
baseUrl: 'https://staging-api.example.com/v1',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
static const prod = AppConfig(
|
|
61
|
+
environment: AppEnvironment.prod,
|
|
62
|
+
baseUrl: 'https://api.example.com/v1',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// 앱 설정 Provider
|
|
67
|
+
final appConfigProvider = Provider<AppConfig>((ref) {
|
|
68
|
+
// main.dart에서 ProviderScope overrides로 주입
|
|
69
|
+
return AppConfig.dev;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/// Dio Provider — 앱 전역 싱글톤
|
|
73
|
+
final dioProvider = Provider<Dio>((ref) {
|
|
74
|
+
final config = ref.watch(appConfigProvider);
|
|
75
|
+
|
|
76
|
+
final dio = Dio(BaseOptions(
|
|
77
|
+
baseUrl: config.baseUrl,
|
|
78
|
+
connectTimeout: const Duration(seconds: 15),
|
|
79
|
+
receiveTimeout: const Duration(seconds: 15),
|
|
80
|
+
sendTimeout: const Duration(seconds: 15),
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
'Accept': 'application/json',
|
|
84
|
+
},
|
|
85
|
+
validateStatus: (status) => status != null && status < 500,
|
|
86
|
+
));
|
|
87
|
+
|
|
88
|
+
// 인터셉터 체인 (순서 중요)
|
|
89
|
+
dio.interceptors.addAll([
|
|
90
|
+
ref.watch(authInterceptorProvider),
|
|
91
|
+
ref.watch(retryInterceptorProvider),
|
|
92
|
+
if (config.environment == AppEnvironment.dev)
|
|
93
|
+
ref.watch(loggingInterceptorProvider),
|
|
94
|
+
ref.watch(errorTransformInterceptorProvider),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
return dio;
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### main.dart 환경 주입
|
|
102
|
+
|
|
103
|
+
```dart
|
|
104
|
+
Future<void> main() async {
|
|
105
|
+
WidgetsFlutterBinding.ensureInitialized();
|
|
106
|
+
|
|
107
|
+
// 환경 결정 (빌드 플래그, .env 등)
|
|
108
|
+
const config = String.fromEnvironment('ENV') == 'prod'
|
|
109
|
+
? AppConfig.prod
|
|
110
|
+
: AppConfig.dev;
|
|
111
|
+
|
|
112
|
+
runApp(
|
|
113
|
+
ProviderScope(
|
|
114
|
+
overrides: [
|
|
115
|
+
appConfigProvider.overrideWithValue(config),
|
|
116
|
+
],
|
|
117
|
+
child: const MyApp(),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### BaseOptions 상세
|
|
124
|
+
|
|
125
|
+
```dart
|
|
126
|
+
BaseOptions(
|
|
127
|
+
// 서버 기본 URL — 환경별 분리 필수
|
|
128
|
+
baseUrl: 'https://api.example.com/v1',
|
|
129
|
+
|
|
130
|
+
// 타임아웃 — 15초 권장 (모바일 네트워크 고려)
|
|
131
|
+
connectTimeout: const Duration(seconds: 15),
|
|
132
|
+
receiveTimeout: const Duration(seconds: 15),
|
|
133
|
+
sendTimeout: const Duration(seconds: 15),
|
|
134
|
+
|
|
135
|
+
// 기본 헤더
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
'Accept': 'application/json',
|
|
139
|
+
'X-App-Version': appVersion, // 서버 호환성 체크용
|
|
140
|
+
'X-Platform': Platform.isIOS ? 'ios' : 'android',
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// 응답 타입
|
|
144
|
+
responseType: ResponseType.json,
|
|
145
|
+
|
|
146
|
+
// 상태 코드 검증 — 5xx는 DioException으로 처리
|
|
147
|
+
validateStatus: (status) => status != null && status < 500,
|
|
148
|
+
)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 규칙
|
|
152
|
+
|
|
153
|
+
- Dio 인스턴스는 Riverpod Provider로 싱글톤 관리 — 직접 생성 금지
|
|
154
|
+
- `connectTimeout`, `receiveTimeout`, `sendTimeout` 모두 15초 설정
|
|
155
|
+
- `baseUrl` 은 환경별 분리 (dev/staging/prod) — 하드코딩 금지
|
|
156
|
+
- `Content-Type: application/json` 기본 헤더
|
|
157
|
+
- `validateStatus` — 5xx만 에러, 4xx는 응답 데이터로 처리
|
|
158
|
+
- Interceptor 등록은 Dio Provider 내에서 순서대로
|
|
159
|
+
- Logging 인터셉터는 dev 환경에서만 활성화
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Network Error Handling
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: "에러 미변환 → DioException UI 노출, 사용자 혼란, 디버깅 불가"
|
|
5
|
+
tags: dio, error, failure, sealed-class, result-pattern
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Network Error Handling
|
|
9
|
+
|
|
10
|
+
**Impact: CRITICAL (에러 미변환 → DioException UI 노출, 사용자 혼란, 디버깅 불가)**
|
|
11
|
+
|
|
12
|
+
DioException을 앱 도메인 NetworkFailure로 변환.
|
|
13
|
+
Repository에서 Result 패턴으로 래핑하여 UI까지 안전하게 전달.
|
|
14
|
+
|
|
15
|
+
### NetworkFailure Sealed Class
|
|
16
|
+
|
|
17
|
+
**Incorrect (DioException 직접 노출):**
|
|
18
|
+
```dart
|
|
19
|
+
class UserRepository {
|
|
20
|
+
Future<User> getUser(String id) async {
|
|
21
|
+
final response = await dio.get('/users/$id');
|
|
22
|
+
return User.fromJson(response.data);
|
|
23
|
+
// → DioException이 그대로 UI까지 전파
|
|
24
|
+
// → try-catch 누락 시 앱 크래시
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Correct (도메인 에러 변환 + Result 패턴):**
|
|
30
|
+
```dart
|
|
31
|
+
/// 네트워크 에러 도메인 모델
|
|
32
|
+
sealed class NetworkFailure {
|
|
33
|
+
final String message;
|
|
34
|
+
final int? statusCode;
|
|
35
|
+
final dynamic originalError;
|
|
36
|
+
|
|
37
|
+
const NetworkFailure({
|
|
38
|
+
required this.message,
|
|
39
|
+
this.statusCode,
|
|
40
|
+
this.originalError,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/// DioException → NetworkFailure 팩토리
|
|
44
|
+
factory NetworkFailure.fromDioException(DioException e) {
|
|
45
|
+
return switch (e.type) {
|
|
46
|
+
DioExceptionType.connectionTimeout ||
|
|
47
|
+
DioExceptionType.sendTimeout ||
|
|
48
|
+
DioExceptionType.receiveTimeout =>
|
|
49
|
+
TimeoutFailure(
|
|
50
|
+
message: '서버 응답 시간이 초과되었습니다',
|
|
51
|
+
originalError: e,
|
|
52
|
+
),
|
|
53
|
+
DioExceptionType.connectionError => NoConnectionFailure(
|
|
54
|
+
message: '인터넷 연결을 확인해주세요',
|
|
55
|
+
originalError: e,
|
|
56
|
+
),
|
|
57
|
+
DioExceptionType.badResponse => ServerFailure(
|
|
58
|
+
message: _extractServerMessage(e.response),
|
|
59
|
+
statusCode: e.response?.statusCode,
|
|
60
|
+
originalError: e,
|
|
61
|
+
),
|
|
62
|
+
DioExceptionType.cancel => RequestCancelledFailure(
|
|
63
|
+
message: '요청이 취소되었습니다',
|
|
64
|
+
originalError: e,
|
|
65
|
+
),
|
|
66
|
+
_ => UnknownNetworkFailure(
|
|
67
|
+
message: '알 수 없는 네트워크 오류가 발생했습니다',
|
|
68
|
+
originalError: e,
|
|
69
|
+
),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static String _extractServerMessage(Response? response) {
|
|
74
|
+
try {
|
|
75
|
+
final data = response?.data;
|
|
76
|
+
if (data is Map<String, dynamic>) {
|
|
77
|
+
return data['message'] as String? ??
|
|
78
|
+
data['error'] as String? ??
|
|
79
|
+
'Server error';
|
|
80
|
+
}
|
|
81
|
+
} catch (_) {}
|
|
82
|
+
return 'Server error (${response?.statusCode})';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class TimeoutFailure extends NetworkFailure {
|
|
87
|
+
const TimeoutFailure({required super.message, super.originalError});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class NoConnectionFailure extends NetworkFailure {
|
|
91
|
+
const NoConnectionFailure({required super.message, super.originalError});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class ServerFailure extends NetworkFailure {
|
|
95
|
+
const ServerFailure({
|
|
96
|
+
required super.message,
|
|
97
|
+
super.statusCode,
|
|
98
|
+
super.originalError,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
bool get isUnauthorized => statusCode == 401;
|
|
102
|
+
bool get isForbidden => statusCode == 403;
|
|
103
|
+
bool get isNotFound => statusCode == 404;
|
|
104
|
+
bool get isConflict => statusCode == 409;
|
|
105
|
+
bool get isValidationError => statusCode == 422;
|
|
106
|
+
bool get isRateLimited => statusCode == 429;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class RequestCancelledFailure extends NetworkFailure {
|
|
110
|
+
const RequestCancelledFailure({required super.message, super.originalError});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
class UnknownNetworkFailure extends NetworkFailure {
|
|
114
|
+
const UnknownNetworkFailure({required super.message, super.originalError});
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Result 패턴
|
|
119
|
+
|
|
120
|
+
```dart
|
|
121
|
+
/// 범용 Result 타입
|
|
122
|
+
sealed class Result<T> {
|
|
123
|
+
const Result();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class Success<T> extends Result<T> {
|
|
127
|
+
final T data;
|
|
128
|
+
const Success(this.data);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
class Failure<T> extends Result<T> {
|
|
132
|
+
final NetworkFailure failure;
|
|
133
|
+
const Failure(this.failure);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Repository에서 사용
|
|
137
|
+
class UserRepository {
|
|
138
|
+
final Dio _dio;
|
|
139
|
+
|
|
140
|
+
UserRepository({required Dio dio}) : _dio = dio;
|
|
141
|
+
|
|
142
|
+
Future<Result<User>> getUser(String id) async {
|
|
143
|
+
try {
|
|
144
|
+
final response = await _dio.get('/users/$id');
|
|
145
|
+
final user = User.fromJson(response.data);
|
|
146
|
+
return Success(user);
|
|
147
|
+
} on DioException catch (e) {
|
|
148
|
+
return Failure(NetworkFailure.fromDioException(e));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Future<Result<List<User>>> getUsers({int page = 1}) async {
|
|
153
|
+
try {
|
|
154
|
+
final response = await _dio.get('/users', queryParameters: {'page': page});
|
|
155
|
+
final users = (response.data as List)
|
|
156
|
+
.map((json) => User.fromJson(json))
|
|
157
|
+
.toList();
|
|
158
|
+
return Success(users);
|
|
159
|
+
} on DioException catch (e) {
|
|
160
|
+
return Failure(NetworkFailure.fromDioException(e));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### UI에서 Result 처리
|
|
167
|
+
|
|
168
|
+
```dart
|
|
169
|
+
/// Provider에서 Result 소비
|
|
170
|
+
class UserNotifier extends AsyncNotifier<User> {
|
|
171
|
+
@override
|
|
172
|
+
Future<User> build() async {
|
|
173
|
+
final result = await ref.read(userRepositoryProvider).getUser('me');
|
|
174
|
+
return switch (result) {
|
|
175
|
+
Success(data: final user) => user,
|
|
176
|
+
Failure(failure: final f) => throw f, // AsyncError로 전환
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Widget에서 에러 표시
|
|
182
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
183
|
+
final userAsync = ref.watch(userProvider);
|
|
184
|
+
|
|
185
|
+
return userAsync.when(
|
|
186
|
+
data: (user) => UserProfile(user: user),
|
|
187
|
+
loading: () => const LoadingIndicator(),
|
|
188
|
+
error: (error, _) {
|
|
189
|
+
if (error is NoConnectionFailure) {
|
|
190
|
+
return const OfflineWidget();
|
|
191
|
+
}
|
|
192
|
+
if (error is ServerFailure && error.isNotFound) {
|
|
193
|
+
return const UserNotFoundWidget();
|
|
194
|
+
}
|
|
195
|
+
return ErrorWidget(message: (error as NetworkFailure).message);
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### 규칙
|
|
202
|
+
|
|
203
|
+
- DioException을 UI 레이어까지 전파 금지 — 반드시 NetworkFailure로 변환
|
|
204
|
+
- `NetworkFailure.fromDioException()` 팩토리 단일 변환 지점
|
|
205
|
+
- Repository 메서드는 `Result<T>` 반환 — 절대 throw 하지 않음
|
|
206
|
+
- sealed class 사용 — switch 완전성 검사로 누락 방지
|
|
207
|
+
- 서버 에러 메시지 추출 → 사용자 친화적 메시지로 매핑
|
|
208
|
+
- 401 → 토큰 리프레시는 Auth 인터셉터 담당 (Repository 아님)
|
|
209
|
+
- 에러 로깅은 Logging 인터셉터에서 처리 (Repository에서 중복 로깅 금지)
|