/** * G7 DevTools Core * * 그누보드7 템플릿 엔진 디버깅 시스템의 핵심 싱글톤 클래스 * 상태 추적, 액션 로깅, 캐시 통계, 라이프사이클 모니터링 등을 담당합니다. * * 활성화 조건: 환경설정 > 고급 설정 > 디버그 모드 (debug_mode) */ import type { StateSnapshot, StateView, StateDiff, ActionLog, ActionMetrics, CacheStats, ComponentInfo, ListenerInfo, LifecycleInfo, MountEvent, UnmountEvent, PerformanceInfo, MemoryWarning, ProfileEntry, ProfileReport, RequestInfo, RequestLog, NetworkInfo, ConditionInfo, IterationInfo, ConditionalInfo, ScopeVariable, FormInfo, InputInfo, FormChange, WebSocketConnectionInfo, WebSocketMessage, WebSocketInfo, WatcherCallback, ActionWatcherCallback, RequestWatcherCallback, WebSocketWatcherCallback, FormWatcherCallback, ConditionWatcherCallback, DevToolsConfig, ExpressionEvalInfo, ExpressionWarning, ExpressionWarningType, ExpressionWatcherCallback, ExpressionStats, DataSourceInfo, HandlerInfo, ComponentEventSubscription, ComponentEventEmitLog, ComponentEventInfo, StateRenderingLog, RenderedComponentInfo, StateRenderingInfo, StateRenderingStats, StateChangeContext, StateHierarchyInfo, StateHierarchyLayer, StateConflict, ComponentStateSource, ContextFlowInfo, ContextFlowNode, StyleValidationInfo, StyleIssue, ComponentStyleInfo, AuthDebugInfo, AuthStateInfo, AuthEventLog, AuthHeaderAnalysis, AuthEventType, LogLevel, LogEntry, LogStats, LogFilterOptions, LogInfo, ParsedQueryParams, CurrentLayoutInfo, LayoutJsonData, LayoutHistoryEntry, LayoutDebugInfo, // 변경 감지 타입 HandlerExitReason, HandlerExecutionDetail, StateChangeRecord, DataSourceChangeRecord, ChangeExpectation, ChangeAlert, ChangeAlertType, ChangeDetectionInfo, ChangeDetectionStats, // Sequence 추적 타입 SequenceActionInfo, SequenceExecutionInfo, SequenceTrackingInfo, SequenceStats, // Stale Closure 추적 타입 StaleClosureWarning, StaleClosureWarningType, StaleClosureTrackingInfo, StaleClosureStats, // Form 바인딩 검증 타입 FormBindingIssue, FormBindingIssueType, FormBindingValidationInfo, FormBindingValidationStats, FormBindingValidationTrackingInfo, // Computed 의존성 추적 타입 ComputedDependencyType, ComputedRecalcTrigger, ComputedPropertyInfo, ComputedRecalcLog, ComputedDependencyChain, ComputedDependencyNode, ComputedDependencyStats, ComputedDependencyTrackingInfo, // 모달 상태 스코프 추적 타입 ModalStateScopeType, ModalStateIssueType, ModalStateInfo, ModalStateIssue, ModalStateRelation, ModalStateChangeLog, ModalStateScopeStats, ModalStateScopeTrackingInfo, // Named Actions 추적 타입 NamedActionRefLog, NamedActionTrackingInfo, } from './types'; import { parseQueryParams, extractPath } from './types'; /** * G7 DevTools 핵심 클래스 */ export class G7DevToolsCore { // ============================================ // 싱글톤 // ============================================ private static instance: G7DevToolsCore; static getInstance(): G7DevToolsCore { if (!G7DevToolsCore.instance) { G7DevToolsCore.instance = new G7DevToolsCore(); } return G7DevToolsCore.instance; } private constructor() { // private constructor for singleton } // ============================================ // 설정 // ============================================ private config: DevToolsConfig = { enabled: false, maxHistorySize: 100, logLevel: 'info', serverEndpoint: '/_boost/g7-debug/dump-state', autoCapture: true, }; // ============================================ // 상태 관리 // ============================================ private stateHistory: StateSnapshot[] = []; private snapshotIdCounter = 0; private stateWatchers: Map> = new Map(); // _local과 _computed 상태를 별도로 저장 private currentLocalState: Record = {}; private currentComputedState: Record = {}; // $parent 컨텍스트 저장 (모달/자식 레이아웃에서 부모 상태 접근용) private currentParentContext: Record | undefined = undefined; // ============================================ // 액션 추적 // ============================================ private actionHistory: ActionLog[] = []; private actionWatchers: Set = new Set(); // ============================================ // 캐시 통계 // ============================================ private cacheStats: CacheStats = { hits: 0, misses: 0, entries: 0 }; // ============================================ // 캐시 결정 추적 // ============================================ private cacheDecisions: import('./types').CacheDecisionInfo[] = []; private cacheDecisionIdCounter = 0; private readonly maxCacheDecisions = 200; private currentRenderCycleId: string | null = null; private isInActionExecution = false; private isInIteration = false; // ============================================ // 라이프사이클 추적 // ============================================ private mountedComponents: Map = new Map(); private eventListeners: Map = new Map(); private mountWatchers: Set<(info: MountEvent) => void> = new Set(); private unmountWatchers: Set<(info: UnmountEvent) => void> = new Set(); // ============================================ // 성능 추적 // ============================================ private renderCounts: Map = new Map(); private bindingEvalCount = 0; private profilingData: ProfileEntry[] = []; private isProfiling = false; private profilingStartTime = 0; // ============================================ // 네트워크 추적 // ============================================ private activeRequests: Map = new Map(); private requestHistory: RequestLog[] = []; private pendingDataSources: Set = new Set(); private requestWatchers: Set = new Set(); // ============================================ // WebSocket 추적 // ============================================ private wsConnections: Map = new Map(); private wsMessageHistory: WebSocketMessage[] = []; private wsMessageWatchers: Set = new Set(); // ============================================ // 조건부 렌더링 추적 // ============================================ private ifConditions: Map = new Map(); private iterations: Map = new Map(); private conditionWatchers: Set = new Set(); // ============================================ // Form 추적 // ============================================ private forms: Map = new Map(); private formWatchers: Set = new Set(); // ============================================ // 표현식 추적 // ============================================ private expressionHistory: ExpressionEvalInfo[] = []; private expressionWatchers: Set = new Set(); private expressionIdCounter = 0; // ============================================ // 데이터소스 추적 // ============================================ private dataSources: Map = new Map(); // ============================================ // 데이터 경로 변환 추적 // ============================================ private dataPathTransforms: import('./types').DataPathTransformInfo[] = []; private readonly maxDataPathTransforms = 100; // ============================================ // Nested Context 추적 // ============================================ private nestedContexts: import('./types').NestedContextInfo[] = []; private nestedContextIdCounter = 0; private readonly maxNestedContexts = 100; // ============================================ // Form 바인딩 검증 추적 // ============================================ private formBindingIssues: FormBindingIssue[] = []; private formBindingValidations: FormBindingValidationInfo[] = []; private formBindingIssueIdCounter = 0; private readonly maxFormBindingIssues = 100; // ============================================ // Computed 의존성 추적 // ============================================ private computedProperties: Map = new Map(); private computedRecalcLogs: ComputedRecalcLog[] = []; private computedRecalcIdCounter = 0; private readonly maxComputedRecalcLogs = 100; // ============================================ // 핸들러 추적 // ============================================ private handlers: Map = new Map(); // ============================================ // 컴포넌트 이벤트 추적 // ============================================ private componentEventSubscriptions: Map = new Map(); private componentEventEmitHistory: ComponentEventEmitLog[] = []; private componentEventIdCounter = 0; private readonly maxComponentEventHistory = 100; // ============================================ // 상태-렌더링 추적 // ============================================ private stateRenderingLogs: StateRenderingLog[] = []; private currentStateChangeContext: StateChangeContext | null = null; private stateRenderingIdCounter = 0; private componentRenderCounts: Map = new Map(); private stateToComponentMap: Map> = new Map(); private readonly maxStateRenderingLogs = 100; // ============================================ // 상태 계층 추적 // ============================================ private componentStateSources: Map = new Map(); private contextFlowNodes: Map = new Map(); private dynamicStates: Map> = new Map(); // ============================================ // CSS 스타일 검증 추적 // ============================================ private styleIssues: StyleIssue[] = []; private componentStyles: Map = new Map(); // ============================================ // 인증 디버깅 추적 // ============================================ private authEvents: AuthEventLog[] = []; private authHeaderHistory: AuthHeaderAnalysis[] = []; private authEventIdCounter = 0; private readonly maxAuthEventHistory = 50; // ============================================ // 로그 추적 // ============================================ private logHistory: LogEntry[] = []; private logIdCounter = 0; private readonly maxLogHistory = 500; // ============================================ // 레이아웃 추적 // ============================================ private currentLayout: CurrentLayoutInfo | null = null; private layoutHistory: LayoutHistoryEntry[] = []; private layoutIdCounter = 0; private layoutStats = { totalLoads: 0, cacheHits: 0, apiLoads: 0 }; private readonly maxLayoutHistory = 50; // ============================================ // 변경 감지 추적 // ============================================ private executionDetails: Map = new Map(); private stateChangeHistory: StateChangeRecord[] = []; private dataSourceChangeHistory: DataSourceChangeRecord[] = []; private changeAlerts: ChangeAlert[] = []; private changeExpectations: Map = new Map(); private executionIdCounter = 0; private stateChangeIdCounter = 0; private alertIdCounter = 0; private readonly maxExecutionDetails = 100; private readonly maxStateChangeHistory = 200; private readonly maxChangeAlerts = 100; // ============================================ // Sequence 실행 추적 // ============================================ private sequenceExecutions: SequenceExecutionInfo[] = []; /** 실행 중인 sequence 스택 (중첩 sequence 지원) */ private sequenceExecutionStack: SequenceExecutionInfo[] = []; private sequenceIdCounter = 0; private readonly maxSequenceExecutions = 50; // ============================================ // Stale Closure 감지 추적 // ============================================ private staleClosureWarnings: StaleClosureWarning[] = []; private staleClosureIdCounter = 0; private readonly maxStaleClosureWarnings = 100; /** 상태 캡처 시점 추적 (handlerId → { path: captureTime }) */ private stateCaptureRegistry: Map> = new Map(); // ============================================ // 모달 상태 스코프 추적 // ============================================ private modalStates: Map = new Map(); private modalStateIssues: ModalStateIssue[] = []; private modalStateRelations: ModalStateRelation[] = []; private modalStateChangeLogs: ModalStateChangeLog[] = []; private modalStateIssueIdCounter = 0; private modalStateChangeLogIdCounter = 0; private readonly maxModalStateIssues = 100; private readonly maxModalStateChangeLogs = 200; // ============================================ // Named Actions 추적 // ============================================ /** 현재 레이아웃의 named_actions 정의 */ private namedActionDefinitions: Record = {}; /** actionRef 해석 이력 */ private namedActionRefLogs: NamedActionRefLog[] = []; private namedActionRefIdCounter = 0; private readonly maxNamedActionRefLogs = 200; // ============================================ // 초기화 및 활성화 // ============================================ /** * DevTools 초기화 * settings 로드 완료 후 호출해야 합니다. */ initialize(): void { this.config.enabled = this.checkDebugMode(); if (!this.config.enabled) { console.log('[G7DevTools] 비활성화됨 (환경설정 > 고급 설정 > 디버그 모드를 켜세요)'); return; } console.log('[G7DevTools] 활성화됨'); this.setupGlobalErrorHandler(); } /** * 디버그 모드 확인 * * 다음 순서로 디버그 모드를 확인합니다: * 1. G7Config.debug (initTemplateApp의 debug 옵션) * 2. _global.settings.advanced.debug_mode (서버 환경설정) */ private checkDebugMode(): boolean { try { // 1. initTemplateApp의 debug 옵션 확인 (G7Config.debug) const g7Config = (window as any).G7Config; if (g7Config?.debug === true) { return true; } // 2. 서버 환경설정 확인 (settings.advanced.debug_mode) const g7Core = (window as any).G7Core; const state = g7Core?.state?.get?.(); if (state?.settings?.advanced?.debug_mode === true) { return true; } // 3. _global.settings.advanced.debug_mode 확인 if (state?._global?.settings?.advanced?.debug_mode === true) { return true; } return false; } catch { return false; } } /** * DevTools 활성화 상태 확인 */ isEnabled(): boolean { return this.config.enabled; } /** * 전역 에러 핸들러 설정 */ private setupGlobalErrorHandler(): void { window.addEventListener('error', (event) => { if (!this.config.enabled) return; this.logAction({ id: this.generateId(), type: 'globalError', startTime: Date.now(), status: 'error', error: { name: 'Error', message: event.message, stack: event.error?.stack, }, }); }); window.addEventListener('unhandledrejection', (event) => { if (!this.config.enabled) return; this.logAction({ id: this.generateId(), type: 'unhandledRejection', startTime: Date.now(), status: 'error', error: { name: 'UnhandledRejection', message: String(event.reason), }, }); }); } // ============================================ // 상태 관리 메서드 // ============================================ /** * 상태 스냅샷 캡처 */ captureStateSnapshot(data: { source: string; prev: Record; next: Record; diff?: StateDiff; }): void { if (!this.config.enabled) return; const snapshot: StateSnapshot = { id: ++this.snapshotIdCounter, timestamp: Date.now(), source: data.source, prev: this.sanitizeObject(data.prev), next: this.sanitizeObject(data.next), diff: data.diff, }; this.stateHistory.push(snapshot); // 이력 크기 제한 if (this.stateHistory.length > this.config.maxHistorySize) { this.stateHistory.shift(); } // 감시자 알림 this.notifyStateWatchers(snapshot); } /** * 현재 상태 조회 * * _global: G7Core.state.get()에서 가져옴 * _local: DynamicRenderer에서 updateLocalState()로 등록된 상태 * _computed: DynamicRenderer에서 updateComputedState()로 등록된 상태 * _isolated: IsolatedStateProvider에서 등록된 격리 상태 (G7Core._isolatedStates) * $parent: 모달/자식 레이아웃에서 부모 상태 접근용 컨텍스트 */ getState(): StateView { try { const g7Core = (window as any).G7Core; const state = g7Core?.state?.get?.() || {}; // _isolated 상태 수집 (IsolatedStateContext에서 등록) const isolated: Record> = {}; if (g7Core?._isolatedStates && typeof g7Core._isolatedStates === 'object') { for (const [scopeId, scopeState] of Object.entries(g7Core._isolatedStates)) { isolated[scopeId] = this.sanitizeObject(scopeState as Record); } } return { _global: state._global || state, _local: this.currentLocalState, _computed: this.currentComputedState, _isolated: Object.keys(isolated).length > 0 ? isolated : undefined, $parent: this.currentParentContext, }; } catch { return { _global: {}, _local: this.currentLocalState, _computed: this.currentComputedState }; } } /** * _local 상태 업데이트 * * DynamicRenderer에서 localDynamicState가 변경될 때 호출됩니다. */ updateLocalState(localState: Record): void { if (!this.config.enabled) return; this.currentLocalState = this.sanitizeObject(localState); } /** * _computed 상태 업데이트 * * DynamicRenderer에서 _computed 컨텍스트가 변경될 때 호출됩니다. */ updateComputedState(computedState: Record): void { if (!this.config.enabled) return; this.currentComputedState = this.sanitizeObject(computedState); } /** * $parent 컨텍스트 업데이트 * * DynamicRenderer에서 모달/자식 레이아웃 렌더링 시 부모 데이터 컨텍스트를 전달합니다. * 이를 통해 DevTools에서 {{$parent._local.xxx}} 형태의 바인딩 디버깅이 가능합니다. * * @param parentContext 부모 데이터 컨텍스트 (undefined이면 $parent 없음) */ updateParentContext(parentContext: Record | undefined): void { if (!this.config.enabled) return; this.currentParentContext = parentContext ? this.sanitizeObject(parentContext) : undefined; } /** * 상태 이력 조회 */ getStateHistory(): StateSnapshot[] { return [...this.stateHistory]; } /** * 저장소 A(React localDynamicState) / B(globalState._local) 불일치 감지 * * 이중 저장소 구조(engine-v1.43.0+)의 보조 안전망. * 엔진의 자동 동기화(DynamicRenderer performStateUpdate + setLocal 자동 승격)가 기본 방어책이지만, * 새로운 쓰기 경로가 추가되면서 한쪽 저장소만 갱신하는 실수를 조기 발견하기 위한 진단 도구. * * 저장소 A = `updateLocalState()` 로 전달된 localDynamicState * 저장소 B = `G7Core.state.get()._local` = globalState._local * * 불일치 leaf 경로를 반환 (hasMismatch=true면 구조적 동기화가 깨진 상태). * * @returns 불일치 상태 + 두 저장소 스냅샷 */ getDualStorageMismatch(): { hasMismatch: boolean; mismatchedPaths: string[]; storageA: Record; storageB: Record; } { const storageA = this.currentLocalState || {}; let storageB: Record = {}; try { const g7Core = (window as any).G7Core; const globalState = g7Core?.state?.get?.() || {}; storageB = globalState._local || {}; } catch { storageB = {}; } const mismatchedPaths = this.findMismatchedLeafPaths(storageA, storageB); return { hasMismatch: mismatchedPaths.length > 0, mismatchedPaths, storageA, storageB, }; } /** * 두 객체의 리프 경로를 비교해 값이 다른 경로 목록 반환 (private helper) * * `getDualStorageMismatch` 전용. 배열은 리프로 취급하되 참조 비교가 아니라 * JSON 문자열화 비교로 값 동등성 판정 (deep equal 수준 빠른 근사). * * @param a 비교 대상 A * @param b 비교 대상 B * @param prefix 재귀 prefix * @returns 불일치 경로 배열 */ private findMismatchedLeafPaths( a: Record, b: Record, prefix = '', ): string[] { const paths: string[] = []; const keys = new Set([ ...Object.keys(a || {}), ...Object.keys(b || {}), ]); for (const key of keys) { const fullKey = prefix ? `${prefix}.${key}` : key; const va = a?.[key]; const vb = b?.[key]; const aIsObj = va && typeof va === 'object' && !Array.isArray(va); const bIsObj = vb && typeof vb === 'object' && !Array.isArray(vb); if (aIsObj && bIsObj) { paths.push(...this.findMismatchedLeafPaths(va, vb, fullKey)); } else { const sameA = va === undefined ? '__undefined__' : JSON.stringify(va); const sameB = vb === undefined ? '__undefined__' : JSON.stringify(vb); if (sameA !== sameB) { paths.push(fullKey); } } } return paths; } /** * 상태 감시 등록 */ watchState(path: string, callback: WatcherCallback): () => void { if (!this.stateWatchers.has(path)) { this.stateWatchers.set(path, new Set()); } this.stateWatchers.get(path)!.add(callback); return () => { this.stateWatchers.get(path)?.delete(callback); }; } /** * 상태 감시자들에게 알림 */ private notifyStateWatchers(snapshot: StateSnapshot): void { // '*' 감시자 this.stateWatchers.get('*')?.forEach((cb) => { try { cb(snapshot.next, snapshot.prev, '*'); } catch (e) { console.error('[G7DevTools] State watcher error:', e); } }); // 특정 경로 감시자 if (snapshot.diff) { for (const change of snapshot.diff.changed) { this.stateWatchers.get(change.path)?.forEach((cb) => { try { cb(change.newValue, change.oldValue, change.path); } catch (e) { console.error('[G7DevTools] State watcher error:', e); } }); } } } /** * 두 스냅샷 간 차이 계산 */ diffSnapshots(snapshot1Id: number, snapshot2Id: number): StateDiff | null { const s1 = this.stateHistory.find((s) => s.id === snapshot1Id); const s2 = this.stateHistory.find((s) => s.id === snapshot2Id); if (!s1 || !s2) return null; return this.calculateDiff(s1.next, s2.next); } /** * 상태 차이 계산 */ private calculateDiff( prev: Record, next: Record, prefix = '' ): StateDiff { const diff: StateDiff = { added: [], removed: [], changed: [] }; const prevKeys = new Set(Object.keys(prev)); const nextKeys = new Set(Object.keys(next)); for (const key of nextKeys) { const path = prefix ? `${prefix}.${key}` : key; if (!prevKeys.has(key)) { diff.added.push(path); } else if (JSON.stringify(prev[key]) !== JSON.stringify(next[key])) { diff.changed.push({ path, oldValue: prev[key], newValue: next[key], }); } } for (const key of prevKeys) { const path = prefix ? `${prefix}.${key}` : key; if (!nextKeys.has(key)) { diff.removed.push(path); } } return diff; } // ============================================ // 액션 추적 메서드 // ============================================ /** * 액션 로그 기록 */ logAction(action: ActionLog): void { if (!this.config.enabled) return; // 기존 액션 업데이트 또는 새 액션 추가 const existingIndex = this.actionHistory.findIndex((a) => a.id === action.id); if (existingIndex >= 0) { this.actionHistory[existingIndex] = { ...this.actionHistory[existingIndex], ...action, }; } else { this.actionHistory.push(action); // 이력 크기 제한 if (this.actionHistory.length > this.config.maxHistorySize) { this.actionHistory.shift(); } } // 프로파일링 중이면 기록 if (this.isProfiling) { this.profilingData.push({ type: 'action', action: action.type, timestamp: performance.now(), duration: action.duration, }); } // 감시자 알림 this.notifyActionWatchers(action); } /** * 액션 이력 조회 */ getActionHistory(): ActionLog[] { return [...this.actionHistory]; } /** * 액션 감시 등록 */ watchActions(callback: ActionWatcherCallback): () => void { this.actionWatchers.add(callback); return () => { this.actionWatchers.delete(callback); }; } /** * 액션 감시자들에게 알림 */ private notifyActionWatchers(action: ActionLog): void { this.actionWatchers.forEach((cb) => { try { cb(action); } catch (e) { console.error('[G7DevTools] Action watcher error:', e); } }); } /** * 액션 메트릭 조회 */ getActionMetrics(): ActionMetrics { const successActions = this.actionHistory.filter((a) => a.status === 'success'); const errorActions = this.actionHistory.filter((a) => a.status === 'error'); const actionsByType: Record = {}; for (const action of this.actionHistory) { actionsByType[action.type] = (actionsByType[action.type] || 0) + 1; } const totalDuration = successActions.reduce((sum, a) => sum + (a.duration || 0), 0); return { totalActions: this.actionHistory.length, successCount: successActions.length, errorCount: errorActions.length, averageDuration: successActions.length > 0 ? totalDuration / successActions.length : 0, actionsByType, }; } // ============================================ // 캐시 통계 메서드 // ============================================ /** * 캐시 히트 기록 */ recordCacheHit(): void { if (!this.config.enabled) return; this.cacheStats.hits++; } /** * 캐시 미스 기록 */ recordCacheMiss(): void { if (!this.config.enabled) return; this.cacheStats.misses++; } /** * 캐시 엔트리 수 업데이트 */ updateCacheEntries(count: number): void { if (!this.config.enabled) return; this.cacheStats.entries = count; } /** * 캐시 통계 조회 */ getCacheStats(): CacheStats { const total = this.cacheStats.hits + this.cacheStats.misses; return { ...this.cacheStats, hitRate: total > 0 ? this.cacheStats.hits / total : 0, }; } /** * 캐시 통계 초기화 */ resetCacheStats(): void { this.cacheStats = { hits: 0, misses: 0, entries: 0 }; } // ============================================ // 캐시 결정 추적 메서드 // ============================================ /** * 캐시 결정 추적 * * 표현식 평가 시 캐시 사용/스킵 결정 과정을 기록합니다. */ trackCacheDecision(info: { expression: string; decision: import('./types').CacheDecisionType; reason: string; componentId?: string; skipCacheOption?: boolean; cachedValue?: any; freshValue?: any; duration?: number; }): void { if (!this.config.enabled) return; const decision: import('./types').CacheDecisionInfo = { id: `cache-${++this.cacheDecisionIdCounter}`, timestamp: Date.now(), expression: info.expression, decision: info.decision, reason: info.reason, context: { isInIteration: this.isInIteration, isInAction: this.isInActionExecution, renderCycleId: this.currentRenderCycleId || undefined, componentId: info.componentId, skipCacheOption: info.skipCacheOption, }, cachedValue: info.cachedValue !== undefined ? this.safeClone(info.cachedValue) : undefined, freshValue: info.freshValue !== undefined ? this.safeClone(info.freshValue) : undefined, valueMatch: info.cachedValue !== undefined && info.freshValue !== undefined ? JSON.stringify(info.cachedValue) === JSON.stringify(info.freshValue) : undefined, duration: info.duration, }; this.cacheDecisions.push(decision); // 최대 크기 제한 if (this.cacheDecisions.length > this.maxCacheDecisions) { this.cacheDecisions.shift(); } // 캐시 통계도 업데이트 if (info.decision === 'cache_hit') { this.cacheStats.hits++; } else if (info.decision === 'cache_miss' || info.decision === 'skip_cache') { this.cacheStats.misses++; } } /** * 렌더 사이클 시작 표시 */ startRenderCycle(componentId?: string): string { this.currentRenderCycleId = `render-${Date.now()}-${componentId || 'root'}`; return this.currentRenderCycleId; } /** * 렌더 사이클 종료 표시 */ endRenderCycle(): void { this.currentRenderCycleId = null; } /** * 액션 실행 컨텍스트 시작 */ startActionContext(): void { this.isInActionExecution = true; } /** * 액션 실행 컨텍스트 종료 */ endActionContext(): void { this.isInActionExecution = false; } /** * iteration 컨텍스트 시작 */ startIterationContext(): void { this.isInIteration = true; } /** * iteration 컨텍스트 종료 */ endIterationContext(): void { this.isInIteration = false; } /** * 캐시 결정 추적 정보 반환 */ getCacheDecisionTrackingInfo(): import('./types').CacheDecisionTrackingInfo { const stats = this.getCacheDecisionStats(); return { decisions: [...this.cacheDecisions], stats, timestamp: Date.now(), }; } /** * 캐시 결정 통계 계산 */ private getCacheDecisionStats(): import('./types').CacheDecisionStats { const decisions = this.cacheDecisions; // 결정 유형별 개수 let cacheHits = 0; let cacheMisses = 0; let skipCacheCount = 0; let invalidateCount = 0; const byReason: Record = {}; const byComponent: Record = {}; for (const d of decisions) { if (d.decision === 'cache_hit') cacheHits++; else if (d.decision === 'cache_miss') cacheMisses++; else if (d.decision === 'skip_cache') skipCacheCount++; else if (d.decision === 'invalidate') invalidateCount++; byReason[d.reason] = (byReason[d.reason] || 0) + 1; if (d.context.componentId) { byComponent[d.context.componentId] = (byComponent[d.context.componentId] || 0) + 1; } } const total = cacheHits + cacheMisses + skipCacheCount; return { totalDecisions: decisions.length, cacheHits, cacheMisses, skipCacheCount, invalidateCount, byReason, byComponent, avgHitRate: total > 0 ? cacheHits / total : 0, }; } /** * 최근 캐시 결정 반환 */ getRecentCacheDecisions(limit = 20, decision?: import('./types').CacheDecisionType): import('./types').CacheDecisionInfo[] { let filtered = [...this.cacheDecisions]; if (decision) { filtered = filtered.filter(d => d.decision === decision); } return filtered.slice(-limit); } /** * 캐시 결정 데이터 초기화 */ clearCacheDecisionData(): void { this.cacheDecisions = []; this.cacheDecisionIdCounter = 0; } // ============================================ // 라이프사이클 추적 메서드 // ============================================ /** * 라이프사이클 추적용 props 간소화 * * 덤프 파일 크기를 줄이기 위해 큰 props는 요약만 저장합니다. */ private summarizePropsForLifecycle(props: any): any { if (!props || typeof props !== 'object') return props; const result: Record = {}; const skipKeys = ['children', 'ref', 'key']; for (const [key, value] of Object.entries(props)) { if (skipKeys.includes(key)) continue; if (value === null || value === undefined) { result[key] = value; } else if (Array.isArray(value)) { // 배열: 길이만 표시 result[key] = `[Array(${value.length})]`; } else if (typeof value === 'object') { // 객체: 키 목록만 표시 const keys = Object.keys(value); if (keys.length <= 3) { result[key] = `{${keys.join(', ')}}`; } else { result[key] = `{${keys.slice(0, 3).join(', ')}, ... +${keys.length - 3}}`; } } else if (typeof value === 'string' && value.length > 50) { // 긴 문자열: 축약 result[key] = value.substring(0, 50) + '...'; } else if (typeof value === 'function') { result[key] = '[Function]'; } else { result[key] = value; } } return result; } /** * 컴포넌트 마운트 추적 */ trackMount(componentId: string, info: Omit): void { if (!this.config.enabled) return; // props 간소화: 큰 객체/배열은 요약만 저장 const summarizedProps = this.summarizePropsForLifecycle(info.props); const componentInfo: ComponentInfo = { ...info, props: summarizedProps, id: componentId, mountTime: Date.now(), }; this.mountedComponents.set(componentId, componentInfo); // 감시자 알림 this.mountWatchers.forEach((cb) => { try { cb({ componentId, componentName: info.name, timestamp: componentInfo.mountTime, }); } catch (e) { console.error('[G7DevTools] Mount watcher error:', e); } }); } /** * 컴포넌트 언마운트 추적 */ trackUnmount(componentId: string): void { if (!this.config.enabled) return; const component = this.mountedComponents.get(componentId); const listeners = this.eventListeners.get(componentId) || []; if (listeners.length > 0) { console.warn( `[G7DevTools] 컴포넌트 ${componentId} 언마운트 시 ${listeners.length}개 리스너 미정리` ); } // 감시자 알림 if (component) { this.unmountWatchers.forEach((cb) => { try { cb({ componentId, componentName: component.name, timestamp: Date.now(), orphanedListeners: listeners.length, }); } catch (e) { console.error('[G7DevTools] Unmount watcher error:', e); } }); } this.mountedComponents.delete(componentId); } /** * 이벤트 리스너 추적 */ trackListener(componentId: string, type: string, target: string): void { if (!this.config.enabled) return; const listeners = this.eventListeners.get(componentId) || []; listeners.push({ type, target, addedAt: Date.now() }); this.eventListeners.set(componentId, listeners); } /** * 이벤트 리스너 제거 추적 */ removeListener(componentId: string, type: string): void { if (!this.config.enabled) return; const listeners = this.eventListeners.get(componentId) || []; const idx = listeners.findIndex((l) => l.type === type); if (idx >= 0) listeners.splice(idx, 1); } /** * 마운트된 컴포넌트 조회 */ getMountedComponents(): ComponentInfo[] { return Array.from(this.mountedComponents.values()); } /** * 정리되지 않은 리스너 조회 */ getOrphanedListeners(): ListenerInfo[] { const orphaned: ListenerInfo[] = []; for (const [compId, listeners] of this.eventListeners) { if (!this.mountedComponents.has(compId)) { orphaned.push(...listeners.map((l) => ({ ...l, componentId: compId }))); } } return orphaned; } /** * 마운트 감시 등록 */ watchMount(callback: (info: MountEvent) => void): () => void { this.mountWatchers.add(callback); return () => { this.mountWatchers.delete(callback); }; } /** * 언마운트 감시 등록 */ watchUnmount(callback: (info: UnmountEvent) => void): () => void { this.unmountWatchers.add(callback); return () => { this.unmountWatchers.delete(callback); }; } // ============================================ // 성능 추적 메서드 // ============================================ /** * 렌더링 추적 */ trackRender(componentName: string): void { if (!this.config.enabled) return; const count = this.renderCounts.get(componentName) || 0; this.renderCounts.set(componentName, count + 1); if (this.isProfiling) { this.profilingData.push({ type: 'render', component: componentName, timestamp: performance.now(), }); } } /** * 바인딩 평가 추적 */ trackBindingEval(expression?: string): void { if (!this.config.enabled) return; this.bindingEvalCount++; if (this.isProfiling && expression) { this.profilingData.push({ type: 'binding', expression, timestamp: performance.now(), }); } } /** * 렌더링 횟수 조회 */ getRenderCount(): Map { return new Map(this.renderCounts); } /** * 바인딩 평가 횟수 조회 */ getBindingEvalCount(): number { return this.bindingEvalCount; } /** * 메모리 경고 조회 */ getMemoryWarnings(): MemoryWarning[] { const warnings: MemoryWarning[] = []; // 큰 상태 객체 감지 try { const state = this.getState(); const stateSize = JSON.stringify(state).length; if (stateSize > 1000000) { // 1MB warnings.push({ type: 'large-state', message: `상태 크기가 ${(stateSize / 1024 / 1024).toFixed(2)}MB입니다`, suggestion: '불필요한 데이터 정리 필요', severity: stateSize > 5000000 ? 'error' : 'warning', }); } } catch { // 순환 참조 등으로 직렬화 실패 시 무시 } // 이력 크기 감지 if (this.stateHistory.length > this.config.maxHistorySize * 0.8) { warnings.push({ type: 'large-history', message: `상태 이력이 ${this.stateHistory.length}개입니다`, suggestion: 'maxHistory 설정 조정 필요', severity: 'warning', }); } // 정리되지 않은 리스너 감지 const orphanedCount = this.getOrphanedListeners().length; if (orphanedCount > 0) { warnings.push({ type: 'orphaned-listeners', message: `${orphanedCount}개의 정리되지 않은 이벤트 리스너`, suggestion: 'useEffect cleanup 또는 removeEventListener 확인', severity: orphanedCount > 10 ? 'error' : 'warning', }); } // 과도한 렌더링 감지 const excessiveRenders = Array.from(this.renderCounts.entries()).filter( ([, count]) => count > 50 ); if (excessiveRenders.length > 0) { warnings.push({ type: 'excessive-renders', message: `${excessiveRenders.length}개 컴포넌트가 50회 이상 렌더링`, suggestion: 'React.memo, useMemo, useCallback 사용 고려', severity: 'warning', }); } return warnings; } /** * 프로파일링 시작 */ startProfiling(): void { if (!this.config.enabled) return; this.isProfiling = true; this.profilingData = []; this.profilingStartTime = performance.now(); console.log('[G7DevTools] 프로파일링 시작'); } /** * 프로파일링 종료 */ stopProfiling(): ProfileReport { this.isProfiling = false; const duration = performance.now() - this.profilingStartTime; const report = this.analyzeProfile(duration); console.log('[G7DevTools] 프로파일링 완료', report); return report; } /** * 프로파일 분석 */ private analyzeProfile(duration: number): ProfileReport { const renders = this.profilingData.filter((e) => e.type === 'render'); const bindings = this.profilingData.filter((e) => e.type === 'binding'); const actions = this.profilingData.filter((e) => e.type === 'action'); // 컴포넌트별 렌더링 집계 const componentRenders: Record = {}; for (const entry of renders) { if (!entry.component) continue; if (!componentRenders[entry.component]) { componentRenders[entry.component] = { count: 0, durations: [] }; } componentRenders[entry.component].count++; if (entry.duration) { componentRenders[entry.component].durations.push(entry.duration); } } const slowestComponents = Object.entries(componentRenders) .map(([name, data]) => ({ name, renderCount: data.count, avgDuration: data.durations.length > 0 ? data.durations.reduce((a, b) => a + b, 0) / data.durations.length : 0, })) .sort((a, b) => b.renderCount - a.renderCount) .slice(0, 10); // 핫 패스 (자주 평가되는 바인딩) const bindingCounts: Record = {}; for (const entry of bindings) { if (!entry.expression) continue; bindingCounts[entry.expression] = (bindingCounts[entry.expression] || 0) + 1; } const hotPaths = Object.entries(bindingCounts) .sort(([, a], [, b]) => b - a) .slice(0, 10) .map(([path]) => path); return { duration, entries: this.profilingData, summary: { totalRenders: renders.length, totalBindings: bindings.length, totalActions: actions.length, slowestComponents, hotPaths, }, }; } /** * 성능 통계 초기화 */ resetPerformanceStats(): void { this.renderCounts.clear(); this.bindingEvalCount = 0; this.profilingData = []; } // ============================================ // 네트워크 추적 메서드 // ============================================ /** * 요청 시작 추적 * * @param url 전체 URL (쿼리스트링 포함) * @param method HTTP 메서드 * @param options 추가 옵션 (requestBody, dataSourceId) * @returns 요청 추적 ID */ trackRequest( url: string, method: string, options?: { requestBody?: any; dataSourceId?: string; } ): string { if (!this.config.enabled) return ''; const requestId = this.generateId(); // URL에서 경로와 쿼리 파라미터 분리 const urlPath = extractPath(url); const queryParams = parseQueryParams(url); this.activeRequests.set(requestId, { id: requestId, url: urlPath, fullUrl: url, queryParams, method, startTime: Date.now(), status: 'pending', requestBody: options?.requestBody ? this.sanitizeObject(options.requestBody) : undefined, dataSourceId: options?.dataSourceId, }); if (this.isProfiling) { this.profilingData.push({ type: 'network', timestamp: performance.now(), }); } return requestId; } /** * 요청 완료 추적 */ completeRequest(requestId: string, statusCode: number, response?: any): void { if (!this.config.enabled) return; const req = this.activeRequests.get(requestId); if (!req) return; const log: RequestLog = { ...req, status: statusCode >= 200 && statusCode < 300 ? 'success' : 'error', statusCode, duration: Date.now() - req.startTime, endTime: Date.now(), response: this.sanitizeObject(response), }; this.requestHistory.push(log); this.activeRequests.delete(requestId); // 이력 크기 제한 if (this.requestHistory.length > this.config.maxHistorySize) { this.requestHistory.shift(); } this.notifyRequestWatchers(log); } /** * 요청 실패 추적 */ failRequest(requestId: string, error: string): void { if (!this.config.enabled) return; const req = this.activeRequests.get(requestId); if (!req) return; const log: RequestLog = { ...req, status: 'error', error, duration: Date.now() - req.startTime, endTime: Date.now(), }; this.requestHistory.push(log); this.activeRequests.delete(requestId); this.notifyRequestWatchers(log); } /** * 데이터소스 로딩 시작 추적 */ trackDataSource(name: string): void { if (!this.config.enabled) return; this.pendingDataSources.add(name); } /** * 데이터소스 로딩 완료 추적 */ completeDataSource(name: string): void { if (!this.config.enabled) return; this.pendingDataSources.delete(name); } /** * 활성 요청 조회 */ getActiveRequests(): RequestInfo[] { return Array.from(this.activeRequests.values()); } /** * 요청 이력 조회 */ getRequestHistory(): RequestLog[] { return [...this.requestHistory]; } /** * 대기 중인 데이터소스 조회 */ getPendingDataSources(): string[] { return Array.from(this.pendingDataSources); } /** * 요청 감시 등록 */ watchRequest(callback: RequestWatcherCallback): () => void { this.requestWatchers.add(callback); return () => { this.requestWatchers.delete(callback); }; } /** * 요청 감시자들에게 알림 */ private notifyRequestWatchers(request: RequestLog): void { this.requestWatchers.forEach((cb) => { try { cb(request); } catch (e) { console.error('[G7DevTools] Request watcher error:', e); } }); } // ============================================ // WebSocket 추적 메서드 // ============================================ /** * WebSocket 연결 추적 */ trackWebSocketConnection(id: string, url: string, state: WebSocketConnectionInfo['state']): void { if (!this.config.enabled) return; this.wsConnections.set(id, { id, url, state, connectedAt: state === 'open' ? Date.now() : undefined, }); } /** * WebSocket 메시지 추적 */ trackWebSocketMessage( connectionId: string, direction: 'sent' | 'received', type: string, payload: any ): void { if (!this.config.enabled) return; const message: WebSocketMessage = { id: this.generateId(), connectionId, direction, type, payload: this.sanitizeObject(payload), timestamp: Date.now(), sequence: this.wsMessageHistory.length, }; this.wsMessageHistory.push(message); // 이력 크기 제한 if (this.wsMessageHistory.length > this.config.maxHistorySize) { this.wsMessageHistory.shift(); } this.notifyWebSocketWatchers(message); } /** * WebSocket 연결 정보 조회 */ getWebSocketConnections(): WebSocketConnectionInfo[] { return Array.from(this.wsConnections.values()); } /** * WebSocket 메시지 이력 조회 */ getWebSocketMessageHistory(): WebSocketMessage[] { return [...this.wsMessageHistory]; } /** * WebSocket 연결 상태 조회 */ getWebSocketConnectionState(): 'connected' | 'disconnected' | 'reconnecting' { const connections = Array.from(this.wsConnections.values()); if (connections.some((c) => c.state === 'open')) return 'connected'; if (connections.some((c) => c.state === 'connecting')) return 'reconnecting'; return 'disconnected'; } /** * WebSocket 메시지 감시 등록 */ watchWebSocketMessage(callback: WebSocketWatcherCallback): () => void { this.wsMessageWatchers.add(callback); return () => { this.wsMessageWatchers.delete(callback); }; } /** * WebSocket 감시자들에게 알림 */ private notifyWebSocketWatchers(message: WebSocketMessage): void { this.wsMessageWatchers.forEach((cb) => { try { cb(message); } catch (e) { console.error('[G7DevTools] WebSocket watcher error:', e); } }); } // ============================================ // 조건부 렌더링 추적 메서드 // ============================================ /** * if 조건 추적 */ trackIfCondition(id: string, expression: string, value: boolean): void { if (!this.config.enabled) return; const existing = this.ifConditions.get(id); const prevValue = existing?.evaluatedValue; this.ifConditions.set(id, { id, expression, evaluatedValue: value, evaluationCount: (existing?.evaluationCount || 0) + 1, lastEvaluated: Date.now(), }); // 값이 변경되면 감시자 알림 if (existing && prevValue !== value) { this.notifyConditionWatchers({ id, expression, oldValue: prevValue!, newValue: value, timestamp: Date.now(), }); } } /** * iteration 추적 */ trackIteration( id: string, info: { source: string; itemVar: string; indexVar?: string; sourceLength: number; } ): void { if (!this.config.enabled) return; this.iterations.set(id, { id, ...info, lastRendered: Date.now(), }); } /** * if 조건들 조회 */ getIfConditions(): ConditionInfo[] { return Array.from(this.ifConditions.values()); } /** * iteration들 조회 */ getIterations(): IterationInfo[] { return Array.from(this.iterations.values()); } /** * 스코프 체인 조회 */ getScopeChain(componentId: string): ScopeVariable[] { const chain: ScopeVariable[] = []; // _global const state = this.getState(); chain.push({ name: '_global', value: state._global, source: 'global' }); chain.push({ name: '_local', value: state._local, source: 'local' }); chain.push({ name: '_computed', value: state._computed, source: 'computed' }); // iteration 변수는 컴포넌트별로 추적 필요 (추후 확장) return chain; } /** * 조건 변경 감시 등록 */ watchConditionChange(callback: ConditionWatcherCallback): () => void { this.conditionWatchers.add(callback); return () => { this.conditionWatchers.delete(callback); }; } /** * 조건 감시자들에게 알림 */ private notifyConditionWatchers(change: import('./types').ConditionChange): void { this.conditionWatchers.forEach((cb) => { try { cb(change); } catch (e) { console.error('[G7DevTools] Condition watcher error:', e); } }); } // ============================================ // Form 추적 메서드 // ============================================ /** * Form 추적 */ trackForm(id: string, dataKey: string, inputs: InputInfo[]): void { if (!this.config.enabled) return; this.forms.set(id, { id, dataKey, inputs, trackedAt: Date.now(), }); } /** * Form 변경 추적 */ trackFormChange(formId: string, inputName: string, value: any): void { if (!this.config.enabled) return; const change: FormChange = { formId, inputName, value, timestamp: Date.now(), }; this.notifyFormWatchers(change); } /** * Form 제거 */ untrackForm(id: string): void { this.forms.delete(id); } /** * Form들 조회 */ getForms(): FormInfo[] { return Array.from(this.forms.values()); } /** * 특정 Form 상태 조회 */ getFormState(dataKey: string): Record { const state = this.getState(); return this.getNestedValue(state, dataKey) || {}; } /** * Input 바인딩 경로 조회 */ getBindingPath(formDataKey: string, inputName: string): string { return `${formDataKey}.${inputName}`; } /** * Form 변경 감시 등록 */ watchFormChange(callback: FormWatcherCallback): () => void { this.formWatchers.add(callback); return () => { this.formWatchers.delete(callback); }; } /** * Form 감시자들에게 알림 */ private notifyFormWatchers(change: FormChange): void { this.formWatchers.forEach((cb) => { try { cb(change); } catch (e) { console.error('[G7DevTools] Form watcher error:', e); } }); } // ============================================ // Form 바인딩 검증 메서드 // ============================================ /** * Form 바인딩 이슈 추적 */ trackFormBindingIssue( type: FormBindingIssueType, severity: 'info' | 'warning' | 'error', details: { formId?: string; formDataKey?: string; inputInfo?: { name?: string; type?: string; componentId?: string; }; contextInfo?: { hasParentFormContext: boolean; parentFormContextProp?: string; isInsideSortable: boolean; isInsideModal: boolean; depth: number; }; description: string; suggestion: string; docLink?: string; } ): void { if (!this.config.enabled) return; const issue: FormBindingIssue = { id: `form-issue-${++this.formBindingIssueIdCounter}`, timestamp: Date.now(), type, severity, formId: details.formId, formDataKey: details.formDataKey, inputInfo: details.inputInfo, contextInfo: details.contextInfo, description: details.description, suggestion: details.suggestion, docLink: details.docLink, }; this.formBindingIssues.push(issue); // 이력 크기 제한 if (this.formBindingIssues.length > this.maxFormBindingIssues) { this.formBindingIssues.shift(); } } /** * Form 바인딩 검증 수행 */ validateFormBinding( formId: string, dataKey: string | undefined, inputs: Array<{ name: string; type: string; value?: any }>, contextInfo: { hasParentContext: boolean; parentContextProp?: string; isContextBroken: boolean; breakReason?: string; } ): FormBindingValidationInfo { const inputBindings = inputs.map(input => { const issues: string[] = []; let isValid = true; if (!input.name) { issues.push('Input에 name 속성이 없습니다'); isValid = false; this.trackFormBindingIssue('missing-input-name', 'error', { formId, formDataKey: dataKey, inputInfo: { name: input.name, type: input.type }, description: 'Input에 name 속성이 없어 자동 바인딩이 동작하지 않습니다', suggestion: 'Input에 name 속성을 추가하세요', docLink: 'troubleshooting-components-form.md', }); } if (!dataKey) { issues.push('Form에 dataKey가 없습니다'); isValid = false; } return { inputName: input.name || '(unnamed)', inputType: input.type, bindingPath: dataKey && input.name ? `${dataKey}.${input.name}` : '-', isValid, currentValue: input.value, issues, }; }); // dataKey 누락 이슈 if (!dataKey) { this.trackFormBindingIssue('missing-datakey', 'error', { formId, description: 'Form에 dataKey가 설정되지 않아 자동 바인딩이 동작하지 않습니다', suggestion: 'Form 컴포넌트에 dataKey props를 설정하세요', docLink: 'troubleshooting-components-form.md', }); } // 컨텍스트 단절 이슈 if (contextInfo.isContextBroken) { const issueType: FormBindingIssueType = contextInfo.breakReason?.includes('sortable') ? 'sortable-context-break' : contextInfo.breakReason?.includes('modal') ? 'modal-context-isolation' : 'context-not-propagated'; this.trackFormBindingIssue(issueType, 'warning', { formId, formDataKey: dataKey, contextInfo: { hasParentFormContext: contextInfo.hasParentContext, parentFormContextProp: contextInfo.parentContextProp, isInsideSortable: contextInfo.breakReason?.includes('sortable') || false, isInsideModal: contextInfo.breakReason?.includes('modal') || false, depth: 0, }, description: contextInfo.breakReason || 'Form 컨텍스트가 제대로 전파되지 않았습니다', suggestion: 'parentFormContextProp={undefined}를 확인하거나 중첩 구조를 검토하세요', docLink: 'troubleshooting-components-form.md', }); } const validation: FormBindingValidationInfo = { formId, dataKey, timestamp: Date.now(), contextPropagation: contextInfo, inputBindings, isValid: inputBindings.every(b => b.isValid) && !!dataKey && !contextInfo.isContextBroken, issueCount: inputBindings.filter(b => !b.isValid).length + (dataKey ? 0 : 1) + (contextInfo.isContextBroken ? 1 : 0), }; // 기존 검증 결과 업데이트 const existingIndex = this.formBindingValidations.findIndex(v => v.formId === formId); if (existingIndex >= 0) { this.formBindingValidations[existingIndex] = validation; } else { this.formBindingValidations.push(validation); } return validation; } /** * Form 바인딩 검증 추적 정보 반환 */ getFormBindingValidationTrackingInfo(): FormBindingValidationTrackingInfo { const stats = this.getFormBindingValidationStats(); return { issues: [...this.formBindingIssues], validations: [...this.formBindingValidations], stats, timestamp: Date.now(), }; } /** * Form 바인딩 검증 통계 반환 */ private getFormBindingValidationStats(): FormBindingValidationStats { const byIssueType: Record = { 'missing-datakey': 0, 'missing-input-name': 0, 'context-not-propagated': 0, 'sortable-context-break': 0, 'modal-context-isolation': 0, 'duplicate-input-name': 0, 'value-type-mismatch': 0, 'binding-path-invalid': 0, }; const bySeverity: Record = { info: 0, warning: 0, error: 0, }; for (const issue of this.formBindingIssues) { byIssueType[issue.type]++; bySeverity[issue.severity]++; } // 자주 발생하는 이슈 계산 const issueTypeCounts = Object.entries(byIssueType) .filter(([, count]) => count > 0) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([type, count]) => ({ type: type as FormBindingIssueType, count, description: this.getIssueTypeDescription(type as FormBindingIssueType), })); return { totalFormsValidated: this.formBindingValidations.length, validForms: this.formBindingValidations.filter(v => v.isValid).length, formsWithIssues: this.formBindingValidations.filter(v => !v.isValid).length, totalIssues: this.formBindingIssues.length, byIssueType, bySeverity, topIssues: issueTypeCounts, }; } /** * 이슈 유형 설명 반환 */ private getIssueTypeDescription(type: FormBindingIssueType): string { const descriptions: Record = { 'missing-datakey': 'Form에 dataKey 누락', 'missing-input-name': 'Input에 name 속성 누락', 'context-not-propagated': 'Form 컨텍스트 전파 실패', 'sortable-context-break': 'Sortable 컨테이너에서 컨텍스트 단절', 'modal-context-isolation': '모달에서 부모 Form 컨텍스트 격리', 'duplicate-input-name': '동일 Form 내 중복 Input name', 'value-type-mismatch': '값 타입과 Input 타입 불일치', 'binding-path-invalid': '바인딩 경로 유효하지 않음', }; return descriptions[type] || type; } /** * Form 바인딩 이슈 초기화 */ clearFormBindingIssues(): void { this.formBindingIssues = []; this.formBindingValidations = []; this.formBindingIssueIdCounter = 0; } // ============================================ // Computed 의존성 추적 메서드 // ============================================ /** * Computed 속성 등록 */ trackComputedProperty( name: string, expression: string, dependencies: Array<{ type: ComputedDependencyType; path: string; value?: any }>, value: any, computationTime: number, componentId?: string, error?: string ): void { if (!this.config.enabled) return; const id = `computed-${name}-${componentId || 'global'}`; const property: ComputedPropertyInfo = { id, name, expression, componentId, dependencies, currentValue: this.safeClone(value), lastComputedAt: Date.now(), computationTime, error, }; this.computedProperties.set(id, property); } /** * Computed 재계산 추적 */ trackComputedRecalc( computedName: string, trigger: ComputedRecalcTrigger, previousValue: any, newValue: any, computationTime: number, triggeredBy?: { type: ComputedDependencyType; path: string; oldValue?: any; newValue?: any; }, componentId?: string ): void { if (!this.config.enabled) return; const computedId = `computed-${computedName}-${componentId || 'global'}`; const valueChanged = JSON.stringify(previousValue) !== JSON.stringify(newValue); const log: ComputedRecalcLog = { id: `recalc-${++this.computedRecalcIdCounter}`, computedId, computedName, timestamp: Date.now(), trigger, triggeredBy, previousValue: this.safeClone(previousValue), newValue: this.safeClone(newValue), valueChanged, computationTime, cascadeCount: 0, // 연쇄 재계산 수는 나중에 분석에서 계산 }; this.computedRecalcLogs.push(log); // 이력 크기 제한 if (this.computedRecalcLogs.length > this.maxComputedRecalcLogs) { this.computedRecalcLogs.shift(); } // Computed 속성 정보 업데이트 const property = this.computedProperties.get(computedId); if (property) { property.currentValue = this.safeClone(newValue); property.lastComputedAt = Date.now(); property.computationTime = computationTime; } } /** * Computed 의존성 체인 분석 */ analyzeComputedDependencyChain(computedName: string, componentId?: string): ComputedDependencyChain { const visited = new Set(); const path: string[] = []; let hasCycle = false; let cyclePath: string[] | undefined; let maxDepth = 0; const buildTree = (name: string, depth: number): ComputedDependencyNode => { maxDepth = Math.max(maxDepth, depth); if (visited.has(name)) { hasCycle = true; cyclePath = [...path, name]; return { name, type: 'computed', children: [], depth, }; } visited.add(name); path.push(name); const prop = this.computedProperties.get(`computed-${name}-${componentId || 'global'}`); const children: ComputedDependencyNode[] = []; if (prop) { for (const dep of prop.dependencies) { if (dep.type === 'computed') { children.push(buildTree(dep.path, depth + 1)); } else { children.push({ name: dep.path, type: dep.type, children: [], depth: depth + 1, }); } } } path.pop(); visited.delete(name); return { name, type: 'computed', children, depth, }; }; const tree = buildTree(computedName, 0); return { root: computedName, tree, hasCycle, cyclePath, maxDepth, }; } /** * Computed 의존성 추적 정보 반환 */ getComputedDependencyTrackingInfo(): ComputedDependencyTrackingInfo { const properties = Array.from(this.computedProperties.values()); const stats = this.getComputedDependencyStats(); // 모든 computed에 대해 의존성 체인 분석 const dependencyChains: ComputedDependencyChain[] = []; for (const prop of properties) { const chain = this.analyzeComputedDependencyChain(prop.name, prop.componentId); dependencyChains.push(chain); } return { properties, recalcLogs: [...this.computedRecalcLogs], dependencyChains, stats, timestamp: Date.now(), }; } /** * Computed 의존성 통계 반환 */ private getComputedDependencyStats(): ComputedDependencyStats { const byTrigger: Record = { 'state-change': 0, 'datasource-update': 0, 'dependency-change': 0, 'manual': 0, 'initial': 0, }; const recalcCounts: Record = {}; const computationTimes: Record = {}; let totalComputationTime = 0; let unnecessaryCount = 0; for (const log of this.computedRecalcLogs) { byTrigger[log.trigger]++; if (!log.valueChanged) { unnecessaryCount++; } recalcCounts[log.computedName] = (recalcCounts[log.computedName] || 0) + 1; if (!computationTimes[log.computedName]) { computationTimes[log.computedName] = []; } computationTimes[log.computedName].push(log.computationTime); totalComputationTime += log.computationTime; } // 가장 많이 재계산된 속성 const topRecalculated = Object.entries(recalcCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([name, count]) => ({ name, count })); // 가장 느린 계산 const slowestComputed = Object.entries(computationTimes) .map(([name, times]) => ({ name, avgTime: times.reduce((a, b) => a + b, 0) / times.length, })) .sort((a, b) => b.avgTime - a.avgTime) .slice(0, 5); // 순환 의존성 감지 수 let cycleCount = 0; for (const prop of this.computedProperties.values()) { const chain = this.analyzeComputedDependencyChain(prop.name, prop.componentId); if (chain.hasCycle) { cycleCount++; } } return { totalComputed: this.computedProperties.size, totalRecalculations: this.computedRecalcLogs.length, unnecessaryRecalculations: unnecessaryCount, avgComputationTime: this.computedRecalcLogs.length > 0 ? totalComputationTime / this.computedRecalcLogs.length : 0, topRecalculated, slowestComputed, cycleDetectionCount: cycleCount, byTrigger, }; } /** * Computed 추적 초기화 */ clearComputedTracking(): void { this.computedProperties.clear(); this.computedRecalcLogs = []; this.computedRecalcIdCounter = 0; } // ============================================ // 모달 상태 스코프 추적 메서드 // ============================================ /** * 모달 열림 추적 */ trackModalOpen(options: { modalId: string; modalName: string; scopeType?: ModalStateScopeType; parentModalId?: string; componentId?: string; initialState?: Record; isolatedStateKeys?: string[]; sharedStateKeys?: string[]; }): void { if (!this.config.enabled) return; const modalInfo: ModalStateInfo = { modalId: options.modalId, modalName: options.modalName, openedAt: Date.now(), closedAt: null, scopeType: options.scopeType || 'isolated', parentModalId: options.parentModalId, componentId: options.componentId, initialState: options.initialState ? { ...options.initialState } : {}, currentState: options.initialState ? { ...options.initialState } : {}, stateChangeCount: 0, isolatedStateKeys: options.isolatedStateKeys || [], sharedStateKeys: options.sharedStateKeys || [], }; this.modalStates.set(options.modalId, modalInfo); // modalStack vs 레이아웃 modals 정의 교차 검증 this.validateModalDefinitionExists(options.modalId, options.modalName); // 부모-자식 관계 추적 if (options.parentModalId) { const existingRelation = this.modalStateRelations.find( r => r.parentModalId === options.parentModalId && r.childModalId === options.modalId ); if (!existingRelation) { this.modalStateRelations.push({ parentModalId: options.parentModalId, childModalId: options.modalId, sharedKeys: options.sharedStateKeys || [], isolatedKeys: options.isolatedStateKeys || [], relationType: 'parent-child', }); } } } /** * 모달 닫힘 추적 */ trackModalClose(modalId: string, finalState?: Record): void { if (!this.config.enabled) return; const modalInfo = this.modalStates.get(modalId); if (!modalInfo) return; modalInfo.closedAt = Date.now(); if (finalState) { modalInfo.currentState = { ...finalState }; } // 상태 유출 검사 this.detectStateLeakage(modalInfo); } /** * modalStack에 열린 모달 ID가 레이아웃 modals 섹션에 정의되어 있는지 검증 * * @since engine-v1.22.0 */ private validateModalDefinitionExists(modalId: string, modalName: string): void { const layout = this.currentLayout; if (!layout?.layoutJson) return; const modals = layout.layoutJson.modals; // modals 섹션 자체가 없거나 빈 배열/객체 if (!modals) { this.recordModalStateIssue({ type: 'missing-definition', modalId, modalName, severity: 'error', description: `모달 "${modalId}"이(가) modalStack에 추가되었으나, 현재 레이아웃에 modals 섹션이 없습니다. 레이아웃 경로: ${layout.layoutPath}`, affectedStateKeys: [], expectedValue: `modals 섹션에 id="${modalId}" 정의 존재`, actualValue: 'modals 섹션 없음', }); return; } // modals가 배열인 경우 (정상 형식) const modalArray = Array.isArray(modals) ? modals : Object.values(modals); const found = modalArray.some( (m: any) => m?.id === modalId || m?.props?.id === modalId ); if (!found) { this.recordModalStateIssue({ type: 'missing-definition', modalId, modalName, severity: 'error', description: `모달 "${modalId}"이(가) modalStack에 추가되었으나, 렌더링된 레이아웃의 modals 섹션에 해당 ID의 정의가 없습니다. partial 로딩 실패, 레이아웃 병합 문제, extends 상속 누락 등을 확인하세요. 레이아웃 경로: ${layout.layoutPath}`, affectedStateKeys: [], expectedValue: `modals 배열에 id="${modalId}" 항목 존재`, actualValue: `modals 배열에 ${modalArray.length}개 모달 정의 (${modalArray.map((m: any) => m?.id || 'unknown').join(', ')})`, }); } } /** * 모달 내 상태 변경 추적 */ trackModalStateChange(options: { modalId: string; stateKey: string; previousValue: any; newValue: any; changeSource?: 'user-action' | 'api-response' | 'parent-sync' | 'init' | 'cleanup'; }): void { if (!this.config.enabled) return; const modalInfo = this.modalStates.get(options.modalId); if (!modalInfo) return; modalInfo.stateChangeCount++; modalInfo.currentState[options.stateKey] = options.newValue; // 격리 위반 검사 const violatesIsolation = this.checkIsolationViolation(modalInfo, options.stateKey); const changeLog: ModalStateChangeLog = { id: `modal-change-${++this.modalStateChangeLogIdCounter}`, modalId: options.modalId, modalName: modalInfo.modalName, timestamp: Date.now(), stateKey: options.stateKey, previousValue: options.previousValue, newValue: options.newValue, changeSource: options.changeSource || 'user-action', violatesIsolation, }; this.modalStateChangeLogs.push(changeLog); // 크기 제한 if (this.modalStateChangeLogs.length > this.maxModalStateChangeLogs) { this.modalStateChangeLogs.shift(); } // 격리 위반 시 이슈 기록 if (violatesIsolation) { this.recordModalStateIssue({ type: 'isolation-violation', modalId: options.modalId, modalName: modalInfo.modalName, severity: 'warning', description: `모달 '${modalInfo.modalName}'에서 격리된 상태 키 '${options.stateKey}'가 변경됨`, affectedStateKeys: [options.stateKey], }); } } /** * 상태 유출 감지 */ private detectStateLeakage(modalInfo: ModalStateInfo): void { // isolated 스코프의 모달에서 상태가 유출되었는지 확인 if (modalInfo.scopeType !== 'isolated') return; for (const key of modalInfo.isolatedStateKeys) { const initialValue = modalInfo.initialState[key]; const currentValue = modalInfo.currentState[key]; // 값이 변경되었고, 이게 부모 상태에 영향을 줄 수 있는지 확인 if (initialValue !== currentValue) { // 부모 모달이 있는 경우 해당 키가 부모에서 사용되는지 확인 if (modalInfo.parentModalId) { const parentModal = this.modalStates.get(modalInfo.parentModalId); if (parentModal && parentModal.currentState.hasOwnProperty(key)) { this.recordModalStateIssue({ type: 'state-leakage', modalId: modalInfo.modalId, modalName: modalInfo.modalName, severity: 'error', description: `모달 닫힘 후 상태 '${key}'가 부모 모달로 유출될 수 있음`, affectedStateKeys: [key], leakedValue: currentValue, expectedValue: initialValue, }); } } } } } /** * 격리 위반 검사 */ private checkIsolationViolation(modalInfo: ModalStateInfo, _stateKey: string): boolean { // shared 스코프가 아닌데 공유 키가 변경되면 위반 if (modalInfo.scopeType === 'isolated') { // 격리 키에 포함된 상태가 외부에서 변경 시도되면 위반 // _stateKey는 향후 상세 격리 위반 검사 시 사용 예정 return false; } return false; } /** * 모달 상태 이슈 기록 */ private recordModalStateIssue(options: { type: ModalStateIssueType; modalId: string; modalName: string; severity: 'warning' | 'error'; description: string; affectedStateKeys: string[]; leakedValue?: any; expectedValue?: any; actualValue?: any; stackInfo?: string; }): void { const issue: ModalStateIssue = { id: `modal-issue-${++this.modalStateIssueIdCounter}`, type: options.type, modalId: options.modalId, modalName: options.modalName, timestamp: Date.now(), severity: options.severity, description: options.description, affectedStateKeys: options.affectedStateKeys, leakedValue: options.leakedValue, expectedValue: options.expectedValue, actualValue: options.actualValue, stackInfo: options.stackInfo, }; this.modalStateIssues.push(issue); // 크기 제한 if (this.modalStateIssues.length > this.maxModalStateIssues) { this.modalStateIssues.shift(); } } /** * 모달 상태 스코프 추적 정보 반환 */ getModalStateScopeTrackingInfo(): ModalStateScopeTrackingInfo { const modals = Array.from(this.modalStates.values()); const stats = this.getModalStateScopeStats(); return { modals, issues: [...this.modalStateIssues], relations: [...this.modalStateRelations], changeLogs: [...this.modalStateChangeLogs], stats, timestamp: Date.now(), }; } /** * 모달 상태 스코프 통계 반환 */ private getModalStateScopeStats(): ModalStateScopeStats { const modals = Array.from(this.modalStates.values()); const issuesBySeverity: Record<'warning' | 'error', number> = { warning: 0, error: 0, }; const issuesByType: Record = { 'state-leakage': 0, 'isolation-violation': 0, 'parent-mutation': 0, 'orphaned-state': 0, 'scope-mismatch': 0, 'cleanup-failure': 0, 'missing-definition': 0, }; for (const issue of this.modalStateIssues) { issuesBySeverity[issue.severity]++; issuesByType[issue.type]++; } const byScope: Record = { isolated: 0, shared: 0, inherited: 0, }; for (const modal of modals) { byScope[modal.scopeType]++; } return { totalModals: modals.length, openModals: modals.filter(m => m.closedAt === null).length, nestedModals: modals.filter(m => m.parentModalId !== undefined).length, totalIssues: this.modalStateIssues.length, issuesBySeverity, issuesByType, byScope, leakageDetectionCount: issuesByType['state-leakage'], cleanupFailureCount: issuesByType['cleanup-failure'], }; } /** * 모달 상태 스코프 추적 초기화 */ clearModalStateScopeTracking(): void { this.modalStates.clear(); this.modalStateIssues = []; this.modalStateRelations = []; this.modalStateChangeLogs = []; this.modalStateIssueIdCounter = 0; this.modalStateChangeLogIdCounter = 0; } // ============================================ // Named Actions 추적 메서드 // ============================================ /** * 현재 레이아웃의 named_actions 정의를 등록합니다. * * @param definitions named_actions 정의 맵 */ setNamedActionDefinitions(definitions: Record): void { if (!this.config.enabled) return; this.namedActionDefinitions = definitions || {}; } /** * actionRef 해석 이력을 기록합니다. * * @param log actionRef 해석 정보 */ trackNamedActionRef(log: Omit): void { if (!this.config.enabled) return; const entry: NamedActionRefLog = { ...log, id: `named_action_ref_${++this.namedActionRefIdCounter}`, }; this.namedActionRefLogs.push(entry); if (this.namedActionRefLogs.length > this.maxNamedActionRefLogs) { this.namedActionRefLogs = this.namedActionRefLogs.slice(-this.maxNamedActionRefLogs); } } /** * Named Actions 추적 정보를 반환합니다. */ getNamedActionTrackingInfo(): NamedActionTrackingInfo { // 각 named action별 참조 횟수 통계 const refCountByName: Record = {}; for (const log of this.namedActionRefLogs) { refCountByName[log.actionRefName] = (refCountByName[log.actionRefName] || 0) + 1; } // 미사용 정의 감지 const unusedDefinitions = Object.keys(this.namedActionDefinitions).filter( name => !refCountByName[name] ); return { definitions: this.namedActionDefinitions, refLogs: [...this.namedActionRefLogs], stats: { totalDefinitions: Object.keys(this.namedActionDefinitions).length, totalRefs: this.namedActionRefLogs.length, refCountByName, unusedDefinitions, }, timestamp: Date.now(), }; } /** * Named Actions 추적 초기화 */ clearNamedActionTracking(): void { this.namedActionDefinitions = {}; this.namedActionRefLogs = []; this.namedActionRefIdCounter = 0; } // ============================================ // 표현식 추적 메서드 // ============================================ /** * 표현식 평가 추적 * * DataBindingEngine에서 표현식을 평가할 때 호출됩니다. */ trackExpressionEval(info: Omit): void { if (!this.config.enabled) return; const evalInfo: ExpressionEvalInfo = { ...info, id: `expr-${++this.expressionIdCounter}`, timestamp: Date.now(), warning: this.detectExpressionWarning(info), }; this.expressionHistory.push(evalInfo); // 이력 크기 제한 (최근 500개만 유지) if (this.expressionHistory.length > 500) { this.expressionHistory.shift(); } // 프로파일링 중이면 기록 if (this.isProfiling) { this.profilingData.push({ type: 'binding', expression: info.expression, timestamp: performance.now(), duration: info.duration, }); } // 감시자 알림 this.notifyExpressionWatchers(evalInfo); } /** * 표현식 경고 감지 */ private detectExpressionWarning(info: Omit): ExpressionWarning | undefined { const { expression, result, resultType, method, duration } = info; // 1. undefined 결과 감지 if (resultType === 'undefined' && !expression.includes('??') && !expression.includes('||')) { // 단순 경로 표현식인 경우 경고 if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(expression.replace(/{{|}}/g, '').trim())) { return { type: 'undefined-result', message: `표현식 결과가 undefined입니다`, suggestion: `데이터가 로드되었는지 확인하거나 fallback 값을 추가하세요: ${expression} ?? ''`, }; } } // 2. null 결과 감지 if (resultType === 'null') { return { type: 'null-result', message: `표현식 결과가 null입니다`, suggestion: `nullish coalescing 연산자를 사용하세요: ${expression} ?? ''`, }; } // 3. 배열이 문자열로 변환됨 (resolveBindings 사용 시) if (method === 'resolveBindings' && resultType === 'string') { const resultStr = String(result); if (resultStr.includes('[object Object]') || /^\[.*\]$/.test(resultStr)) { return { type: 'array-to-string', message: `배열/객체가 문자열로 변환되었습니다`, suggestion: `evaluateExpression()을 사용하여 원본 타입을 유지하세요`, }; } } // 4. 객체가 문자열로 변환됨 if (method === 'resolveBindings' && resultType === 'string' && String(result).includes('[object Object]')) { return { type: 'object-to-string', message: `객체가 [object Object]로 변환되었습니다`, suggestion: `evaluateExpression()을 사용하거나 특정 속성에 접근하세요`, }; } // 5. Optional chaining 누락 의심 if (resultType === 'undefined' && expression.includes('.') && !expression.includes('?.')) { const parts = expression.replace(/{{|}}/g, '').trim().split('.'); if (parts.length >= 2) { return { type: 'missing-optional-chain', message: `Optional chaining(?.) 누락 가능성`, suggestion: `안전한 접근을 위해 ?. 사용을 고려하세요: ${parts.join('?.')}`, }; } } // 6. 잘못된 iteration 변수명 감지 if (expression.includes('{{item.') || expression.includes('{{index}}')) { // iteration 내부에서 item/index를 직접 사용하는 경우 const iterationVars = Array.from(this.iterations.values()); const hasCustomVar = iterationVars.some(i => i.itemVar !== 'item'); if (hasCustomVar) { return { type: 'wrong-iteration-var', message: `'item'/'index' 대신 iteration에서 정의한 변수명을 사용해야 합니다`, suggestion: `iteration의 item_var/index_var에 정의된 변수명을 확인하세요`, }; } } // 7. 느린 평가 감지 (10ms 이상) if (duration && duration > 10) { return { type: 'slow-evaluation', message: `표현식 평가에 ${duration.toFixed(2)}ms 소요됨`, suggestion: `복잡한 표현식을 단순화하거나 computed 값으로 분리하세요`, }; } return undefined; } /** * 표현식 이력 조회 */ getExpressions(): ExpressionEvalInfo[] { return [...this.expressionHistory]; } /** * 경고가 있는 표현식만 조회 */ getExpressionWarnings(): ExpressionEvalInfo[] { return this.expressionHistory.filter(e => e.warning != null); } /** * 특정 표현식 검색 */ searchExpressions(query: string): ExpressionEvalInfo[] { const lowerQuery = query.toLowerCase(); return this.expressionHistory.filter(e => e.expression.toLowerCase().includes(lowerQuery) || e.componentName?.toLowerCase().includes(lowerQuery) || e.propName?.toLowerCase().includes(lowerQuery) ); } /** * 표현식 통계 조회 */ getExpressionStats(): ExpressionStats { const expressions = this.expressionHistory; const uniqueExprs = new Set(expressions.map(e => e.expression)); const warnings = expressions.filter(e => e.warning != null); const cached = expressions.filter(e => e.fromCache); const durations = expressions.filter(e => e.duration != null).map(e => e.duration!); // 결과 타입별 집계 const byType: Record = {}; for (const expr of expressions) { byType[expr.resultType] = (byType[expr.resultType] || 0) + 1; } // 경고 유형별 집계 const byWarning: Record = {}; for (const expr of warnings) { if (expr.warning) { byWarning[expr.warning.type] = (byWarning[expr.warning.type] || 0) + 1; } } return { totalEvaluations: expressions.length, uniqueExpressions: uniqueExprs.size, warningCount: warnings.length, cacheHitRate: expressions.length > 0 ? cached.length / expressions.length : 0, averageDuration: durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0, byType, byWarning, }; } /** * 표현식 이력 초기화 */ clearExpressions(): void { this.expressionHistory = []; this.expressionIdCounter = 0; } /** * 표현식 감시 등록 */ watchExpressions(callback: ExpressionWatcherCallback): () => void { this.expressionWatchers.add(callback); return () => { this.expressionWatchers.delete(callback); }; } /** * 표현식 감시자들에게 알림 */ private notifyExpressionWatchers(info: ExpressionEvalInfo): void { this.expressionWatchers.forEach((cb) => { try { cb(info); } catch (e) { console.error('[G7DevTools] Expression watcher error:', e); } }); } // ============================================ // 설정 메서드 // ============================================ /** * 로그 레벨 설정 */ setLogLevel(level: 'debug' | 'info' | 'warn' | 'error'): void { this.config.logLevel = level; } /** * 최대 이력 크기 설정 */ setMaxHistory(count: number): void { this.config.maxHistorySize = Math.max(10, Math.min(1000, count)); } /** * 서버 엔드포인트 설정 */ setServerEndpoint(endpoint: string): void { this.config.serverEndpoint = endpoint; } /** * 현재 설정 조회 */ getConfig(): DevToolsConfig { return { ...this.config }; } // ============================================ // 유틸리티 메서드 // ============================================ /** * 고유 ID 생성 */ generateId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * 객체 직렬화 (순환 참조 제거) */ private sanitizeObject(obj: any, seen = new WeakSet()): any { if (obj === null || typeof obj !== 'object') { return obj; } if (seen.has(obj)) { return '[Circular]'; } seen.add(obj); if (Array.isArray(obj)) { return obj.map((item) => this.sanitizeObject(item, seen)); } const result: Record = {}; for (const key of Object.keys(obj)) { try { result[key] = this.sanitizeObject(obj[key], seen); } catch { result[key] = '[Unable to serialize]'; } } return result; } /** * 중첩 객체에서 값 가져오기 */ private getNestedValue(obj: any, path: string): any { if (!path) return obj; const parts = path.split('.'); let current = obj; for (const part of parts) { if (current == null) return undefined; current = current[part]; } return current; } // ============================================ // 진단 엔진용 Getter 메서드들 // ============================================ /** * 라이프사이클 정보 반환 */ getLifecycleInfo(): LifecycleInfo { return { mountedComponents: Array.from(this.mountedComponents.values()), orphanedListeners: this.getOrphanedListeners(), }; } /** * 성능 정보 반환 */ getPerformanceInfo(): PerformanceInfo { return { renderCounts: this.renderCounts, bindingEvalCount: this.bindingEvalCount, memoryWarnings: this.getMemoryWarnings(), }; } /** * 네트워크 정보 반환 */ getNetworkInfo(): NetworkInfo { return { activeRequests: Array.from(this.activeRequests.values()), requestHistory: [...this.requestHistory], pendingDataSources: Array.from(this.pendingDataSources), }; } /** * 조건부 렌더링 정보 반환 */ getConditionalInfo(): ConditionalInfo { return { ifConditions: Array.from(this.ifConditions.values()), iterations: Array.from(this.iterations.values()), }; } /** * Form 정보 반환 */ getFormInfo(): FormInfo[] { return Array.from(this.forms.values()); } /** * WebSocket 정보 반환 */ getWebSocketInfo(): WebSocketInfo { return { connections: Array.from(this.wsConnections.values()), messageHistory: [...this.wsMessageHistory], connectionState: this.getWebSocketConnectionState(), }; } /** * 특정 시점의 상태 반환 * * @param timestamp 조회할 시점 * @returns 해당 시점의 상태 또는 undefined */ getStateAtTime(timestamp: number): StateView | undefined { // 해당 시점 이전의 가장 최근 스냅샷 찾기 const snapshot = [...this.stateHistory] .reverse() .find(s => s.timestamp <= timestamp); if (!snapshot) { return undefined; } return { _global: snapshot.next, _local: {}, }; } /** * 모든 데이터 초기화 */ reset(): void { this.stateHistory = []; this.actionHistory = []; this.cacheStats = { hits: 0, misses: 0, entries: 0 }; // 캐시 결정 추적 초기화 this.cacheDecisions = []; this.cacheDecisionIdCounter = 0; this.currentRenderCycleId = null; this.isInActionExecution = false; this.isInIteration = false; this.mountedComponents.clear(); this.eventListeners.clear(); this.renderCounts.clear(); this.bindingEvalCount = 0; this.profilingData = []; this.activeRequests.clear(); this.requestHistory = []; this.pendingDataSources.clear(); this.wsConnections.clear(); this.wsMessageHistory = []; this.ifConditions.clear(); this.iterations.clear(); this.forms.clear(); this.expressionHistory = []; this.expressionIdCounter = 0; this.dataSources.clear(); // 데이터 경로 변환 초기화 this.dataPathTransforms = []; // Nested Context 초기화 this.nestedContexts = []; this.nestedContextIdCounter = 0; // Form 바인딩 검증 초기화 this.formBindingIssues = []; this.formBindingValidations = []; this.formBindingIssueIdCounter = 0; // Computed 의존성 추적 초기화 this.computedProperties.clear(); this.computedRecalcLogs = []; this.computedRecalcIdCounter = 0; // 모달 상태 스코프 추적 초기화 this.modalStates.clear(); this.modalStateIssues = []; this.modalStateRelations = []; this.modalStateChangeLogs = []; this.modalStateIssueIdCounter = 0; this.modalStateChangeLogIdCounter = 0; // 로그 초기화 this.logHistory = []; this.logIdCounter = 0; } /** * 표현식 정보 반환 (진단 엔진용) */ getExpressionInfo(): { expressions: ExpressionEvalInfo[]; stats: ExpressionStats } { return { expressions: this.getExpressions(), stats: this.getExpressionStats(), }; } // ============================================ // 로그 추적 메서드 // ============================================ /** * 로그 추적 * * Logger에서 호출되어 로그를 DevTools에 기록합니다. */ trackLog(level: LogLevel, prefix: string, args: unknown[]): void { if (!this.config.enabled) return; // 메시지 직렬화 const message = args .map(arg => { if (typeof arg === 'string') return arg; try { return JSON.stringify(arg); } catch { return String(arg); } }) .join(' '); const entry: LogEntry = { id: `log-${++this.logIdCounter}`, level, prefix, message, args: this.sanitizeObject(args), timestamp: Date.now(), stack: level === 'error' ? new Error().stack : undefined, }; this.logHistory.push(entry); // 이력 크기 제한 if (this.logHistory.length > this.maxLogHistory) { this.logHistory.shift(); } } // ============================================ // Debounce 액션 및 부분 업데이트 추적 // ============================================ /** debounce 액션 이력 */ private debounceActionHistory: Array<{ id: string; handler: string; type: string; status: 'pending' | 'success' | 'error' | 'cancelled'; params?: Record; debounce?: { delay: number; status: 'pending' | 'executed' | 'cancelled'; scheduledAt?: number; executedAt?: number; mode?: 'leading' | 'trailing'; }; timestamp: number; }> = []; /** 데이터소스 부분 업데이트 이력 */ private dataSourceUpdateHistory: Array<{ id: string; dataSourceId: string; updateType: 'full' | 'partial'; itemPath?: string; itemId?: string | number; updates?: Record; timestamp: number; }> = []; private debounceActionIdCounter = 0; private dataSourceUpdateIdCounter = 0; /** * debounce 액션 추적 * * debounce 옵션이 있는 액션의 실행 상태를 추적합니다. */ trackAction(info: { handler: string; type: string; status: 'pending' | 'success' | 'error' | 'cancelled'; params?: Record; debounce?: { delay: number; status: 'pending' | 'executed' | 'cancelled'; scheduledAt?: number; executedAt?: number; mode?: 'leading' | 'trailing'; }; }): void { if (!this.config.enabled) return; const entry = { id: `debounce-action-${++this.debounceActionIdCounter}`, ...info, timestamp: Date.now(), }; this.debounceActionHistory.push(entry); // 이력 크기 제한 (최대 100개) if (this.debounceActionHistory.length > 100) { this.debounceActionHistory.shift(); } } /** * 데이터소스 부분 업데이트 추적 * * G7Core.dataSource.updateItem() 호출을 추적합니다. */ trackDataSourceUpdate(info: { dataSourceId: string; updateType: 'full' | 'partial'; itemPath?: string; itemId?: string | number; updates?: Record; timestamp: number; }): void { if (!this.config.enabled) return; const entry = { id: `ds-update-${++this.dataSourceUpdateIdCounter}`, ...info, }; this.dataSourceUpdateHistory.push(entry); // 이력 크기 제한 (최대 100개) if (this.dataSourceUpdateHistory.length > 100) { this.dataSourceUpdateHistory.shift(); } } /** * debounce 액션 이력 조회 */ getDebounceActionHistory(options?: { handler?: string; status?: 'pending' | 'success' | 'error' | 'cancelled'; limit?: number; }): typeof this.debounceActionHistory { let history = [...this.debounceActionHistory]; if (options?.handler) { history = history.filter(h => h.handler.includes(options.handler!)); } if (options?.status) { history = history.filter(h => h.status === options.status); } if (options?.limit) { history = history.slice(-options.limit); } return history; } /** * 데이터소스 업데이트 이력 조회 */ getDataSourceUpdateHistory(options?: { dataSourceId?: string; updateType?: 'full' | 'partial'; limit?: number; }): typeof this.dataSourceUpdateHistory { let history = [...this.dataSourceUpdateHistory]; if (options?.dataSourceId) { history = history.filter(h => h.dataSourceId === options.dataSourceId); } if (options?.updateType) { history = history.filter(h => h.updateType === options.updateType); } if (options?.limit) { history = history.slice(-options.limit); } return history; } /** * 로그 이력 조회 */ getLogs(options?: LogFilterOptions): LogEntry[] { let logs = [...this.logHistory]; if (options?.level) { const levels = Array.isArray(options.level) ? options.level : [options.level]; logs = logs.filter(log => levels.includes(log.level)); } if (options?.prefix) { const prefixLower = options.prefix.toLowerCase(); logs = logs.filter(log => log.prefix.toLowerCase().includes(prefixLower) ); } if (options?.search) { const searchLower = options.search.toLowerCase(); logs = logs.filter(log => log.message.toLowerCase().includes(searchLower) ); } if (options?.since) { logs = logs.filter(log => log.timestamp >= options.since!); } if (options?.limit) { logs = logs.slice(-options.limit); } return logs; } /** * 로그 통계 조회 */ getLogStats(): LogStats { const now = Date.now(); const oneMinuteAgo = now - 60000; const byLevel: Record = { log: 0, warn: 0, error: 0, debug: 0, info: 0, }; const byPrefix: Record = {}; let recentErrors = 0; let recentWarnings = 0; for (const entry of this.logHistory) { // 레벨별 집계 byLevel[entry.level]++; // Prefix별 집계 byPrefix[entry.prefix] = (byPrefix[entry.prefix] || 0) + 1; // 최근 에러/경고 집계 if (entry.timestamp >= oneMinuteAgo) { if (entry.level === 'error') recentErrors++; if (entry.level === 'warn') recentWarnings++; } } return { totalLogs: this.logHistory.length, byLevel, byPrefix, recentErrors, recentWarnings, }; } /** * 로그 이력 초기화 */ clearLogs(): void { this.logHistory = []; this.logIdCounter = 0; } /** * 로그 정보 반환 (상태 덤프용) * * entries와 stats가 동일한 logHistory 전체를 기반으로 계산됩니다. * limit을 제거하여 stats와 entries 간 불일치를 방지합니다. */ getLogInfo(): LogInfo { return { entries: this.getLogs(), stats: this.getLogStats(), }; } // ============================================ // 데이터소스 추적 메서드 // ============================================ /** * 데이터소스 등록 * * DataSourceManager에서 데이터소스 정의 시 호출됩니다. */ trackDataSourceDefinition(info: Omit): void { if (!this.config.enabled) return; this.dataSources.set(info.id, { ...info, status: 'idle', }); } /** * 데이터소스 로딩 시작 */ trackDataSourceLoading(id: string): void { if (!this.config.enabled) return; const existing = this.dataSources.get(id); if (existing) { this.dataSources.set(id, { ...existing, status: 'loading' }); } } /** * 데이터소스 로드 완료 */ trackDataSourceLoaded(id: string, data: any, dataPath?: string): void { if (!this.config.enabled) return; const existing = this.dataSources.get(id); if (!existing) return; // 데이터 구조 분석 let itemCount: number | undefined; let keys: string[] | undefined; let resolvedDataPath = dataPath; // 데이터 경로 자동 감지 if (data) { if (Array.isArray(data)) { itemCount = data.length; keys = data.length > 0 && typeof data[0] === 'object' ? Object.keys(data[0]) : undefined; } else if (typeof data === 'object') { // 일반적인 API 응답 구조 감지 (data.data, data.items 등) if (Array.isArray(data.data)) { resolvedDataPath = resolvedDataPath || 'data'; itemCount = data.data.length; keys = data.data.length > 0 && typeof data.data[0] === 'object' ? Object.keys(data.data[0]) : undefined; } else if (Array.isArray(data.items)) { resolvedDataPath = resolvedDataPath || 'items'; itemCount = data.items.length; keys = data.items.length > 0 && typeof data.items[0] === 'object' ? Object.keys(data.items[0]) : undefined; } else { keys = Object.keys(data); } } } this.dataSources.set(id, { ...existing, status: 'loaded', dataPath: resolvedDataPath, itemCount, keys, lastLoadedAt: Date.now(), error: undefined, }); } /** * 데이터소스 로드 실패 */ trackDataSourceError(id: string, error: string): void { if (!this.config.enabled) return; const existing = this.dataSources.get(id); if (existing) { this.dataSources.set(id, { ...existing, status: 'error', error, lastLoadedAt: Date.now(), }); } } /** * 데이터소스 제거 */ untrackDataSource(id: string): void { this.dataSources.delete(id); } /** * 모든 데이터소스 정보 조회 */ getDataSources(): DataSourceInfo[] { return Array.from(this.dataSources.values()); } /** * 특정 데이터소스 정보 조회 */ getDataSource(id: string): DataSourceInfo | undefined { return this.dataSources.get(id); } /** * 데이터소스 초기화 */ clearDataSources(): void { this.dataSources.clear(); } // ============================================ // 데이터 경로 변환 추적 메서드 // ============================================ /** * 데이터 경로 변환 추적 * * API 응답 → actualData → initGlobal/initLocal 과정을 추적합니다. */ trackDataPathTransform(info: { dataSourceId: string; step: import('./types').DataPathTransformStep; inputPath: string; inputValue: any; outputPath: string; outputValue: any; config?: { path?: string; initGlobal?: any; initLocal?: any; }; warning?: string; }): void { if (!this.config.enabled) return; // 기존 변환 정보 찾기 또는 새로 생성 let transform = this.dataPathTransforms.find(t => t.dataSourceId === info.dataSourceId); if (!transform) { transform = { dataSourceId: info.dataSourceId, timestamp: Date.now(), transformSteps: [], warnings: [], }; this.dataPathTransforms.push(transform); // 최대 크기 제한 if (this.dataPathTransforms.length > this.maxDataPathTransforms) { this.dataPathTransforms.shift(); } } // 변환 단계 추가 transform.transformSteps.push({ step: info.step, inputPath: info.inputPath, inputValue: this.safeClone(info.inputValue), outputPath: info.outputPath, outputValue: this.safeClone(info.outputValue), config: info.config, }); // 경고 추가 if (info.warning) { transform.warnings.push(info.warning); } } /** * 데이터 경로 변환 최종 바인딩 설정 */ setDataPathFinalBinding(dataSourceId: string, binding: { expression: string; resolvedPath: string; value: any; }): void { if (!this.config.enabled) return; const transform = this.dataPathTransforms.find(t => t.dataSourceId === dataSourceId); if (transform) { transform.finalBinding = { expression: binding.expression, resolvedPath: binding.resolvedPath, value: this.safeClone(binding.value), }; } } /** * 데이터 경로 변환 추적 정보 반환 */ getDataPathTransformTrackingInfo(): import('./types').DataPathTransformTrackingInfo { const stats = this.getDataPathTransformStats(); return { transforms: [...this.dataPathTransforms], stats, timestamp: Date.now(), }; } /** * 데이터 경로 변환 통계 계산 */ private getDataPathTransformStats(): import('./types').DataPathTransformStats { const transforms = this.dataPathTransforms; // 데이터소스별 개수 const byDataSource: Record = {}; let warningsCount = 0; const warningCounts: Record = {}; for (const t of transforms) { byDataSource[t.dataSourceId] = (byDataSource[t.dataSourceId] || 0) + 1; warningsCount += t.warnings.length; for (const w of t.warnings) { warningCounts[w] = (warningCounts[w] || 0) + 1; } } // 자주 발생하는 경고 const commonWarnings = Object.entries(warningCounts) .map(([warning, count]) => ({ warning, count })) .sort((a, b) => b.count - a.count) .slice(0, 10); return { totalTransforms: transforms.length, byDataSource, warningsCount, commonWarnings, }; } /** * 특정 데이터소스의 변환 추적 정보 조회 */ getDataPathTransformForDataSource(dataSourceId: string): import('./types').DataPathTransformInfo | undefined { return this.dataPathTransforms.find(t => t.dataSourceId === dataSourceId); } /** * 데이터 경로 변환 데이터 초기화 */ clearDataPathTransforms(): void { this.dataPathTransforms = []; } // ============================================ // Nested Context 추적 메서드 // ============================================ /** * Nested Context 추적 * * expandChildren, cellChildren, iteration, modal 등 중첩 렌더링 컨텍스트를 추적합니다. */ trackNestedContext(info: { componentId: string; componentType: import('./types').NestedContextType; parentContext: { available: string[]; values: Record; }; ownContext: { added: string[]; values: Record; }; depth: number; parentId?: string; }): string { if (!this.config.enabled) return ''; const id = `nested-${++this.nestedContextIdCounter}`; // 병합된 컨텍스트 계산 const mergedAll = [...new Set([...info.parentContext.available, ...info.ownContext.added])]; const mergedValues = { ...info.parentContext.values, ...info.ownContext.values }; const context: import('./types').NestedContextInfo = { id, timestamp: Date.now(), componentId: info.componentId, componentType: info.componentType, parentContext: { available: info.parentContext.available, values: this.safeClone(info.parentContext.values), }, ownContext: { added: info.ownContext.added, values: this.safeClone(info.ownContext.values), }, mergedContext: { all: mergedAll, values: this.safeClone(mergedValues), }, accessAttempts: [], depth: info.depth, parentId: info.parentId, }; this.nestedContexts.push(context); // 최대 크기 제한 if (this.nestedContexts.length > this.maxNestedContexts) { this.nestedContexts.shift(); } return id; } /** * Nested Context 접근 시도 기록 */ trackNestedContextAccess(contextId: string, access: { path: string; found: boolean; value?: any; error?: string; }): void { if (!this.config.enabled) return; const context = this.nestedContexts.find(c => c.id === contextId); if (context) { context.accessAttempts.push({ path: access.path, found: access.found, value: access.found ? this.safeClone(access.value) : undefined, error: access.error, }); } } /** * Nested Context 추적 정보 반환 */ getNestedContextTrackingInfo(): import('./types').NestedContextTrackingInfo { const stats = this.getNestedContextStats(); return { contexts: [...this.nestedContexts], stats, timestamp: Date.now(), }; } /** * Nested Context 통계 계산 */ private getNestedContextStats(): import('./types').NestedContextStats { const contexts = this.nestedContexts; // 타입별 개수 const byType: Record = { expandChildren: 0, cellChildren: 0, iteration: 0, modal: 0, slot: 0, }; let maxDepth = 0; let failedAccessCount = 0; const failedPaths: Record = {}; for (const c of contexts) { byType[c.componentType]++; if (c.depth > maxDepth) maxDepth = c.depth; for (const attempt of c.accessAttempts) { if (!attempt.found) { failedAccessCount++; failedPaths[attempt.path] = (failedPaths[attempt.path] || 0) + 1; } } } // 자주 실패하는 경로 const commonFailedPaths = Object.entries(failedPaths) .map(([path, count]) => ({ path, count })) .sort((a, b) => b.count - a.count) .slice(0, 10); return { totalContexts: contexts.length, byType, maxDepth, failedAccessCount, commonFailedPaths, }; } /** * 특정 컴포넌트의 Nested Context 조회 */ getNestedContextForComponent(componentId: string): import('./types').NestedContextInfo | undefined { return this.nestedContexts.find(c => c.componentId === componentId); } /** * Nested Context 데이터 초기화 */ clearNestedContexts(): void { this.nestedContexts = []; this.nestedContextIdCounter = 0; } // ============================================ // 핸들러 추적 메서드 // ============================================ /** * 핸들러 등록 추적 * * @param name 핸들러 이름 * @param category 핸들러 카테고리 * @param description 핸들러 설명 * @param source 핸들러 소스 (모듈명 등) */ trackHandlerRegistration( name: string, category: HandlerInfo['category'] = 'custom', description?: string, source?: string ): void { if (!this.isEnabled) return; this.handlers.set(name, { name, category, description, registeredAt: Date.now(), source, }); } /** * 핸들러 해제 추적 * * @param name 핸들러 이름 */ trackHandlerUnregistration(name: string): void { if (!this.isEnabled) return; this.handlers.delete(name); } /** * 모든 핸들러 정보 조회 */ getHandlers(): HandlerInfo[] { return Array.from(this.handlers.values()); } /** * 특정 핸들러 정보 조회 */ getHandler(name: string): HandlerInfo | undefined { return this.handlers.get(name); } /** * 카테고리별 핸들러 조회 */ getHandlersByCategory(category: HandlerInfo['category']): HandlerInfo[] { return Array.from(this.handlers.values()).filter(h => h.category === category); } /** * 핸들러 초기화 */ clearHandlers(): void { this.handlers.clear(); } // ============================================ // 컴포넌트 이벤트 추적 메서드 // ============================================ /** * 이벤트 구독 추적 * * @param eventName 이벤트 이름 */ trackEventSubscribe(eventName: string): void { if (!this.isEnabled) return; const existing = this.componentEventSubscriptions.get(eventName); const now = Date.now(); if (existing) { existing.subscriberCount++; existing.lastSubscribedAt = now; } else { this.componentEventSubscriptions.set(eventName, { eventName, subscriberCount: 1, firstSubscribedAt: now, lastSubscribedAt: now, }); } } /** * 이벤트 구독 해제 추적 * * @param eventName 이벤트 이름 */ trackEventUnsubscribe(eventName: string): void { if (!this.isEnabled) return; const existing = this.componentEventSubscriptions.get(eventName); if (existing) { existing.subscriberCount--; if (existing.subscriberCount <= 0) { this.componentEventSubscriptions.delete(eventName); } } } /** * 이벤트 발생 추적 * * @param eventName 이벤트 이름 * @param data 전달 데이터 * @param listenerCount 리스너 수 * @param results 리스너 결과 * @param error 에러 (있는 경우) */ trackEventEmit( eventName: string, data: any, listenerCount: number, results?: any[], error?: Error ): void { if (!this.isEnabled) return; const log: ComponentEventEmitLog = { id: `evt_${++this.componentEventIdCounter}`, eventName, data: this.sanitizeForLogging(data), timestamp: Date.now(), listenerCount, results: results ? this.sanitizeForLogging(results) : undefined, hasError: !!error, errorMessage: error?.message, }; this.componentEventEmitHistory.push(log); // 최대 이력 개수 제한 if (this.componentEventEmitHistory.length > this.maxComponentEventHistory) { this.componentEventEmitHistory = this.componentEventEmitHistory.slice(-this.maxComponentEventHistory); } } /** * 특정 이벤트의 모든 리스너 제거 추적 * * @param eventName 이벤트 이름 */ trackEventOff(eventName: string): void { if (!this.isEnabled) return; this.componentEventSubscriptions.delete(eventName); } /** * 모든 이벤트 리스너 제거 추적 */ trackEventClear(): void { if (!this.isEnabled) return; this.componentEventSubscriptions.clear(); } /** * 컴포넌트 이벤트 정보 조회 */ getComponentEventInfo(): ComponentEventInfo { const subscriptions = Array.from(this.componentEventSubscriptions.values()); return { subscriptions, emitHistory: [...this.componentEventEmitHistory], totalSubscribers: subscriptions.reduce((sum, s) => sum + s.subscriberCount, 0), totalEmits: this.componentEventEmitHistory.length, }; } /** * 이벤트 emit 이력 조회 */ getEventEmitHistory(): ComponentEventEmitLog[] { return [...this.componentEventEmitHistory]; } /** * 이벤트 구독 목록 조회 */ getEventSubscriptions(): ComponentEventSubscription[] { return Array.from(this.componentEventSubscriptions.values()); } /** * 컴포넌트 이벤트 초기화 */ clearComponentEvents(): void { this.componentEventSubscriptions.clear(); this.componentEventEmitHistory = []; this.componentEventIdCounter = 0; } /** * 로깅용 데이터 정제 (순환 참조 제거) */ private sanitizeForLogging(data: any): any { if (data === null || data === undefined) return data; if (typeof data !== 'object') return data; try { return JSON.parse(JSON.stringify(data)); } catch { return '[Circular or Non-serializable]'; } } // ============================================ // 상태-렌더링 추적 메서드 // ============================================ /** * 상태 변경 시작 추적 * * setState 호출 시 ActionDispatcher에서 호출됩니다. * * @param statePath 변경된 상태 경로 * @param oldValue 이전 값 * @param newValue 새 값 * @param trigger 트리거 정보 (액션 ID, 핸들러 타입 등) * @returns setState ID (렌더링 추적에 사용) */ startStateChange( statePath: string, oldValue: any, newValue: any, trigger?: { actionId?: string; handlerType?: string; source?: string } ): string { if (!this.config.enabled) return ''; const setStateId = `setState_${++this.stateRenderingIdCounter}`; this.currentStateChangeContext = { setStateId, startTime: performance.now(), changedPath: statePath, trigger: trigger || {}, renderedComponents: [], }; // 상태 변경 로그 생성 (렌더링 완료 시 업데이트됨) const log: StateRenderingLog = { id: `sr_${this.stateRenderingIdCounter}`, setStateId, statePath, oldValue: this.sanitizeForLogging(oldValue), newValue: this.sanitizeForLogging(newValue), timestamp: Date.now(), triggeredBy: trigger || {}, renderedComponents: [], totalRenderDuration: 0, affectedBindingsCount: 0, }; this.stateRenderingLogs.push(log); // 로그 크기 제한 if (this.stateRenderingLogs.length > this.maxStateRenderingLogs) { this.stateRenderingLogs.shift(); } return setStateId; } /** * 컴포넌트 렌더링 추적 (상태 변경 컨텍스트 내에서) * * 상태 변경으로 인해 컴포넌트가 렌더링될 때 호출됩니다. * * @param componentId 컴포넌트 ID * @param componentName 컴포넌트 이름 * @param renderDuration 렌더링 소요 시간 * @param accessedStatePaths 접근한 상태 경로들 * @param evaluatedBindings 평가된 바인딩 표현식들 * @param parentId 부모 컴포넌트 ID */ trackComponentRender( componentId: string, componentName: string, renderDuration: number, accessedStatePaths: string[] = [], evaluatedBindings: string[] = [], parentId?: string ): void { if (!this.config.enabled) return; // 컴포넌트별 렌더링 횟수 증가 const count = this.componentRenderCounts.get(componentName) || 0; this.componentRenderCounts.set(componentName, count + 1); // 상태 경로 → 컴포넌트 매핑 업데이트 for (const path of accessedStatePaths) { if (!this.stateToComponentMap.has(path)) { this.stateToComponentMap.set(path, new Set()); } this.stateToComponentMap.get(path)!.add(componentName); } // 현재 상태 변경 컨텍스트가 있으면 렌더링 정보 추가 if (this.currentStateChangeContext) { const renderInfo: RenderedComponentInfo = { componentId, componentName, renderDuration, accessedStatePaths, evaluatedBindings, renderOrder: this.currentStateChangeContext.renderedComponents.length, parentId, }; this.currentStateChangeContext.renderedComponents.push(renderInfo); } // 기본 렌더링 추적도 유지 this.trackRender(componentName); } /** * 상태 변경 완료 추적 * * 상태 변경으로 인한 모든 렌더링이 완료된 후 호출됩니다. * * @param setStateId 시작 시 반환받은 setState ID */ completeStateChange(setStateId: string): void { if (!this.config.enabled || !this.currentStateChangeContext) return; if (this.currentStateChangeContext.setStateId !== setStateId) return; const context = this.currentStateChangeContext; const endTime = performance.now(); const totalDuration = endTime - context.startTime; // 해당 로그 찾아서 업데이트 const logIndex = this.stateRenderingLogs.findIndex( log => log.setStateId === setStateId ); if (logIndex >= 0) { const log = this.stateRenderingLogs[logIndex]; log.renderedComponents = context.renderedComponents; log.totalRenderDuration = totalDuration; log.affectedBindingsCount = context.renderedComponents.reduce( (sum, c) => sum + c.evaluatedBindings.length, 0 ); } // 컨텍스트 초기화 this.currentStateChangeContext = null; } /** * 현재 상태 변경 컨텍스트 조회 */ getCurrentStateChangeContext(): StateChangeContext | null { return this.currentStateChangeContext; } /** * 상태-렌더링 로그 조회 */ getStateRenderingLogs(): StateRenderingLog[] { return [...this.stateRenderingLogs]; } /** * 상태-렌더링 정보 전체 조회 */ getStateRenderingInfo(): StateRenderingInfo { const logs = this.stateRenderingLogs; const componentCounts = Object.fromEntries(this.componentRenderCounts); const stateToComponent: Record = {}; for (const [path, components] of this.stateToComponentMap) { stateToComponent[path] = Array.from(components); } return { logs, componentRenderCounts: componentCounts, stateToComponentMap: stateToComponent, stats: this.calculateStateRenderingStats(), }; } /** * 상태-렌더링 통계 계산 */ private calculateStateRenderingStats(): StateRenderingStats { const logs = this.stateRenderingLogs; if (logs.length === 0) { return { totalStateChanges: 0, totalRenders: 0, avgRenderDuration: 0, avgComponentsPerChange: 0, topRenderedComponents: [], topInfluentialPaths: [], }; } // 총 렌더링 수 const totalRenders = logs.reduce( (sum, log) => sum + log.renderedComponents.length, 0 ); // 평균 렌더링 시간 const avgRenderDuration = logs.reduce((sum, log) => sum + log.totalRenderDuration, 0) / logs.length; // 상태 변경당 평균 컴포넌트 수 const avgComponentsPerChange = totalRenders / logs.length; // 가장 많이 렌더링된 컴포넌트 TOP 5 const sortedComponents = Array.from(this.componentRenderCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([name, count]) => ({ name, count })); // 가장 영향력 있는 상태 경로 TOP 5 const sortedPaths = Array.from(this.stateToComponentMap.entries()) .sort((a, b) => b[1].size - a[1].size) .slice(0, 5) .map(([path, components]) => ({ path, affectedComponents: components.size, })); return { totalStateChanges: logs.length, totalRenders, avgRenderDuration, avgComponentsPerChange, topRenderedComponents: sortedComponents, topInfluentialPaths: sortedPaths, }; } /** * 특정 상태 경로에 영향받는 컴포넌트 조회 */ getComponentsAffectedByState(statePath: string): string[] { const components = this.stateToComponentMap.get(statePath); return components ? Array.from(components) : []; } /** * 특정 컴포넌트의 렌더링 이력 조회 */ getComponentRenderHistory(componentName: string): StateRenderingLog[] { return this.stateRenderingLogs.filter(log => log.renderedComponents.some(c => c.componentName === componentName) ); } /** * 상태-렌더링 추적 데이터 초기화 */ clearStateRenderingLogs(): void { this.stateRenderingLogs = []; this.componentRenderCounts.clear(); this.stateToComponentMap.clear(); this.currentStateChangeContext = null; this.stateRenderingIdCounter = 0; } // ============================================ // 상태 계층 추적 메서드 // ============================================ /** * 컴포넌트의 상태 소스 추적 * * 컴포넌트가 어떤 상태 소스에서 데이터를 읽는지 추적합니다. * * @param componentId 컴포넌트 ID * @param componentName 컴포넌트 이름 * @param stateSource 상태 소스 정보 * @param stateProvider 상태 제공자 정보 */ trackComponentStateSource( componentId: string, componentName: string, stateSource: { global: string[]; local: string[]; context: string[]; }, stateProvider: { type: 'globalState' | 'dynamicState' | 'componentContext'; hasComponentContext: boolean; parentId?: string; } ): void { if (!this.config.enabled) return; this.componentStateSources.set(componentId, { componentId, componentName, stateSource, stateProvider, }); } /** * dynamicState 업데이트 추적 * * 컴포넌트의 dynamicState가 업데이트될 때 호출됩니다. * * @param componentId 컴포넌트 ID * @param dynamicState 동적 상태 값 */ trackDynamicState(componentId: string, dynamicState: Record): void { if (!this.config.enabled) return; this.dynamicStates.set(componentId, dynamicState); } /** * componentContext 흐름 추적 * * 컴포넌트가 context를 받고 전달하는 과정을 추적합니다. * * @param componentId 컴포넌트 ID * @param componentName 컴포넌트 이름 * @param contextReceived context를 받았는지 * @param passedToChildren 자식에게 전달했는지 * @param usedInRender 렌더링에 사용했는지 * @param parentId 부모 컴포넌트 ID */ trackContextFlow( componentId: string, componentName: string, contextReceived: boolean, passedToChildren: boolean, usedInRender: boolean, parentId?: string ): void { if (!this.config.enabled) return; const node: ContextFlowNode = { component: componentName, componentId, contextReceived, passedToChildren, usedInRender, }; this.contextFlowNodes.set(componentId, node); // 부모 노드가 있으면 자식으로 연결 if (parentId) { const parentNode = this.contextFlowNodes.get(parentId); if (parentNode) { if (!parentNode.children) { parentNode.children = []; } parentNode.children.push(node); } } } /** * 상태 계층 정보 조회 * * 현재 상태 계층, 충돌 감지, 컴포넌트별 상태 소스 정보를 반환합니다. * 덤프 시에는 간소화된 버전을 사용하여 파일 크기를 줄입니다. */ getStateHierarchyInfo(summarize = true): StateHierarchyInfo { const layers = this.buildStateHierarchyLayers(); const conflicts = this.detectStateConflicts(layers); const componentStateSources = Array.from(this.componentStateSources.values()); // 덤프 시 간소화: 큰 values 객체를 요약 const summarizedLayers = summarize ? layers.map(layer => ({ ...layer, values: this.summarizeValues(layer.values, 2), })) : layers; return { layers: summarizedLayers, conflicts, componentStateSources, timestamp: Date.now(), }; } /** * 값을 요약하여 덤프 크기 감소 * * - 배열: 처음 3개 항목 + 길이 정보 * - 객체: 지정된 깊이까지만 전개 * - 문자열: 100자 제한 */ private summarizeValues(obj: any, maxDepth: number, currentDepth = 0): any { if (obj === null || obj === undefined) return obj; // 기본 타입 if (typeof obj !== 'object') { if (typeof obj === 'string' && obj.length > 100) { return obj.substring(0, 100) + `... (${obj.length}자)`; } return obj; } // 깊이 제한 도달 if (currentDepth >= maxDepth) { if (Array.isArray(obj)) { return `[Array(${obj.length})]`; } const keys = Object.keys(obj); return `{${keys.slice(0, 5).join(', ')}${keys.length > 5 ? `, ... +${keys.length - 5}` : ''}}`; } // 배열 처리 if (Array.isArray(obj)) { if (obj.length <= 3) { return obj.map(item => this.summarizeValues(item, maxDepth, currentDepth + 1)); } return { __type: 'array', __length: obj.length, __preview: obj.slice(0, 3).map(item => this.summarizeValues(item, maxDepth, currentDepth + 1)), }; } // 객체 처리 const result: Record = {}; const keys = Object.keys(obj); for (const key of keys) { result[key] = this.summarizeValues(obj[key], maxDepth, currentDepth + 1); } return result; } /** * 상태 계층 레이어 빌드 */ private buildStateHierarchyLayers(): StateHierarchyLayer[] { const layers: StateHierarchyLayer[] = []; try { const g7Core = (window as any).G7Core; const state = g7Core?.state?.get?.(); if (!state) return layers; // 1. Global _local 레이어 if (state._local) { layers.push({ name: 'Global _local', type: 'global', path: '_local', values: state._local, priority: 1, }); } // 2. Global _global 레이어 if (state._global) { layers.push({ name: 'Global _global', type: 'global', path: '_global', values: state._global, priority: 1, }); } // 3. DynamicState 레이어들 (우선순위 높음) for (const [componentId, dynamicState] of this.dynamicStates) { if (dynamicState && Object.keys(dynamicState).length > 0) { layers.push({ name: `DynamicState (${componentId})`, type: 'dynamicState', componentId, values: dynamicState, priority: 2, }); } } // 4. Effective (병합된) 상태 계산 const effectiveLocal = this.computeEffectiveState(state._local || {}); layers.push({ name: 'Effective _local (merged)', type: 'effective', values: effectiveLocal, priority: 3, }); } catch (error) { console.warn('[G7DevTools] 상태 계층 빌드 실패:', error); } return layers; } /** * 실제 적용되는 상태 계산 * * global _local과 dynamicState를 병합한 실제 상태를 계산합니다. */ private computeEffectiveState(globalLocal: Record): Record { const effective = { ...globalLocal }; // 모든 dynamicState를 병합 (우선순위 높음) for (const [, dynamicState] of this.dynamicStates) { if (dynamicState._local) { Object.assign(effective, dynamicState._local); } } return effective; } /** * 상태 충돌 감지 * * 여러 레이어에서 동일한 상태 경로가 다른 값을 가지는 경우를 감지합니다. */ private detectStateConflicts(layers: StateHierarchyLayer[]): StateConflict[] { const conflicts: StateConflict[] = []; // Global _local 레이어 찾기 const globalLocalLayer = layers.find(l => l.type === 'global' && l.path === '_local'); if (!globalLocalLayer) return conflicts; // 각 dynamicState와 비교 const dynamicLayers = layers.filter(l => l.type === 'dynamicState'); for (const dynamicLayer of dynamicLayers) { const dynamicLocal = dynamicLayer.values._local || dynamicLayer.values; for (const [key, dynamicValue] of Object.entries(dynamicLocal)) { const globalValue = globalLocalLayer.values[key]; // 값이 다르면 충돌 if (JSON.stringify(globalValue) !== JSON.stringify(dynamicValue)) { // 이 상태를 사용하는/못하는 컴포넌트 찾기 const usedBy: string[] = []; const notUsedBy: string[] = []; for (const source of this.componentStateSources.values()) { const usesLocal = source.stateSource.local.some( p => p === `_local.${key}` || p === key ); if (usesLocal) { if (source.stateProvider.type === 'dynamicState') { usedBy.push(source.componentName); } else if (source.stateProvider.type === 'globalState') { notUsedBy.push(source.componentName); } } } conflicts.push({ path: `_local.${key}`, globalValue, dynamicStateValue: dynamicValue, effectiveValue: dynamicValue, // dynamicState가 우선 usedBy, notUsedBy, severity: notUsedBy.length > 0 ? 'warning' : 'info', description: notUsedBy.length > 0 ? `${notUsedBy.join(', ')}은(는) globalState._local을 읽어 dynamicState 값을 사용하지 못함` : 'dynamicState 값이 globalState와 다르지만 모든 컴포넌트가 dynamicState를 사용', }); } } } return conflicts; } /** * componentContext 흐름 정보 조회 */ getContextFlowInfo(): ContextFlowInfo { // 루트 노드 찾기 (부모가 없는 노드) const rootNodes: ContextFlowNode[] = []; const allIds = new Set(this.contextFlowNodes.keys()); for (const node of this.contextFlowNodes.values()) { if (node.children) { for (const child of node.children) { allIds.delete(child.componentId); } } } // 남은 ID가 루트 노드 for (const id of allIds) { const node = this.contextFlowNodes.get(id); if (node) { rootNodes.push(node); } } return { rootComponent: rootNodes.length > 0 ? rootNodes[0].component : 'Unknown', contextFlow: rootNodes, timestamp: Date.now(), }; } /** * 상태 계층 추적 데이터 초기화 */ clearStateHierarchyData(): void { this.componentStateSources.clear(); this.contextFlowNodes.clear(); this.dynamicStates.clear(); } // ============================================ // CSS 스타일 검증 메서드 // ============================================ /** * 컴포넌트 스타일 정보 추적 */ trackComponentStyle( componentId: string, componentName: string, classes: string[], computedStyles: ComponentStyleInfo['computedStyles'] ): void { if (!this.config.enabled) return; // Tailwind 클래스 분석 const tailwindAnalysis = this.analyzeTailwindClasses(classes); const styleInfo: ComponentStyleInfo = { componentId, componentName, classes, computedStyles, tailwindAnalysis, }; this.componentStyles.set(componentId, styleInfo); // 스타일 이슈 감지 this.detectStyleIssues(styleInfo); } /** * Tailwind 클래스 분석 */ private analyzeTailwindClasses(classes: string[]): ComponentStyleInfo['tailwindAnalysis'] { const darkClasses: string[] = []; const responsiveClasses: string[] = []; const dynamicClasses: string[] = []; const usedClasses: string[] = []; const responsivePrefixes = ['sm:', 'md:', 'lg:', 'xl:', '2xl:']; const dynamicPatterns = [/bg-\w+-\d+/, /text-\w+-\d+/, /border-\w+-\d+/]; for (const cls of classes) { usedClasses.push(cls); if (cls.startsWith('dark:')) { darkClasses.push(cls); } for (const prefix of responsivePrefixes) { if (cls.startsWith(prefix)) { responsiveClasses.push(cls); break; } } // 동적 클래스 감지 (숫자로 끝나는 컬러 클래스) for (const pattern of dynamicPatterns) { if (pattern.test(cls)) { dynamicClasses.push(cls); break; } } } return { usedClasses, darkClasses, responsiveClasses, dynamicClasses, }; } /** * 스타일 이슈 감지 */ private detectStyleIssues(styleInfo: ComponentStyleInfo): void { const { componentId, componentName, computedStyles, tailwindAnalysis } = styleInfo; // 1. 보이지 않는 요소 감지 if ( computedStyles.opacity === '0' || computedStyles.visibility === 'hidden' || computedStyles.display === 'none' || (computedStyles.width === '0px' && computedStyles.height === '0px') ) { this.addStyleIssue({ id: this.generateId(), type: 'invisible-element', componentId, componentName, property: computedStyles.opacity === '0' ? 'opacity' : computedStyles.visibility === 'hidden' ? 'visibility' : computedStyles.display === 'none' ? 'display' : 'width/height', currentValue: computedStyles.opacity === '0' ? '0' : computedStyles.visibility === 'hidden' ? 'hidden' : computedStyles.display === 'none' ? 'none' : '0px', severity: 'warning', description: '요소가 화면에 보이지 않습니다.', suggestion: 'opacity, visibility, display, width/height 값을 확인하세요.', }); } // 2. 다크 모드 클래스 누락 감지 (배경/텍스트 컬러에 대해) const bgClasses = tailwindAnalysis.usedClasses.filter(c => c.startsWith('bg-') && !c.startsWith('dark:')); const darkBgClasses = tailwindAnalysis.darkClasses.filter(c => c.includes('bg-')); if (bgClasses.length > 0 && darkBgClasses.length === 0) { this.addStyleIssue({ id: this.generateId(), type: 'dark-mode-missing', componentId, componentName, property: 'background-color', currentValue: bgClasses.join(', '), expectedValue: 'dark:bg-* 클래스 필요', severity: 'info', description: '다크 모드 배경색 클래스가 누락되었습니다.', suggestion: `${bgClasses[0]}에 대응하는 dark: 클래스를 추가하세요.`, }); } // 3. 동적 클래스 경고 (Tailwind purging 가능성) if (tailwindAnalysis.dynamicClasses.length > 0) { this.addStyleIssue({ id: this.generateId(), type: 'tailwind-purging', componentId, componentName, property: 'class', currentValue: tailwindAnalysis.dynamicClasses.join(', '), severity: 'info', description: '동적으로 생성된 Tailwind 클래스가 있습니다.', suggestion: 'tailwind.config.js의 safelist에 추가하거나 전체 클래스명을 상수로 정의하세요.', }); } } /** * 스타일 이슈 추가 */ private addStyleIssue(issue: StyleIssue): void { // 중복 방지 const exists = this.styleIssues.some( i => i.componentId === issue.componentId && i.type === issue.type && i.property === issue.property ); if (!exists) { this.styleIssues.push(issue); } } /** * 스타일 검증 정보 반환 */ getStyleValidationInfo(): StyleValidationInfo { const componentStylesArray = Array.from(this.componentStyles.values()); return { issues: [...this.styleIssues], componentStyles: componentStylesArray, stats: { totalComponents: componentStylesArray.length, invisibleCount: this.styleIssues.filter(i => i.type === 'invisible-element').length, tailwindIssueCount: this.styleIssues.filter(i => i.type === 'tailwind-purging').length, darkModeIssueCount: this.styleIssues.filter(i => i.type === 'dark-mode-missing').length, }, timestamp: Date.now(), }; } /** * 스타일 검증 데이터 초기화 */ clearStyleValidationData(): void { this.styleIssues = []; this.componentStyles.clear(); } // ============================================ // 인증 디버깅 메서드 // ============================================ /** * 인증 이벤트 로깅 */ trackAuthEvent( type: AuthEventType, success: boolean, error?: string, details?: Record ): void { if (!this.config.enabled) return; const event: AuthEventLog = { id: `auth_${++this.authEventIdCounter}`, type, timestamp: Date.now(), success, error, details, }; this.authEvents.push(event); // 히스토리 제한 if (this.authEvents.length > this.maxAuthEventHistory) { this.authEvents = this.authEvents.slice(-this.maxAuthEventHistory); } } /** * API 인증 헤더 분석 추적 */ trackAuthHeader( url: string, hasAuthHeader: boolean, headerType?: string, tokenValid?: boolean, responseStatus?: number ): void { if (!this.config.enabled) return; const analysis: AuthHeaderAnalysis = { url, hasAuthHeader, headerType, tokenValid: tokenValid ?? false, responseStatus, timestamp: Date.now(), }; this.authHeaderHistory.push(analysis); // 히스토리 제한 if (this.authHeaderHistory.length > this.maxAuthEventHistory) { this.authHeaderHistory = this.authHeaderHistory.slice(-this.maxAuthEventHistory); } // 401 응답 시 자동으로 이벤트 로깅 if (responseStatus === 401) { this.trackAuthEvent('api-unauthorized', false, `401 Unauthorized: ${url}`, { url }); } } /** * 인증 상태 정보 가져오기 */ private getAuthState(): AuthStateInfo { try { const g7Core = (window as any).G7Core; const apiClient = g7Core?.api; // AuthManager 인스턴스 가져오기 (G7Core.auth가 없으면 AuthManager.getInstance() 사용) let auth = g7Core?.auth; if (!auth && g7Core?.AuthManager?.getInstance) { auth = g7Core.AuthManager.getInstance(); } // 토큰 정보 확인 (ApiClient의 TOKEN_KEY = 'auth_token') const accessToken = apiClient?.getToken?.() || auth?.getAccessToken?.() || localStorage.getItem('auth_token') || localStorage.getItem('access_token'); const refreshToken = auth?.getRefreshToken?.() || localStorage.getItem('refresh_token'); // 사용자 정보 소스 추적 let source: 'AuthManager' | 'G7Core.state' | 'unknown' = 'unknown'; // 사용자 정보 - AuthManager의 getUser() 먼저 확인 let user = auth?.getUser?.(); if (user) { source = 'AuthManager'; } // AuthManager에 user가 없으면 G7Core state에서 확인 if (!user) { const state = g7Core?.state?.get?.(); // user 또는 currentUser 필드 확인 (Admin: user, User: currentUser) user = state?._global?.user || state?._global?.currentUser || state?.user || state?.currentUser; if (user) { source = 'G7Core.state'; } } // 여전히 없으면 AuthManager의 isAuthenticated() 상태 확인 const isAuthManagerAuthenticated = auth?.isAuthenticated?.() ?? false; // storage 위치 확인 let storage: 'localStorage' | 'sessionStorage' | 'memory' = 'memory'; if (localStorage.getItem('auth_token') || localStorage.getItem('access_token')) { storage = 'localStorage'; } else if (sessionStorage.getItem('auth_token') || sessionStorage.getItem('access_token')) { storage = 'sessionStorage'; } // 인증 여부: 토큰이 있고 (user가 있거나 AuthManager가 인증됨) const isAuthenticated = !!accessToken && (!!user || isAuthManagerAuthenticated); // 토큰 미리보기 (앞 10자 + ... + 뒤 6자) let accessTokenPreview: string | undefined; if (accessToken && typeof accessToken === 'string' && accessToken.length > 20) { accessTokenPreview = `${accessToken.substring(0, 10)}...${accessToken.substring(accessToken.length - 6)}`; } else if (accessToken) { accessTokenPreview = '***'; } // 사용자 정보 상세 구성 const userInfo = user ? { id: user.id, email: user.email, name: user.name, // 역할 정보 상세 roles: user.roles?.map((r: any) => { if (typeof r === 'string') { return { name: r }; } return { id: r.id, name: r.name, guard_name: r.guard_name, }; }), // 권한 정보 상세 permissions: user.permissions?.map((p: any) => { if (typeof p === 'string') { return { name: p }; } return { id: p.id, name: p.name, guard_name: p.guard_name, }; }), // 추가 사용자 정보 avatar: user.avatar || user.profile_photo_url || user.profile_image, created_at: user.created_at, updated_at: user.updated_at, last_login_at: user.last_login_at, email_verified_at: user.email_verified_at, // 기타 필드 (동적) ...(user.phone && { phone: user.phone }), ...(user.nickname && { nickname: user.nickname }), ...(user.status && { status: user.status }), ...(user.locale && { locale: user.locale }), } : undefined; return { isAuthenticated, user: userInfo, tokens: { hasAccessToken: !!accessToken, hasRefreshToken: !!refreshToken, storage, accessTokenPreview, }, lastActivity: Date.now(), source, }; } catch { return { isAuthenticated: false, tokens: { hasAccessToken: false, hasRefreshToken: false, storage: 'memory', }, source: 'unknown', }; } } /** * 인증 디버깅 정보 반환 */ getAuthDebugInfo(): AuthDebugInfo { const events = [...this.authEvents]; return { state: this.getAuthState(), events, headerAnalysis: [...this.authHeaderHistory], stats: { loginAttempts: events.filter(e => e.type === 'login').length, successfulLogins: events.filter(e => e.type === 'login' && e.success).length, failedLogins: events.filter(e => e.type === 'login' && !e.success).length, tokenRefreshes: events.filter(e => e.type === 'token-refresh').length, unauthorizedResponses: events.filter(e => e.type === 'api-unauthorized').length, }, timestamp: Date.now(), }; } /** * 인증 디버깅 데이터 초기화 */ clearAuthData(): void { this.authEvents = []; this.authHeaderHistory = []; this.authEventIdCounter = 0; } // ============================================ // 레이아웃 추적 메서드 // ============================================ /** * 레이아웃 로드 추적 * * LayoutLoader에서 레이아웃 로드 완료 시 호출됩니다. * 현재 렌더링 중인 레이아웃의 전체 JSON을 저장합니다. * * @param layoutPath 레이아웃 경로 (예: "admin/admin_dashboard") * @param templateId 템플릿 ID (예: "sirsoft-admin_basic") * @param layoutJson 전체 레이아웃 JSON 데이터 * @param source 로드 소스 ('cache' | 'api') */ trackLayoutLoad( layoutPath: string, templateId: string, layoutJson: LayoutJsonData, source: 'cache' | 'api' = 'api' ): void { if (!this.config.enabled) return; const now = Date.now(); // 현재 레이아웃 저장 this.currentLayout = { layoutPath, templateId, layoutJson: this.sanitizeObject(layoutJson), loadedAt: now, version: layoutJson.version, layoutName: layoutJson.layout_name, source, }; // 이력에 추가 const historyEntry: LayoutHistoryEntry = { id: `layout-${++this.layoutIdCounter}`, layoutPath, templateId, loadedAt: now, source, version: layoutJson.version, }; this.layoutHistory.push(historyEntry); // 이력 크기 제한 if (this.layoutHistory.length > this.maxLayoutHistory) { this.layoutHistory.shift(); } // 통계 업데이트 this.layoutStats.totalLoads++; if (source === 'cache') { this.layoutStats.cacheHits++; } else { this.layoutStats.apiLoads++; } } /** * 현재 레이아웃 조회 * * @returns 현재 렌더링 중인 레이아웃 정보 또는 null */ getCurrentLayout(): CurrentLayoutInfo | null { return this.currentLayout; } /** * 레이아웃 이력 조회 * * @returns 레이아웃 로드 이력 */ getLayoutHistory(): LayoutHistoryEntry[] { return [...this.layoutHistory]; } /** * 레이아웃 디버깅 정보 반환 (상태 덤프용) * * @returns 레이아웃 디버깅 정보 */ getLayoutDebugInfo(): LayoutDebugInfo { return { current: this.currentLayout, history: [...this.layoutHistory], stats: { ...this.layoutStats }, }; } /** * 레이아웃 데이터 초기화 */ clearLayoutData(): void { this.currentLayout = null; this.layoutHistory = []; this.layoutIdCounter = 0; this.layoutStats = { totalLoads: 0, cacheHits: 0, apiLoads: 0 }; } // ============================================ // 변경 감지 메서드 // ============================================ /** * 핸들러 실행 시작 기록 * * @param handlerName 핸들러 이름 * @returns 실행 ID (종료 시 사용) */ startHandlerExecution(handlerName: string): string { if (!this.config.enabled) return ''; const executionId = `exec_${++this.executionIdCounter}_${Date.now()}`; const detail: HandlerExecutionDetail = { handlerName, executionId, startTime: Date.now(), stateChanges: [], dataSourceChanges: [], alerts: [], }; this.executionDetails.set(executionId, detail); // 오래된 항목 정리 if (this.executionDetails.size > this.maxExecutionDetails) { const keys = Array.from(this.executionDetails.keys()); const toRemove = keys.slice(0, keys.length - this.maxExecutionDetails); toRemove.forEach(key => this.executionDetails.delete(key)); } return executionId; } /** * 핸들러 실행 종료 기록 * * @param executionId 실행 ID * @param exitReason 종료 사유 * @param exitLocation 종료 위치 (선택) * @param exitDescription 종료 설명 (선택) */ endHandlerExecution( executionId: string, exitReason: HandlerExitReason, exitLocation?: string, exitDescription?: string ): void { if (!this.config.enabled || !executionId) return; const detail = this.executionDetails.get(executionId); if (!detail) return; detail.endTime = Date.now(); detail.duration = detail.endTime - detail.startTime; detail.exitReason = exitReason; detail.exitLocation = exitLocation; detail.exitDescription = exitDescription; // 기대 변경 확인 및 알림 생성 const expectation = this.changeExpectations.get(executionId); if (expectation) { detail.expectedChanges = expectation; this.checkExpectations(executionId, detail, expectation); this.changeExpectations.delete(executionId); } // 상태 변경 없이 성공 종료된 경우 알림 if (exitReason === 'normal' && detail.stateChanges.length === 0 && detail.dataSourceChanges.length === 0) { this.addChangeAlert(executionId, { type: 'no-state-change', severity: 'warning', message: `핸들러 "${detail.handlerName}"이(가) 상태 변경 없이 완료됨`, description: '핸들러가 성공적으로 완료되었지만 상태나 데이터소스 변경이 없습니다. 의도한 동작인지 확인하세요.', handlerName: detail.handlerName, suggestion: '핸들러 내부 조건문을 확인하거나 DevTools의 "변경감지" 탭에서 exitLocation을 확인하세요.', docLink: 'troubleshooting-state.md', }); } // early return 감지 시 알림 if (exitReason === 'early-return-condition' || exitReason === 'early-return-validation') { this.addChangeAlert(executionId, { type: 'early-return-detected', severity: 'info', message: `핸들러 "${detail.handlerName}"이(가) early return으로 종료됨`, description: exitDescription || `종료 위치: ${exitLocation || '알 수 없음'}`, handlerName: detail.handlerName, suggestion: exitReason === 'early-return-validation' ? '검증 실패로 인한 early return입니다. 입력 데이터를 확인하세요.' : '조건부 early return입니다. 조건문의 평가 결과를 확인하세요.', }); } } /** * 종료 사유 기록 (핸들러 내부에서 호출) * * @param executionId 실행 ID * @param exitReason 종료 사유 * @param location 종료 위치 * @param description 상세 설명 */ recordExitReason( executionId: string, exitReason: HandlerExitReason, location: string, description?: string ): void { if (!this.config.enabled || !executionId) return; const detail = this.executionDetails.get(executionId); if (!detail) return; detail.exitReason = exitReason; detail.exitLocation = location; detail.exitDescription = description; } /** * 상태 변경 기록 * * @param executionId 관련 핸들러 실행 ID (선택) * @param path 상태 경로 * @param changeType 변경 유형 * @param oldValue 이전 값 * @param newValue 새 값 */ recordStateChange( executionId: string | undefined, path: string, changeType: StateChangeRecord['changeType'], oldValue: any, newValue: any ): void { if (!this.config.enabled) return; const record: StateChangeRecord = { id: `sc_${++this.stateChangeIdCounter}`, path, changeType, oldValue: this.safeClone(oldValue), newValue: this.safeClone(newValue), timestamp: Date.now(), comparison: this.compareValues(oldValue, newValue), executionId, }; this.stateChangeHistory.push(record); // 핸들러 실행과 연결 if (executionId) { const detail = this.executionDetails.get(executionId); if (detail) { detail.stateChanges.push(record); } } // 히스토리 크기 제한 if (this.stateChangeHistory.length > this.maxStateChangeHistory) { this.stateChangeHistory.splice(0, this.stateChangeHistory.length - this.maxStateChangeHistory); } // 객체 참조가 동일한 경우 알림 if (record.comparison.isDeepEqual && typeof oldValue === 'object' && oldValue !== null) { this.addChangeAlert(executionId || '', { type: 'object-reference-same', severity: 'warning', message: `상태 "${path}"가 변경되었으나 값이 동일함`, description: '객체 참조가 동일하거나 깊은 비교 결과 변경이 없습니다. 변경 감지가 실패할 수 있습니다.', handlerName: executionId ? this.executionDetails.get(executionId)?.handlerName || 'unknown' : 'unknown', statePath: path, suggestion: '불변성을 유지하여 새 객체를 생성하세요. 예: { ...oldObject, newField: value }', docLink: 'troubleshooting-state.md#object-mutation', }); } } /** * 데이터소스 변경 기록 * * @param executionId 관련 핸들러 실행 ID (선택) * @param dataSourceId 데이터소스 ID * @param changeType 변경 유형 * @param previousStatus 이전 상태 * @param newStatus 새 상태 */ recordDataSourceChange( executionId: string | undefined, dataSourceId: string, changeType: DataSourceChangeRecord['changeType'], previousStatus: DataSourceChangeRecord['previousStatus'], newStatus: DataSourceChangeRecord['newStatus'] ): void { if (!this.config.enabled) return; const record: DataSourceChangeRecord = { dataSourceId, changeType, timestamp: Date.now(), previousStatus, newStatus, executionId, }; this.dataSourceChangeHistory.push(record); // 핸들러 실행과 연결 if (executionId) { const detail = this.executionDetails.get(executionId); if (detail) { detail.dataSourceChanges.push(record); } } // 히스토리 크기 제한 if (this.dataSourceChangeHistory.length > this.maxStateChangeHistory) { this.dataSourceChangeHistory.splice(0, this.dataSourceChangeHistory.length - this.maxStateChangeHistory); } } /** * 변경 기대치 설정 (핸들러가 변경을 예고) * * @param executionId 실행 ID * @param expectedStatePaths 기대되는 상태 변경 경로 목록 * @param expectedDataSources 기대되는 데이터소스 갱신 목록 */ expectChange( executionId: string, expectedStatePaths: string[], expectedDataSources: string[] ): void { if (!this.config.enabled || !executionId) return; const detail = this.executionDetails.get(executionId); if (!detail) return; const expectation: ChangeExpectation = { expectedStatePaths, expectedDataSources, setAt: Date.now(), source: detail.handlerName, }; this.changeExpectations.set(executionId, expectation); } /** * 기대 변경 확인 및 알림 생성 */ private checkExpectations( executionId: string, detail: HandlerExecutionDetail, expectation: ChangeExpectation ): void { const actualStatePaths = new Set(detail.stateChanges.map(sc => sc.path)); const actualDataSources = new Set(detail.dataSourceChanges.map(dc => dc.dataSourceId)); // 기대한 상태 변경이 발생하지 않은 경우 for (const expectedPath of expectation.expectedStatePaths) { if (!actualStatePaths.has(expectedPath)) { this.addChangeAlert(executionId, { type: 'expected-not-fulfilled', severity: 'error', message: `기대한 상태 변경 "${expectedPath}"가 발생하지 않음`, description: `핸들러 "${detail.handlerName}"이(가) "${expectedPath}" 변경을 예고했지만 실제로 변경되지 않았습니다.`, handlerName: detail.handlerName, statePath: expectedPath, suggestion: '핸들러 내부 로직을 확인하세요. early return이나 조건부 분기로 인해 변경이 생략되었을 수 있습니다.', docLink: 'troubleshooting-state.md', }); } } // 기대한 데이터소스 갱신이 발생하지 않은 경우 for (const expectedDs of expectation.expectedDataSources) { if (!actualDataSources.has(expectedDs)) { this.addChangeAlert(executionId, { type: 'expected-not-fulfilled', severity: 'error', message: `기대한 데이터소스 갱신 "${expectedDs}"가 발생하지 않음`, description: `핸들러 "${detail.handlerName}"이(가) "${expectedDs}" 갱신을 예고했지만 실제로 갱신되지 않았습니다.`, handlerName: detail.handlerName, dataSourceId: expectedDs, suggestion: 'refetchDataSource 호출이 실행되었는지 확인하세요.', docLink: 'data-sources.md', }); } } } /** * 변경 알림 추가 */ private addChangeAlert( executionId: string, alertData: Omit ): void { const alert: ChangeAlert = { ...alertData, id: `alert_${++this.alertIdCounter}`, timestamp: Date.now(), }; this.changeAlerts.push(alert); // 핸들러 실행과 연결 if (executionId) { const detail = this.executionDetails.get(executionId); if (detail) { detail.alerts.push(alert); } } // 알림 크기 제한 if (this.changeAlerts.length > this.maxChangeAlerts) { this.changeAlerts.splice(0, this.changeAlerts.length - this.maxChangeAlerts); } } /** * 값 비교 */ private compareValues(oldValue: any, newValue: any): StateChangeRecord['comparison'] { const oldType = this.getValueType(oldValue); const newType = this.getValueType(newValue); const typeChanged = oldType !== newType; let isDeepEqual = false; let changedKeys: string[] | undefined; if (!typeChanged && (oldType === 'object' || oldType === 'array')) { isDeepEqual = this.deepEqual(oldValue, newValue); if (!isDeepEqual && oldType === 'object') { changedKeys = this.getChangedKeys(oldValue, newValue); } } else { isDeepEqual = oldValue === newValue; } return { typeChanged, oldType, newType, isDeepEqual, changedKeys, }; } /** * 값의 타입 반환 */ private getValueType(value: any): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (Array.isArray(value)) return 'array'; return typeof value; } /** * 깊은 비교 */ private deepEqual(a: any, b: any): boolean { if (a === b) return true; if (typeof a !== typeof b) return false; if (a === null || b === null) return a === b; if (typeof a !== 'object') return a === b; if (Array.isArray(a) !== Array.isArray(b)) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!keysB.includes(key)) return false; if (!this.deepEqual(a[key], b[key])) return false; } return true; } /** * 변경된 키 목록 반환 */ private getChangedKeys(oldObj: Record, newObj: Record): string[] { const changedKeys: string[] = []; const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); for (const key of allKeys) { if (!this.deepEqual(oldObj[key], newObj[key])) { changedKeys.push(key); } } return changedKeys; } /** * 변경 감지 정보 반환 (상태 덤프용) */ getChangeDetectionInfo(): ChangeDetectionInfo { const details = Array.from(this.executionDetails.values()); // 통계 계산 const stats: ChangeDetectionStats = { totalExecutions: details.length, executionsWithStateChange: details.filter(d => d.stateChanges.length > 0).length, executionsWithoutStateChange: details.filter(d => d.stateChanges.length === 0 && d.exitReason === 'normal').length, earlyReturnCount: details.filter(d => d.exitReason === 'early-return-condition' || d.exitReason === 'early-return-validation' ).length, alertCount: this.changeAlerts.length, alertsByType: this.getAlertsByType(), }; return { executionDetails: details, stateChangeHistory: [...this.stateChangeHistory], dataSourceChangeHistory: [...this.dataSourceChangeHistory], alerts: [...this.changeAlerts], stats, timestamp: Date.now(), }; } /** * 알림 유형별 개수 반환 */ private getAlertsByType(): Record { const result: Record = { 'no-state-change': 0, 'no-datasource-change': 0, 'expected-not-fulfilled': 0, 'object-reference-same': 0, 'early-return-detected': 0, 'async-timing-issue': 0, }; for (const alert of this.changeAlerts) { result[alert.type]++; } return result; } /** * 변경 감지 데이터 초기화 */ clearChangeDetectionData(): void { this.executionDetails.clear(); this.stateChangeHistory = []; this.dataSourceChangeHistory = []; this.changeAlerts = []; this.changeExpectations.clear(); this.executionIdCounter = 0; this.stateChangeIdCounter = 0; this.alertIdCounter = 0; } /** * 최근 알림 반환 * * @param limit 반환할 최대 개수 * @param severity 필터링할 심각도 (선택) */ getRecentAlerts(limit = 10, severity?: ChangeAlert['severity']): ChangeAlert[] { let alerts = [...this.changeAlerts]; if (severity) { alerts = alerts.filter(a => a.severity === severity); } return alerts.slice(-limit); } /** * 특정 핸들러의 실행 상세 반환 * * @param handlerName 핸들러 이름 * @param limit 반환할 최대 개수 */ getHandlerExecutions(handlerName: string, limit = 10): HandlerExecutionDetail[] { const details = Array.from(this.executionDetails.values()) .filter(d => d.handlerName === handlerName) .slice(-limit); return details; } // ============================================ // Sequence 실행 추적 메서드 // ============================================ /** * 스택에서 sequenceId로 실행 정보 찾기 (헬퍼 메서드) */ private findExecutionInStack(sequenceId: string): SequenceExecutionInfo | undefined { return this.sequenceExecutionStack.find(e => e.sequenceId === sequenceId); } /** * Sequence 실행 시작 * * @param trigger 트리거 정보 * @returns 생성된 sequenceId */ startSequenceExecution(trigger?: SequenceExecutionInfo['trigger']): string { if (!this.config.enabled) return ''; const sequenceId = `seq_${++this.sequenceIdCounter}_${Date.now()}`; const execution: SequenceExecutionInfo = { sequenceId, startTime: Date.now(), totalDuration: 0, trigger, actions: [], status: 'running', }; // 스택에 push (중첩 sequence 지원) this.sequenceExecutionStack.push(execution); return sequenceId; } /** * Sequence 내 액션 실행 전 상태 캡처 * * @param sequenceId Sequence ID * @param actionIndex 액션 인덱스 * @param handler 핸들러 이름 * @param params 액션 파라미터 * @param currentState 현재 상태 */ captureSequenceActionBefore( sequenceId: string, actionIndex: number, handler: string, params: Record, currentState: { _global: Record; _local: Record; _isolated?: Record; } ): void { if (!this.config.enabled) return; // 스택에서 해당 sequenceId 찾기 const execution = this.findExecutionInStack(sequenceId); if (!execution) return; const actionInfo: SequenceActionInfo = { index: actionIndex, handler, params: this.safeClone(params), stateBeforeAction: { _global: this.safeClone(currentState._global), _local: this.safeClone(currentState._local), _isolated: currentState._isolated ? this.safeClone(currentState._isolated) : undefined, }, stateAfterAction: { _global: {}, _local: {}, }, duration: 0, status: 'success', }; execution.actions.push(actionInfo); } /** * Sequence 내 액션 실행 후 상태 캡처 * * @param sequenceId Sequence ID * @param actionIndex 액션 인덱스 * @param currentState 현재 상태 * @param duration 소요 시간 * @param result 액션 결과 * @param error 에러 (있는 경우) */ captureSequenceActionAfter( sequenceId: string, actionIndex: number, currentState: { _global: Record; _local: Record; _isolated?: Record; }, duration: number, result?: any, error?: Error ): void { if (!this.config.enabled) return; // 스택에서 해당 sequenceId 찾기 const execution = this.findExecutionInStack(sequenceId); if (!execution) return; const actionInfo = execution.actions.find((a: SequenceActionInfo) => a.index === actionIndex); if (!actionInfo) return; actionInfo.stateAfterAction = { _global: this.safeClone(currentState._global), _local: this.safeClone(currentState._local), _isolated: currentState._isolated ? this.safeClone(currentState._isolated) : undefined, }; actionInfo.duration = duration; actionInfo.result = this.safeClone(result); // 상태 diff 계산 actionInfo.stateDiff = { global: this.computeStateDiff(actionInfo.stateBeforeAction._global, actionInfo.stateAfterAction._global), local: this.computeStateDiff(actionInfo.stateBeforeAction._local, actionInfo.stateAfterAction._local), }; if (actionInfo.stateBeforeAction._isolated && actionInfo.stateAfterAction._isolated) { actionInfo.stateDiff.isolated = this.computeStateDiff( actionInfo.stateBeforeAction._isolated, actionInfo.stateAfterAction._isolated ); } // pending state 캡처 const pendingLocal = (window as any).__g7PendingLocalState; if (pendingLocal) { actionInfo.pendingState = this.safeClone(pendingLocal); } if (error) { actionInfo.status = 'error'; actionInfo.error = { name: error.name, message: error.message, stack: error.stack, }; } } /** * Sequence 실행 완료 * * @param sequenceId Sequence ID * @param error 전체 에러 (있는 경우) */ endSequenceExecution(sequenceId: string, error?: Error): void { if (!this.config.enabled) return; // 스택에서 해당 sequenceId 찾기 (인덱스 필요) const stackIndex = this.sequenceExecutionStack.findIndex(e => e.sequenceId === sequenceId); if (stackIndex === -1) return; const execution = this.sequenceExecutionStack[stackIndex]; execution.endTime = Date.now(); execution.totalDuration = execution.endTime - execution.startTime; if (error) { execution.status = 'error'; execution.error = { name: error.name, message: error.message, stack: error.stack, }; // 실패한 액션 인덱스 찾기 const failedAction = execution.actions.find((a: SequenceActionInfo) => a.status === 'error'); if (failedAction) { execution.failedAtIndex = failedAction.index; } } else { execution.status = 'success'; } // 이력에 추가 this.sequenceExecutions.push(execution); // 최대 크기 제한 if (this.sequenceExecutions.length > this.maxSequenceExecutions) { this.sequenceExecutions.shift(); } // 스택에서 제거 this.sequenceExecutionStack.splice(stackIndex, 1); } /** * 상태 diff 계산 */ private computeStateDiff(before: Record, after: Record): StateDiff { const added: string[] = []; const removed: string[] = []; const changed: Array<{ path: string; oldValue: any; newValue: any }> = []; const beforeKeys = new Set(Object.keys(before)); const afterKeys = new Set(Object.keys(after)); // 추가된 키 for (const key of afterKeys) { if (!beforeKeys.has(key)) { added.push(key); } } // 제거된 키 for (const key of beforeKeys) { if (!afterKeys.has(key)) { removed.push(key); } } // 변경된 키 for (const key of beforeKeys) { if (afterKeys.has(key)) { const oldVal = before[key]; const newVal = after[key]; if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { changed.push({ path: key, oldValue: oldVal, newValue: newVal, }); } } } return { added, removed, changed }; } /** * Sequence 추적 정보 반환 */ getSequenceTrackingInfo(): SequenceTrackingInfo { const stats = this.getSequenceStats(); // 현재 실행 중인 sequence들 (스택의 최상위) const currentExecution = this.sequenceExecutionStack.length > 0 ? this.sequenceExecutionStack[this.sequenceExecutionStack.length - 1] : undefined; return { executions: [...this.sequenceExecutions], currentExecution, stats, }; } /** * Sequence 통계 계산 */ private getSequenceStats(): SequenceStats { const executions = this.sequenceExecutions; const totalExecutions = executions.length; const successCount = executions.filter(e => e.status === 'success').length; const errorCount = executions.filter(e => e.status === 'error').length; const totalActions = executions.reduce((sum, e) => sum + e.actions.length, 0); const avgDuration = totalExecutions > 0 ? executions.reduce((sum, e) => sum + e.totalDuration, 0) / totalExecutions : 0; // 핸들러 사용 빈도 계산 const handlerCounts: Record = {}; for (const execution of executions) { for (const action of execution.actions) { handlerCounts[action.handler] = (handlerCounts[action.handler] || 0) + 1; } } const topHandlers = Object.entries(handlerCounts) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([handler, count]) => ({ handler, count })); return { totalExecutions, successCount, errorCount, totalActions, avgDuration, topHandlers, }; } /** * Sequence 데이터 초기화 */ clearSequenceData(): void { this.sequenceExecutions = []; this.sequenceExecutionStack = []; this.sequenceIdCounter = 0; } /** * 특정 Sequence 실행 정보 반환 * * @param sequenceId Sequence ID */ getSequenceExecution(sequenceId: string): SequenceExecutionInfo | undefined { // 스택에서 먼저 찾기 (실행 중인 sequence) const inStack = this.findExecutionInStack(sequenceId); if (inStack) { return inStack; } // 완료된 이력에서 찾기 return this.sequenceExecutions.find(e => e.sequenceId === sequenceId); } /** * 최근 Sequence 실행 목록 반환 * * @param limit 반환할 최대 개수 */ getRecentSequences(limit = 10): SequenceExecutionInfo[] { return this.sequenceExecutions.slice(-limit); } // ============================================ // Stale Closure 감지 메서드 // ============================================ /** * 상태 캡처 등록 (비동기 핸들러 시작 시 호출) * * @param handlerId 핸들러 ID * @param statePaths 캡처할 상태 경로들 * @param stateValues 현재 상태 값들 */ registerStateCaptureForHandler( handlerId: string, statePaths: string[], stateValues: Record ): void { if (!this.config.enabled) return; const captureMap = new Map(); const now = Date.now(); for (const path of statePaths) { captureMap.set(path, { value: this.safeClone(stateValues[path]), capturedAt: now, }); } this.stateCaptureRegistry.set(handlerId, captureMap); } /** * Stale Closure 감지 (비동기 작업 완료 후 호출) * * @param handlerId 핸들러 ID * @param location 발생 위치 * @param currentState 현재 상태 * @param warningType 경고 유형 * @param actionId 관련 액션 ID */ detectStaleClosure( handlerId: string, location: string, currentState: Record, warningType: StaleClosureWarningType, actionId?: string ): StaleClosureWarning[] { if (!this.config.enabled) return []; const captureMap = this.stateCaptureRegistry.get(handlerId); if (!captureMap) return []; const now = Date.now(); const warnings: StaleClosureWarning[] = []; for (const [path, captured] of captureMap) { const currentValue = this.getValueByPath(currentState, path); const timeDiff = now - captured.capturedAt; // 값이 다르고, 일정 시간이 지났으면 경고 생성 if (!this.isDeepEqual(captured.value, currentValue) && timeDiff > 0) { const warning = this.createStaleClosureWarning( warningType, location, path, captured.value, captured.capturedAt, currentValue, now, timeDiff, actionId ); warnings.push(warning); this.addStaleClosureWarning(warning); } } // 사용 후 캡처 레지스트리 정리 this.stateCaptureRegistry.delete(handlerId); return warnings; } /** * Stale Closure 경고 직접 추가 * * @param info 경고 정보 */ trackStaleClosureWarning(info: { type: StaleClosureWarningType; location: string; capturedPath: string; capturedValue: any; capturedAt: number; currentValue: any; actionId?: string; stackTrace?: string; }): void { if (!this.config.enabled) return; const now = Date.now(); const warning = this.createStaleClosureWarning( info.type, info.location, info.capturedPath, info.capturedValue, info.capturedAt, info.currentValue, now, now - info.capturedAt, info.actionId, info.stackTrace ); this.addStaleClosureWarning(warning); } /** * Stale Closure 경고 생성 */ private createStaleClosureWarning( type: StaleClosureWarningType, location: string, path: string, capturedValue: any, capturedAt: number, currentValue: any, retrievedAt: number, timeDiff: number, actionId?: string, stackTrace?: string ): StaleClosureWarning { const severity = this.getStaleClosureSeverity(type, timeDiff); const { description, suggestion, docLink } = this.getStaleClosureMessages(type, path, timeDiff); return { id: `stale_${++this.staleClosureIdCounter}_${Date.now()}`, timestamp: Date.now(), type, location, capturedState: { path, capturedValue: this.safeClone(capturedValue), capturedAt, }, currentState: { path, currentValue: this.safeClone(currentValue), retrievedAt, }, timeDiff, severity, description, suggestion, docLink, actionId, stackTrace, }; } /** * 경고 심각도 결정 */ private getStaleClosureSeverity( type: StaleClosureWarningType, timeDiff: number ): 'info' | 'warning' | 'error' { // 시간 차이가 크면 더 심각 if (timeDiff > 5000) return 'error'; if (timeDiff > 1000) return 'warning'; // 유형별 기본 심각도 switch (type) { case 'async-state-capture': case 'sequence-state-mismatch': return 'warning'; case 'callback-state-capture': case 'timeout-state-capture': return 'info'; case 'event-handler-stale': return 'warning'; default: return 'info'; } } /** * 경고 메시지 생성 */ private getStaleClosureMessages( type: StaleClosureWarningType, path: string, timeDiff: number ): { description: string; suggestion: string; docLink?: string } { const timeStr = timeDiff < 1000 ? `${timeDiff}ms` : `${(timeDiff / 1000).toFixed(1)}s`; switch (type) { case 'async-state-capture': return { description: `await 후 ${timeStr} 경과 시점에 '${path}' 상태가 변경됨. 캡처된 상태 대신 최신 상태를 사용해야 함`, suggestion: `await 이후에는 G7Core.state.get()으로 최신 상태를 다시 조회하거나, useRef + getter 패턴을 사용하세요`, docLink: 'troubleshooting-state-closure.md', }; case 'callback-state-capture': return { description: `콜백에서 ${timeStr} 전에 캡처된 '${path}' 상태를 사용 중. 최신 상태와 다름`, suggestion: `콜백에서 상태를 참조할 때는 stateRef.current 패턴 또는 G7Core.state.get()를 사용하세요`, docLink: 'troubleshooting-state-closure.md', }; case 'timeout-state-capture': return { description: `setTimeout/setInterval 콜백에서 '${path}' 상태가 ${timeStr} 전 값 사용 중`, suggestion: `타이머 콜백 내에서는 G7Core.state.get()으로 최신 상태를 조회하세요`, docLink: 'troubleshooting-state-closure.md', }; case 'sequence-state-mismatch': return { description: `sequence 내 '${path}' 상태가 이전 액션 결과와 불일치. context.state 대신 캡처된 값 사용 의심`, suggestion: `sequence 내에서는 context.state 또는 $prev를 사용하여 최신 상태를 참조하세요`, docLink: 'troubleshooting-state-setstate.md', }; case 'event-handler-stale': return { description: `이벤트 핸들러에서 ${timeStr} 전에 바인딩된 '${path}' 상태 사용 중`, suggestion: `이벤트 핸들러에서 상태를 참조할 때는 useRef 패턴 또는 G7Core.state.get()를 사용하세요`, docLink: 'troubleshooting-state-closure.md', }; default: return { description: `'${path}' 상태에서 stale closure 감지됨 (${timeStr} 경과)`, suggestion: `상태 참조 시 최신 값을 사용하는지 확인하세요`, }; } } /** * 경고 추가 */ private addStaleClosureWarning(warning: StaleClosureWarning): void { this.staleClosureWarnings.push(warning); // 최대 크기 제한 if (this.staleClosureWarnings.length > this.maxStaleClosureWarnings) { this.staleClosureWarnings.shift(); } } /** * 경로로 값 가져오기 */ private getValueByPath(obj: Record, path: string): any { const parts = path.split('.'); let current = obj; for (const part of parts) { if (current === null || current === undefined) return undefined; current = current[part]; } return current; } /** * 깊은 비교 */ private isDeepEqual(a: any, b: any): boolean { if (a === b) return true; if (a === null || b === null) return a === b; if (typeof a !== typeof b) return false; if (typeof a !== 'object') return a === b; try { return JSON.stringify(a) === JSON.stringify(b); } catch { return false; } } /** * Stale Closure 추적 정보 반환 */ getStaleClosureTrackingInfo(): StaleClosureTrackingInfo { const stats = this.getStaleClosureStats(); return { warnings: [...this.staleClosureWarnings], stats, timestamp: Date.now(), }; } /** * Stale Closure 통계 계산 */ private getStaleClosureStats(): StaleClosureStats { const warnings = this.staleClosureWarnings; // 유형별 개수 const warningsByType: Record = { 'async-state-capture': 0, 'callback-state-capture': 0, 'timeout-state-capture': 0, 'sequence-state-mismatch': 0, 'event-handler-stale': 0, }; for (const w of warnings) { warningsByType[w.type]++; } // 심각도별 개수 const warningsBySeverity: Record = { info: 0, warning: 0, error: 0 }; for (const w of warnings) { warningsBySeverity[w.severity]++; } // 위치별 개수 const locationCounts: Record = {}; for (const w of warnings) { locationCounts[w.location] = (locationCounts[w.location] || 0) + 1; } const topLocations = Object.entries(locationCounts) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([location, count]) => ({ location, count })); // 상태 경로별 개수 const pathCounts: Record = {}; for (const w of warnings) { pathCounts[w.capturedState.path] = (pathCounts[w.capturedState.path] || 0) + 1; } const topAffectedPaths = Object.entries(pathCounts) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([path, count]) => ({ path, count })); return { totalWarnings: warnings.length, warningsByType, warningsBySeverity, topLocations, topAffectedPaths, }; } /** * Stale Closure 데이터 초기화 */ clearStaleClosureData(): void { this.staleClosureWarnings = []; this.staleClosureIdCounter = 0; this.stateCaptureRegistry.clear(); } /** * 최근 Stale Closure 경고 반환 * * @param limit 반환할 최대 개수 * @param severity 심각도 필터 */ getRecentStaleClosureWarnings( limit = 10, severity?: 'info' | 'warning' | 'error' ): StaleClosureWarning[] { let warnings = [...this.staleClosureWarnings]; if (severity) { warnings = warnings.filter(w => w.severity === severity); } return warnings.slice(-limit); } /** * 안전한 객체 복사 (순환 참조 처리) */ private safeClone(obj: any): any { if (obj === null || obj === undefined) return obj; if (typeof obj !== 'object') return obj; try { return JSON.parse(JSON.stringify(obj)); } catch { // 순환 참조 등으로 직렬화 실패 시 간단한 복사 if (Array.isArray(obj)) { return obj.map(item => { try { return JSON.parse(JSON.stringify(item)); } catch { return '[Unserializable]'; } }); } const result: Record = {}; for (const key of Object.keys(obj)) { try { result[key] = JSON.parse(JSON.stringify(obj[key])); } catch { result[key] = '[Unserializable]'; } } return result; } } } export default G7DevToolsCore;