# 구조적 리팩토링 개발 계획 > 작성: 2026-04-09 --- ## 배경 ChatWindow의 두 가지 구조적 결함이 장시간 사용 시 메모리 누수와 UI 프레임 드롭을 유발합니다. 1. **람다 이벤트 핸들러 누적** — 동적 UI 요소에 붙인 람다 핸들러가 `Children.Clear()` 후에도 GC되지 않음 2. **스트리밍 중 인크리멘탈 렌더 실패** — prefix 키 비교가 스트리밍 상황에서 항상 실패하여 전체 재빌드로 폴백 --- ## 과제 A: 람다 이벤트 핸들러 누적 해소 ### 현황 26개소 이상에서 동적 UI 요소(Border, TextBlock, Button)에 람다 `+=` 핸들러를 붙이고, 컨테이너 `.Clear()` 시 핸들러를 해제하지 않음. 클로저가 캡처한 변수(UI 요소, 문자열)가 GC 대상에서 제외됨. ### 영향도별 분류 | 빈도 | 파일 | Clear 대상 | 핸들러 수/회 | 추정 누적 | |------|------|-----------|-------------|----------| | **높음** | `ConversationListPresentation.cs` | `ConversationPanel.Children` | 대화당 5개 × 50항목 = 250 | 탭 전환/필터마다 250개 | | **높음** | `TopicPresetPresentation.cs` | `TopicButtonPanel.Children` | 프리셋당 3개 × 15항목 = 45 | 탭 전환마다 45개 | | **중간** | `PermissionPresentation.cs` | `PermissionItems.Children` | 항목당 4개 × 20항목 = 80 | 권한 팝업 열 때마다 80개 | | **중간** | `PreviewPresentation.cs` | `PreviewTabPanel.Children` | 탭당 5개 × 10탭 = 50 | 미리보기 탭 변경마다 50개 | | **중간** | `ComposerQueuePresentation.cs` | `DraftQueuePanel.Children` | 항목당 2개 × 5항목 = 10 | 큐 갱신마다 10개 | | **중간** | `GitBranchPresentation.cs` | `GitBranchItems.Children` | 항목당 3개 × 20항목 = 60 | 브랜치 팝업 열 때마다 60개 | | **낮음** | `FileBrowserPresentation.cs` | `FileTreeView.Items` | 항목당 3개 × 100항목 = 300 | 트리 재구축마다 300개 | ### 설계 방침 **이벤트 위임(Event Delegation) 패턴** 적용 — 개별 자식 요소에 핸들러를 붙이지 않고, 부모 컨테이너에 단일 핸들러를 두고 `e.Source`/`e.OriginalSource`로 분기. ### 단계별 구현 계획 #### Phase 1: 고빈도 대상 (추정 공수: 2시간) **1-1. ConversationListPresentation.cs** 현재: ```csharp // 대화 항목마다 5개 람다 핸들러 border.MouseEnter += (s, _) => { ... }; border.MouseLeave += (s, _) => { ... }; border.MouseLeftButtonDown += (s, _) => { ... }; border.MouseRightButtonUp += (s, _) => { ... }; border.MouseLeftButtonUp += (s, _) => { ... }; ConversationPanel.Children.Add(border); ``` 변경 전략: ```csharp // ConversationPanel에 단일 핸들러 (생성자에서 1회 등록) ConversationPanel.MouseEnter += ConversationPanel_MouseEnter; ConversationPanel.MouseLeave += ConversationPanel_MouseLeave; ConversationPanel.MouseLeftButtonDown += ConversationPanel_MouseLeftButtonDown; // ... private void ConversationPanel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // e.OriginalSource에서 가장 가까운 Border를 찾아 DataContext의 ConversationMeta로 분기 if (FindAncestor(e.OriginalSource as DependencyObject) is { Tag: string conversationId }) HandleConversationClick(conversationId); } ``` 핵심 변경: - 각 Border의 `Tag` 속성에 `conversationId` 저장 - `ConversationPanel`에 이벤트 5개를 생성자에서 1회만 등록 - `AddConversationItem()`에서 람다 핸들러 제거 - hover 효과는 `ConversationPanel_MouseMove`에서 `VisualTreeHelper.HitTest`로 처리 **1-2. TopicPresetPresentation.cs** 동일 패턴 적용: - `TopicButtonPanel`에 `PreviewMouseLeftButtonDown`, `MouseEnter`, `MouseLeave` 3개만 등록 - 각 프리셋 카드의 `Tag`에 프리셋 ID 저장 - `AttachTopicCardHover` 메서드를 제거하고 부모 위임으로 대체 #### Phase 2: 중빈도 대상 (추정 공수: 2시간) **2-1. PermissionPresentation.cs** — 동일 패턴 **2-2. PreviewPresentation.cs** — 탭 닫기 버튼만 주의 (부모 위임 + `Tag` 분기) **2-3. ComposerQueuePresentation.cs** — 큐 항목 적음, 간단 **2-4. GitBranchPresentation.cs** — `CreateFlatPopupRow()` 내부 핸들러를 부모 위임으로 전환 #### Phase 3: 저빈도 + 공통 유틸 (추정 공수: 1시간) **3-1. FileBrowserPresentation.cs** — TreeViewItem은 WPF 내부 이벤트 라우팅이 복잡하므로, Clear 전에 명시적 해제 방식 적용: ```csharp private void DetachFileTreeHandlers(ItemCollection items) { foreach (var item in items.OfType()) { item.Expanded -= FileTreeItem_Expanded; item.MouseDoubleClick -= FileTreeItem_DoubleClick; item.MouseRightButtonUp -= FileTreeItem_RightClick; DetachFileTreeHandlers(item.Items); // 재귀 } } // BuildFileTree() 시작 시 DetachFileTreeHandlers(FileTreeView.Items) 호출 ``` **3-2. 공통 헬퍼 추가** ```csharp // ChatWindow.VisualInteractionHelpers.cs에 추가 private static T? FindAncestorWithTag(DependencyObject? source) where T : FrameworkElement { while (source != null) { if (source is T fe && fe.Tag != null) return fe; source = VisualTreeHelper.GetParent(source); } return null; } ``` ### 검증 방법 1. 수정 전/후 Visual Studio Memory Profiler로 GC Gen2 객체 수 비교 2. 대화 탭 100회 전환 후 핸들러 참조 수 변화 측정 3. UI 기능 회귀 테스트 (hover 효과, 클릭, 우클릭 메뉴) --- ## 과제 B: 스트리밍 인크리멘탈 렌더 실패 해소 ### 현황 `TryApplyIncrementalTranscriptRender`는 이론적으로 새 항목만 추가/교체하지만, 스트리밍 중 3가지 이유로 항상 실패하여 `ApplyFullTranscriptRender`로 폴백합니다: | 실패 원인 | 위치 | 설명 | |----------|------|------| | `hiddenCount` 불일치 | `BuildTranscriptRenderPlan` L39 | 스트리밍 시 `GetActiveTimelineRenderLimit()`가 다른 값 반환 → `_lastRenderedHiddenCount != hiddenCount` | | prefix 키 불일치 | `TryApplyIncrementalTranscriptRender` L13 | hiddenCount 변화로 visible 범위가 밀려서 첫 번째 키가 달라짐 | | `_agentLiveContainer` 존재 | `BuildTranscriptRenderPlan` L36 | 라이브 카드가 transcript에 삽입되면 `hasExternalChildren=true` → `canIncremental=false` | ### 현재 렌더 흐름 ``` RenderMessages(preserveViewport: true) — 350ms/2200ms 타이머 → BuildTranscriptRenderPlan() → GetActiveTimelineRenderLimit() → 스트리밍이면 더 작은 값 → hiddenCount 계산 → 매번 다름 → canIncremental=false → TryApplyIncrementalTranscriptRender() → 실패 → ApplyFullTranscriptRender() → ClearTranscriptElements() + 전체 재구축 ``` ### 설계 방침 **스트리밍 전용 경량 업데이트 경로** 도입 — 스트리밍 중에는 전체 타임라인을 재구축하지 않고, 마지막 메시지 영역만 갱신. ### 단계별 구현 계획 #### Phase 1: hiddenCount 안정화 (추정 공수: 30분) **문제**: 스트리밍 중 `GetActiveTimelineRenderLimit()`가 더 작은 값을 반환하여 hiddenCount가 변동. **수정**: 스트리밍 중에는 `_lastRenderedHiddenCount`를 고정하여 비교 안정화. ```csharp // BuildTranscriptRenderPlan 수정 var effectiveRenderLimit = GetActiveTimelineRenderLimit(); var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit); // 스트리밍 중에는 hiddenCount가 증가만 허용 (줄어들지 않음) // → 이미 렌더링된 stable 항목을 숨기지 않아 prefix 불일치 방지 if (_isStreaming && hiddenCount < _lastRenderedHiddenCount) hiddenCount = _lastRenderedHiddenCount; ``` #### Phase 2: _agentLiveContainer 분리 (추정 공수: 1시간) **문제**: 라이브 진행 카드(`_agentLiveContainer`)가 transcript 패널에 삽입되면 `hasExternalChildren=true` → 인크리멘탈 불가. **수정**: 라이브 카드를 transcript 패널 밖의 별도 오버레이로 분리. ```xml ``` 변경 사항: - `_agentLiveContainer`를 `AgentLiveOverlay.Child`로 설정 (transcript 패널에서 분리) - `ApplyFullTranscriptRender`에서 `_agentLiveContainer` 삽입 코드 제거 - `hasExternalChildren` 체크 불필요 → `canIncremental` 조건 단순화 - 스크롤 위치 조정 로직은 오버레이 높이를 반영 #### Phase 3: 스트리밍 전용 append-only 경로 (추정 공수: 1.5시간) **목표**: 스트리밍 중에는 `TryApplyIncrementalTranscriptRender`의 prefix 비교를 우회하고, 새 항목만 추가하는 빠른 경로 사용. ```csharp // TranscriptRenderExecution.cs에 추가 private bool TryApplyStreamingAppendRender(TranscriptRenderPlan renderPlan) { // 스트리밍 전용: prefix 비교 대신 stable 키 집합의 부분집합 관계만 확인 if (!_isStreaming || _lastRenderedTimelineKeys.Count == 0) return false; // stable 키가 변하지 않았는지 확인 (순서 무관, 존재 여부만) var lastStableKeys = new HashSet( _lastRenderedTimelineKeys.Where(k => !k.StartsWith("_live_")), StringComparer.Ordinal); var newStableKeys = renderPlan.NewKeys .Where(k => !k.StartsWith("_live_")) .ToList(); // 새 stable 키가 기존의 superset이면 append 가능 if (!lastStableKeys.IsSubsetOf(newStableKeys)) return false; // 1. 기존 live 항목 제거 for (var i = 0; i < renderPlan.PreviousLiveCount && GetTranscriptElementCount() > 0; i++) RemoveTranscriptElementAt(GetTranscriptElementCount() - 1); // 2. 새로 추가된 stable 항목 렌더 foreach (var item in renderPlan.VisibleTimeline) { if (!lastStableKeys.Contains(item.Key)) item.Render(); } // 3. 새 live 항목 렌더 foreach (var item in renderPlan.VisibleTimeline.Where( t => t.Key.StartsWith("_live_"))) item.Render(); _lastRenderedTimelineKeys = renderPlan.NewKeys; _lastRenderedHiddenCount = renderPlan.HiddenCount; return true; } ``` 호출 순서 수정 (TranscriptRendering.cs): ```csharp if (!TryApplyStreamingAppendRender(renderPlan) // 스트리밍 전용 빠른 경로 && !TryApplyIncrementalTranscriptRender(renderPlan) // 일반 인크리멘탈 ) ApplyFullTranscriptRender(renderPlan); // 최후 수단 ``` #### Phase 4: 비가시 상태 렌더 차단 (추정 공수: 15분) ```csharp // RenderMessages 최상단에 추가 if (WindowState == WindowState.Minimized || !IsVisible) return; ``` ### 검증 방법 1. 스트리밍 중 `ApplyFullTranscriptRender` 호출 횟수를 성능 로그로 측정 (수정 전 vs 후) 2. 스트리밍 중 프레임 드롭 수 비교 (WPF Performance Toolkit) 3. 50개 이상 메시지가 있는 대화에서 스트리밍 응답 시 스크롤 버벅임 확인 --- ## 실행 계획 요약 | 단계 | 과제 | 작업 | 공수 | 우선순위 | |------|------|------|------|---------| | A-1 | 핸들러 | ConversationList + TopicPreset 이벤트 위임 | 2h | P1 | ✅ 완료 (2026-04-09) | | B-1 | 렌더 | hiddenCount 안정화 | 30m | P1 | ✅ 완료 (2026-04-09) | | B-4 | 렌더 | 비가시 상태 렌더 차단 | 15m | P1 | ✅ 완료 (2026-04-09) | | B-2 | 렌더 | _agentLiveContainer 인크리멘탈 허용 | 1h | P2 | ✅ 완료 (2026-04-09) | | B-3 | 렌더 | 스트리밍 append-only 경로 | 1.5h | P2 | ✅ 완료 (2026-04-09) | | A-2 | 핸들러 | Permission + Preview + GitBranch 위임 | 2h | P2 | ✅ 완료 (2026-04-09) | | A-3 | 핸들러 | FileBrowser 명시적 해제 + 공통 헬퍼 | 1h | P3 | ✅ 완료 (2026-04-09) | **총 예상 공수**: ~8시간 ### 위험 요소 | 위험 | 영향 | 대응 | |------|------|------| | 이벤트 위임 시 hover 효과 깨짐 | UI 품질 저하 | 기존 CSS-style 직접 설정 대신 VisualStateManager 활용 | | 인크리멘탈 렌더 중 DOM 불일치 | 렌더링 오류 | try/catch + 전체 재빌드 폴백 유지 | | 라이브 카드 오버레이 분리 시 스크롤 동기화 | 라이브 카드가 transcript와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |