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,227 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Analytics Event Taxonomy & Screen Tracking
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: "이벤트 미설계 → 무의미한 데이터 축적, 의사결정 근거 부재"
|
|
5
|
+
tags: firebase-analytics, event, screen-view, user-property, observer
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Analytics Event Taxonomy & Screen Tracking
|
|
9
|
+
|
|
10
|
+
**Impact: HIGH (이벤트 미설계 → 무의미한 데이터 축적, 의사결정 근거 부재)**
|
|
11
|
+
|
|
12
|
+
Firebase Analytics 초기화, 이벤트 택소노미 설계, 사용자 속성,
|
|
13
|
+
AnalyticsObserver를 통한 화면 추적, 디버그 모드.
|
|
14
|
+
|
|
15
|
+
### 의존성
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
# pubspec.yaml
|
|
19
|
+
dependencies:
|
|
20
|
+
firebase_core: ^3.8.0
|
|
21
|
+
firebase_analytics: ^11.4.0
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 이벤트 택소노미 설계
|
|
25
|
+
|
|
26
|
+
**Incorrect (무분별한 이벤트 로깅):**
|
|
27
|
+
```dart
|
|
28
|
+
// 이벤트 이름이 일관성 없고 파라미터 미정의
|
|
29
|
+
analytics.logEvent(name: 'clicked_button');
|
|
30
|
+
analytics.logEvent(name: 'user_did_something');
|
|
31
|
+
analytics.logEvent(name: 'pageView', parameters: {'p': 'home'});
|
|
32
|
+
// → 분석 불가능한 데이터 축적
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Correct (구조화된 택소노미):**
|
|
36
|
+
```dart
|
|
37
|
+
/// 이벤트 이름 상수 (snake_case, 40자 이하)
|
|
38
|
+
abstract class AnalyticsEvents {
|
|
39
|
+
// 화면 이벤트
|
|
40
|
+
static const screenView = 'screen_view';
|
|
41
|
+
|
|
42
|
+
// 사용자 액션
|
|
43
|
+
static const buttonClick = 'button_click';
|
|
44
|
+
static const featureUse = 'feature_use';
|
|
45
|
+
static const search = 'search';
|
|
46
|
+
|
|
47
|
+
// 비즈니스 이벤트
|
|
48
|
+
static const matchCreated = 'match_created';
|
|
49
|
+
static const matchJoined = 'match_joined';
|
|
50
|
+
static const matchCompleted = 'match_completed';
|
|
51
|
+
|
|
52
|
+
// 전환 이벤트
|
|
53
|
+
static const signUp = 'sign_up';
|
|
54
|
+
static const login = 'login';
|
|
55
|
+
static const subscriptionStarted = 'subscription_started';
|
|
56
|
+
|
|
57
|
+
// 에러 이벤트
|
|
58
|
+
static const errorOccurred = 'error_occurred';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// 파라미터 이름 상수
|
|
62
|
+
abstract class AnalyticsParams {
|
|
63
|
+
static const screenName = 'screen_name';
|
|
64
|
+
static const screenClass = 'screen_class';
|
|
65
|
+
static const buttonName = 'button_name';
|
|
66
|
+
static const featureName = 'feature_name';
|
|
67
|
+
static const sportType = 'sport_type';
|
|
68
|
+
static const errorType = 'error_type';
|
|
69
|
+
static const errorMessage = 'error_message';
|
|
70
|
+
static const source = 'source';
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Analytics 서비스
|
|
75
|
+
|
|
76
|
+
```dart
|
|
77
|
+
class AnalyticsService {
|
|
78
|
+
final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
|
|
79
|
+
|
|
80
|
+
/// 화면 조회
|
|
81
|
+
Future<void> logScreenView({
|
|
82
|
+
required String screenName,
|
|
83
|
+
String? screenClass,
|
|
84
|
+
}) async {
|
|
85
|
+
await _analytics.logScreenView(
|
|
86
|
+
screenName: screenName,
|
|
87
|
+
screenClass: screenClass ?? screenName,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// 버튼 클릭
|
|
92
|
+
Future<void> logButtonClick({
|
|
93
|
+
required String buttonName,
|
|
94
|
+
required String screenName,
|
|
95
|
+
}) async {
|
|
96
|
+
await _analytics.logEvent(
|
|
97
|
+
name: AnalyticsEvents.buttonClick,
|
|
98
|
+
parameters: {
|
|
99
|
+
AnalyticsParams.buttonName: buttonName,
|
|
100
|
+
AnalyticsParams.screenName: screenName,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// 기능 사용
|
|
106
|
+
Future<void> logFeatureUse({
|
|
107
|
+
required String featureName,
|
|
108
|
+
Map<String, Object>? extra,
|
|
109
|
+
}) async {
|
|
110
|
+
await _analytics.logEvent(
|
|
111
|
+
name: AnalyticsEvents.featureUse,
|
|
112
|
+
parameters: {
|
|
113
|
+
AnalyticsParams.featureName: featureName,
|
|
114
|
+
...?extra,
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// 비즈니스 이벤트
|
|
120
|
+
Future<void> logMatchCreated({
|
|
121
|
+
required String sportType,
|
|
122
|
+
required int maxPlayers,
|
|
123
|
+
}) async {
|
|
124
|
+
await _analytics.logEvent(
|
|
125
|
+
name: AnalyticsEvents.matchCreated,
|
|
126
|
+
parameters: {
|
|
127
|
+
AnalyticsParams.sportType: sportType,
|
|
128
|
+
'max_players': maxPlayers,
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Riverpod Provider
|
|
135
|
+
final analyticsServiceProvider = Provider<AnalyticsService>((ref) {
|
|
136
|
+
return AnalyticsService();
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 사용자 속성
|
|
141
|
+
|
|
142
|
+
```dart
|
|
143
|
+
/// 사용자 세그먼트 설정 (최대 25개 속성)
|
|
144
|
+
Future<void> setUserProperties({
|
|
145
|
+
required String userId,
|
|
146
|
+
String? userType,
|
|
147
|
+
String? subscriptionTier,
|
|
148
|
+
String? preferredSport,
|
|
149
|
+
String? region,
|
|
150
|
+
}) async {
|
|
151
|
+
final analytics = FirebaseAnalytics.instance;
|
|
152
|
+
|
|
153
|
+
await analytics.setUserId(id: userId);
|
|
154
|
+
|
|
155
|
+
if (userType != null) {
|
|
156
|
+
await analytics.setUserProperty(name: 'user_type', value: userType);
|
|
157
|
+
}
|
|
158
|
+
if (subscriptionTier != null) {
|
|
159
|
+
await analytics.setUserProperty(
|
|
160
|
+
name: 'subscription_tier',
|
|
161
|
+
value: subscriptionTier,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (preferredSport != null) {
|
|
165
|
+
await analytics.setUserProperty(
|
|
166
|
+
name: 'preferred_sport',
|
|
167
|
+
value: preferredSport,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (region != null) {
|
|
171
|
+
await analytics.setUserProperty(name: 'region', value: region);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### go_router 화면 추적 (AnalyticsObserver)
|
|
177
|
+
|
|
178
|
+
```dart
|
|
179
|
+
// GoRouter 설정에 observer 추가
|
|
180
|
+
final goRouter = GoRouter(
|
|
181
|
+
routes: [...],
|
|
182
|
+
observers: [
|
|
183
|
+
FirebaseAnalyticsObserver(
|
|
184
|
+
analytics: FirebaseAnalytics.instance,
|
|
185
|
+
nameExtractor: (settings) {
|
|
186
|
+
// route 이름을 screen_name으로 매핑
|
|
187
|
+
return settings.name ?? settings.arguments?.toString() ?? 'unknown';
|
|
188
|
+
},
|
|
189
|
+
),
|
|
190
|
+
],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// GoRoute에 name 명시 (observer가 이름 추출)
|
|
194
|
+
GoRoute(
|
|
195
|
+
path: '/match/:id',
|
|
196
|
+
name: 'match_detail',
|
|
197
|
+
builder: (context, state) => MatchDetailScreen(
|
|
198
|
+
matchId: state.pathParameters['id']!,
|
|
199
|
+
),
|
|
200
|
+
),
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### 디버그 모드
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
# Android: DebugView 활성화
|
|
207
|
+
adb shell setprop debug.firebase.analytics.app <package_name>
|
|
208
|
+
|
|
209
|
+
# 이벤트가 실시간으로 Firebase Console > DebugView에 표시
|
|
210
|
+
# 비활성화:
|
|
211
|
+
adb shell setprop debug.firebase.analytics.app .none.
|
|
212
|
+
|
|
213
|
+
# iOS: Xcode Scheme > Arguments
|
|
214
|
+
-FIRDebugEnabled # 활성화
|
|
215
|
+
-FIRDebugDisabled # 비활성화
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 규칙
|
|
219
|
+
|
|
220
|
+
- 이벤트 택소노미 → 구현 전에 문서화 (이름, 파라미터, 트리거 조건)
|
|
221
|
+
- 이벤트 이름 → snake_case, 40자 이하, 자동 수집 이벤트와 충돌 금지
|
|
222
|
+
- 파라미터 → 최대 25개/이벤트, 값 100자 이하
|
|
223
|
+
- 사용자 속성 → 세그먼트 기준만 (최대 25개, 값 36자 이하)
|
|
224
|
+
- `AnalyticsObserver` → go_router 연동으로 화면 전환 자동 추적
|
|
225
|
+
- `setUserId` → 로그인 시 설정, 로그아웃 시 `null` 로 해제
|
|
226
|
+
- 디버그 → DebugView로 실시간 이벤트 검증 후 프로덕션 배포
|
|
227
|
+
- PII (개인식별정보) → 이벤트 파라미터에 이름/이메일/전화번호 금지
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Crashlytics Setup & Error Capture
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: "크래시 미수집 → 프로덕션 장애 감지 불가, 사용자 이탈 원인 파악 불가"
|
|
5
|
+
tags: crashlytics, firebase, crash, error, fatal
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Crashlytics Setup & Error Capture
|
|
9
|
+
|
|
10
|
+
**Impact: CRITICAL (크래시 미수집 → 프로덕션 장애 감지 불가, 사용자 이탈 원인 파악 불가)**
|
|
11
|
+
|
|
12
|
+
Firebase Crashlytics 초기화, Flutter/Dart 에러 캡처, 비치명 에러 기록,
|
|
13
|
+
사용자 식별자 및 커스텀 키 설정.
|
|
14
|
+
|
|
15
|
+
### 의존성
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
# pubspec.yaml
|
|
19
|
+
dependencies:
|
|
20
|
+
firebase_core: ^3.8.0
|
|
21
|
+
firebase_crashlytics: ^4.3.0
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 초기화
|
|
25
|
+
|
|
26
|
+
**Incorrect (에러 핸들러 미연결):**
|
|
27
|
+
```dart
|
|
28
|
+
void main() async {
|
|
29
|
+
WidgetsFlutterBinding.ensureInitialized();
|
|
30
|
+
await Firebase.initializeApp();
|
|
31
|
+
// Crashlytics 에러 핸들러 미등록 → 크래시 미수집
|
|
32
|
+
runApp(const MyApp());
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Correct (완전한 에러 캡처 파이프라인):**
|
|
37
|
+
```dart
|
|
38
|
+
Future<void> main() async {
|
|
39
|
+
WidgetsFlutterBinding.ensureInitialized();
|
|
40
|
+
await Firebase.initializeApp();
|
|
41
|
+
|
|
42
|
+
// 1. Flutter 프레임워크 에러 (위젯 빌드 에러 등)
|
|
43
|
+
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
|
|
44
|
+
|
|
45
|
+
// 2. 비동기 에러 (Future, Isolate 등)
|
|
46
|
+
PlatformDispatcher.instance.onError = (error, stack) {
|
|
47
|
+
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// 3. Dart Zone 에러 (runZonedGuarded)
|
|
52
|
+
runZonedGuarded<Future<void>>(
|
|
53
|
+
() async {
|
|
54
|
+
runApp(const ProviderScope(child: MyApp()));
|
|
55
|
+
},
|
|
56
|
+
(error, stack) {
|
|
57
|
+
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 디버그 모드 제어
|
|
64
|
+
|
|
65
|
+
```dart
|
|
66
|
+
// 개발 중 Crashlytics 비활성화 (크래시 대시보드 오염 방지)
|
|
67
|
+
await FirebaseCrashlytics.instance
|
|
68
|
+
.setCrashlyticsCollectionEnabled(!kDebugMode);
|
|
69
|
+
|
|
70
|
+
// 또는 환경별 분리
|
|
71
|
+
final isProduction = const String.fromEnvironment('ENV') == 'production';
|
|
72
|
+
await FirebaseCrashlytics.instance
|
|
73
|
+
.setCrashlyticsCollectionEnabled(isProduction);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 비치명 에러 기록
|
|
77
|
+
|
|
78
|
+
**Incorrect (try-catch만 하고 기록 안 함):**
|
|
79
|
+
```dart
|
|
80
|
+
try {
|
|
81
|
+
await apiService.fetchData();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
debugPrint('Error: $e');
|
|
84
|
+
// → 프로덕션에서 에러 발생 사실을 알 수 없음
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Correct (비치명 에러 Crashlytics 기록):**
|
|
89
|
+
```dart
|
|
90
|
+
try {
|
|
91
|
+
await apiService.fetchData();
|
|
92
|
+
} catch (error, stack) {
|
|
93
|
+
// fatal: false → 비치명 에러 (앱 크래시 아님)
|
|
94
|
+
await FirebaseCrashlytics.instance.recordError(
|
|
95
|
+
error,
|
|
96
|
+
stack,
|
|
97
|
+
fatal: false,
|
|
98
|
+
reason: 'fetchData API call failed',
|
|
99
|
+
);
|
|
100
|
+
// 사용자에게 에러 UI 표시
|
|
101
|
+
rethrow; // 또는 적절한 에러 핸들링
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 사용자 식별자
|
|
106
|
+
|
|
107
|
+
```dart
|
|
108
|
+
class CrashlyticsService {
|
|
109
|
+
final FirebaseCrashlytics _crashlytics = FirebaseCrashlytics.instance;
|
|
110
|
+
|
|
111
|
+
/// 로그인 시 사용자 식별자 설정
|
|
112
|
+
Future<void> setUser(String userId) async {
|
|
113
|
+
await _crashlytics.setUserIdentifier(userId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// 로그아웃 시 식별자 제거
|
|
117
|
+
Future<void> clearUser() async {
|
|
118
|
+
await _crashlytics.setUserIdentifier('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// 커스텀 키 설정 (크래시 컨텍스트)
|
|
122
|
+
Future<void> setContext({
|
|
123
|
+
required String appVersion,
|
|
124
|
+
required String buildNumber,
|
|
125
|
+
String? subscriptionTier,
|
|
126
|
+
bool? isOnboarded,
|
|
127
|
+
}) async {
|
|
128
|
+
await _crashlytics.setCustomKey('app_version', appVersion);
|
|
129
|
+
await _crashlytics.setCustomKey('build_number', buildNumber);
|
|
130
|
+
if (subscriptionTier != null) {
|
|
131
|
+
await _crashlytics.setCustomKey('subscription_tier', subscriptionTier);
|
|
132
|
+
}
|
|
133
|
+
if (isOnboarded != null) {
|
|
134
|
+
await _crashlytics.setCustomKey('is_onboarded', isOnboarded);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// 커스텀 로그 (크래시 발생 직전 흐름 추적)
|
|
139
|
+
void log(String message) {
|
|
140
|
+
_crashlytics.log(message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// 비치명 에러 기록
|
|
144
|
+
Future<void> recordNonFatal(
|
|
145
|
+
dynamic error,
|
|
146
|
+
StackTrace stack, {
|
|
147
|
+
String? reason,
|
|
148
|
+
}) async {
|
|
149
|
+
await _crashlytics.recordError(
|
|
150
|
+
error,
|
|
151
|
+
stack,
|
|
152
|
+
fatal: false,
|
|
153
|
+
reason: reason,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Riverpod Provider
|
|
159
|
+
final crashlyticsServiceProvider = Provider<CrashlyticsService>((ref) {
|
|
160
|
+
return CrashlyticsService();
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### dSYM / ProGuard 설정
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
iOS (dSYM 업로드):
|
|
168
|
+
Xcode > Build Settings > Debug Information Format = DWARF with dSYM File
|
|
169
|
+
firebase_crashlytics가 빌드 스크립트 자동 추가 (Run Script Phase)
|
|
170
|
+
수동: firebase crashlytics:symbols:upload --app=<APP_ID> path/to/dSYMs
|
|
171
|
+
|
|
172
|
+
Android (ProGuard/R8 mapping):
|
|
173
|
+
android/app/build.gradle:
|
|
174
|
+
buildTypes {
|
|
175
|
+
release {
|
|
176
|
+
minifyEnabled true
|
|
177
|
+
shrinkResources true
|
|
178
|
+
// firebase_crashlytics Gradle 플러그인이 mapping 자동 업로드
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
android/build.gradle:
|
|
182
|
+
plugins { id 'com.google.firebase.crashlytics' }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 규칙
|
|
186
|
+
|
|
187
|
+
- `FlutterError.onError` → Crashlytics 연결 필수 (Flutter 프레임워크 에러)
|
|
188
|
+
- `PlatformDispatcher.instance.onError` → 비동기 에러 캡처 필수
|
|
189
|
+
- `runZonedGuarded` → Dart Zone 에러까지 포괄 (선택이지만 강력 권장)
|
|
190
|
+
- 디버그 모드 → `setCrashlyticsCollectionEnabled(false)` (대시보드 오염 방지)
|
|
191
|
+
- 비치명 에러 → `recordError(fatal: false)` + reason 명시
|
|
192
|
+
- `setUserIdentifier` → 로그인/로그아웃 시 설정/해제
|
|
193
|
+
- `setCustomKey` → 구독 등급, 기능 플래그 등 비즈니스 컨텍스트
|
|
194
|
+
- `log()` → 크래시 직전 사용자 흐름 기록 (최대 64KB)
|
|
195
|
+
- dSYM (iOS) / ProGuard mapping (Android) → 릴리스 빌드 심볼 업로드 필수
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Structured Logging & Sensitive Data Masking
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: "로그 미관리 → 프로덕션 디버깅 불가, 민감 데이터 유출 위험"
|
|
5
|
+
tags: logger, structured-logging, masking, remote-logging, log-level
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Structured Logging & Sensitive Data Masking
|
|
9
|
+
|
|
10
|
+
**Impact: MEDIUM (로그 미관리 → 프로덕션 디버깅 불가, 민감 데이터 유출 위험)**
|
|
11
|
+
|
|
12
|
+
logger 패키지를 활용한 레벨별 로깅, JSON 구조화된 로그 출력,
|
|
13
|
+
릴리스 빌드 레벨 필터, 원격 로깅 연동, 민감 데이터 마스킹.
|
|
14
|
+
|
|
15
|
+
### 의존성
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
# pubspec.yaml
|
|
19
|
+
dependencies:
|
|
20
|
+
logger: ^2.5.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 기본 설정
|
|
24
|
+
|
|
25
|
+
**Incorrect (print/debugPrint 직접 사용):**
|
|
26
|
+
```dart
|
|
27
|
+
print('User logged in: ${user.email}');
|
|
28
|
+
debugPrint('API response: ${response.data}');
|
|
29
|
+
// → 레벨 구분 없음, 릴리스에서 제거 불가, PII 노출
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Correct (구조화된 로거):**
|
|
33
|
+
```dart
|
|
34
|
+
class AppLogger {
|
|
35
|
+
static final AppLogger instance = AppLogger._();
|
|
36
|
+
AppLogger._();
|
|
37
|
+
|
|
38
|
+
late final Logger _logger;
|
|
39
|
+
final List<LogOutput> _outputs = [];
|
|
40
|
+
|
|
41
|
+
void initialize({bool isRelease = false}) {
|
|
42
|
+
_logger = Logger(
|
|
43
|
+
filter: isRelease ? ProductionFilter() : DevelopFilter(),
|
|
44
|
+
printer: isRelease
|
|
45
|
+
? JsonPrinter() // 프로덕션: JSON 구조화
|
|
46
|
+
: PrettyPrinter( // 개발: 색상 + 포맷팅
|
|
47
|
+
methodCount: 2,
|
|
48
|
+
errorMethodCount: 8,
|
|
49
|
+
lineLength: 120,
|
|
50
|
+
colors: true,
|
|
51
|
+
printEmojis: false,
|
|
52
|
+
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
|
|
53
|
+
),
|
|
54
|
+
output: MultiOutput(_outputs),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
void addOutput(LogOutput output) => _outputs.add(output);
|
|
59
|
+
|
|
60
|
+
void verbose(String message, [dynamic error, StackTrace? stack]) =>
|
|
61
|
+
_logger.t(message, error: error, stackTrace: stack);
|
|
62
|
+
|
|
63
|
+
void debug(String message, [dynamic error, StackTrace? stack]) =>
|
|
64
|
+
_logger.d(message, error: error, stackTrace: stack);
|
|
65
|
+
|
|
66
|
+
void info(String message, [dynamic error, StackTrace? stack]) =>
|
|
67
|
+
_logger.i(message, error: error, stackTrace: stack);
|
|
68
|
+
|
|
69
|
+
void warning(String message, [dynamic error, StackTrace? stack]) =>
|
|
70
|
+
_logger.w(message, error: error, stackTrace: stack);
|
|
71
|
+
|
|
72
|
+
void error(String message, [dynamic error, StackTrace? stack]) =>
|
|
73
|
+
_logger.e(message, error: error, stackTrace: stack);
|
|
74
|
+
|
|
75
|
+
void fatal(String message, [dynamic error, StackTrace? stack]) =>
|
|
76
|
+
_logger.f(message, error: error, stackTrace: stack);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 사용
|
|
80
|
+
final log = AppLogger.instance;
|
|
81
|
+
log.info('User logged in', null, null);
|
|
82
|
+
log.error('API call failed', exception, stackTrace);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 릴리스 빌드 필터
|
|
86
|
+
|
|
87
|
+
```dart
|
|
88
|
+
/// 프로덕션: warning 이상만 출력
|
|
89
|
+
class ProductionFilter extends LogFilter {
|
|
90
|
+
@override
|
|
91
|
+
bool shouldLog(LogEvent event) {
|
|
92
|
+
return event.level.index >= Level.warning.index;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// 개발: 모든 레벨 출력
|
|
97
|
+
class DevelopFilter extends LogFilter {
|
|
98
|
+
@override
|
|
99
|
+
bool shouldLog(LogEvent event) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### JSON 구조화 프린터
|
|
106
|
+
|
|
107
|
+
```dart
|
|
108
|
+
/// 프로덕션용 JSON 출력 (원격 로깅 시스템과 호환)
|
|
109
|
+
class JsonPrinter extends LogPrinter {
|
|
110
|
+
@override
|
|
111
|
+
List<String> log(LogEvent event) {
|
|
112
|
+
final output = {
|
|
113
|
+
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
|
114
|
+
'level': event.level.name,
|
|
115
|
+
'message': event.message,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (event.error != null) {
|
|
119
|
+
output['error'] = event.error.toString();
|
|
120
|
+
}
|
|
121
|
+
if (event.stackTrace != null) {
|
|
122
|
+
output['stackTrace'] = event.stackTrace.toString();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [jsonEncode(output)];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 원격 로깅
|
|
131
|
+
|
|
132
|
+
```dart
|
|
133
|
+
/// Sentry breadcrumb로 원격 로깅
|
|
134
|
+
class SentryLogOutput extends LogOutput {
|
|
135
|
+
@override
|
|
136
|
+
void output(OutputEvent event) {
|
|
137
|
+
// warning 이상만 Sentry breadcrumb으로 전송
|
|
138
|
+
if (event.level.index < Level.warning.index) return;
|
|
139
|
+
|
|
140
|
+
final level = switch (event.level) {
|
|
141
|
+
Level.warning => SentryLevel.warning,
|
|
142
|
+
Level.error => SentryLevel.error,
|
|
143
|
+
Level.fatal => SentryLevel.fatal,
|
|
144
|
+
_ => SentryLevel.info,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
Sentry.addBreadcrumb(Breadcrumb(
|
|
148
|
+
message: event.lines.join('\n'),
|
|
149
|
+
level: level,
|
|
150
|
+
category: 'app.log',
|
|
151
|
+
timestamp: DateTime.now().toUtc(),
|
|
152
|
+
));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// Crashlytics 커스텀 로그
|
|
157
|
+
class CrashlyticsLogOutput extends LogOutput {
|
|
158
|
+
@override
|
|
159
|
+
void output(OutputEvent event) {
|
|
160
|
+
if (event.level.index < Level.info.index) return;
|
|
161
|
+
|
|
162
|
+
// Crashlytics log → 크래시 발생 시 컨텍스트로 포함 (최대 64KB)
|
|
163
|
+
FirebaseCrashlytics.instance.log(
|
|
164
|
+
event.lines.join('\n'),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 초기화 시 출력 추가
|
|
170
|
+
void initializeLogging() {
|
|
171
|
+
final log = AppLogger.instance;
|
|
172
|
+
log.initialize(isRelease: kReleaseMode);
|
|
173
|
+
|
|
174
|
+
if (kReleaseMode) {
|
|
175
|
+
log.addOutput(SentryLogOutput());
|
|
176
|
+
log.addOutput(CrashlyticsLogOutput());
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### 민감 데이터 마스킹
|
|
182
|
+
|
|
183
|
+
```dart
|
|
184
|
+
/// 민감 데이터 마스킹 유틸리티
|
|
185
|
+
class LogSanitizer {
|
|
186
|
+
static final _patterns = <RegExp, String>{
|
|
187
|
+
// 이메일
|
|
188
|
+
RegExp(r'[\w.+-]+@[\w-]+\.[\w.]+'):
|
|
189
|
+
'***@***.***',
|
|
190
|
+
// 전화번호
|
|
191
|
+
RegExp(r'\+?\d{10,15}'):
|
|
192
|
+
'***-****-****',
|
|
193
|
+
// 토큰/키 (Bearer, API key 등)
|
|
194
|
+
RegExp(r'(Bearer\s+|token[=:]\s*|api[_-]?key[=:]\s*)[A-Za-z0-9\-._~+/]+=*',
|
|
195
|
+
caseSensitive: false):
|
|
196
|
+
r'$1[REDACTED]',
|
|
197
|
+
// 카드 번호 (16자리)
|
|
198
|
+
RegExp(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b'):
|
|
199
|
+
'****-****-****-****',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/// 문자열 내 민감 데이터 마스킹
|
|
203
|
+
static String sanitize(String input) {
|
|
204
|
+
var result = input;
|
|
205
|
+
for (final entry in _patterns.entries) {
|
|
206
|
+
result = result.replaceAll(entry.key, entry.value);
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Map 내 민감 키 마스킹
|
|
212
|
+
static Map<String, dynamic> sanitizeMap(Map<String, dynamic> data) {
|
|
213
|
+
const sensitiveKeys = {
|
|
214
|
+
'password', 'token', 'secret', 'api_key', 'apiKey',
|
|
215
|
+
'authorization', 'credit_card', 'ssn', 'email', 'phone',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return data.map((key, value) {
|
|
219
|
+
if (sensitiveKeys.contains(key.toLowerCase())) {
|
|
220
|
+
return MapEntry(key, '[REDACTED]');
|
|
221
|
+
}
|
|
222
|
+
if (value is String) {
|
|
223
|
+
return MapEntry(key, sanitize(value));
|
|
224
|
+
}
|
|
225
|
+
if (value is Map<String, dynamic>) {
|
|
226
|
+
return MapEntry(key, sanitizeMap(value));
|
|
227
|
+
}
|
|
228
|
+
return MapEntry(key, value);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// HTTP 로깅에 마스킹 적용
|
|
234
|
+
class SanitizedLogInterceptor extends Interceptor {
|
|
235
|
+
@override
|
|
236
|
+
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
|
237
|
+
final sanitizedHeaders = LogSanitizer.sanitizeMap(
|
|
238
|
+
options.headers.cast<String, dynamic>(),
|
|
239
|
+
);
|
|
240
|
+
AppLogger.instance.debug(
|
|
241
|
+
'HTTP ${options.method} ${options.uri}\n'
|
|
242
|
+
'Headers: $sanitizedHeaders',
|
|
243
|
+
);
|
|
244
|
+
handler.next(options);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### 규칙
|
|
250
|
+
|
|
251
|
+
- `print`/`debugPrint` 직접 사용 금지 → `AppLogger` 래퍼 사용
|
|
252
|
+
- 릴리스 빌드 → verbose/debug 레벨 비활성화 (ProductionFilter)
|
|
253
|
+
- 프로덕션 → JSON 구조화 출력 (원격 로깅 시스템 호환)
|
|
254
|
+
- 원격 로깅 → Sentry breadcrumb + Crashlytics log 연동
|
|
255
|
+
- warning 이상 → 원격 전송, info → Crashlytics 컨텍스트만
|
|
256
|
+
- 민감 데이터 → `LogSanitizer`로 마스킹 (토큰, PII, 카드번호)
|
|
257
|
+
- HTTP 로깅 → 헤더/바디 마스킹 후 출력
|
|
258
|
+
- 로그 레벨 가이드: verbose(개발 추적), debug(디버깅), info(흐름), warning(주의), error(복구 가능), fatal(복구 불가)
|