- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함 - FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함 - AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함 - AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함 - DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함 - README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
12 KiB
구조적 리팩토링 개발 계획
작성: 2026-04-09
배경
ChatWindow의 두 가지 구조적 결함이 장시간 사용 시 메모리 누수와 UI 프레임 드롭을 유발합니다.
- 람다 이벤트 핸들러 누적 — 동적 UI 요소에 붙인 람다 핸들러가
Children.Clear()후에도 GC되지 않음 - 스트리밍 중 인크리멘탈 렌더 실패 — 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
현재:
// 대화 항목마다 5개 람다 핸들러
border.MouseEnter += (s, _) => { ... };
border.MouseLeave += (s, _) => { ... };
border.MouseLeftButtonDown += (s, _) => { ... };
border.MouseRightButtonUp += (s, _) => { ... };
border.MouseLeftButtonUp += (s, _) => { ... };
ConversationPanel.Children.Add(border);
변경 전략:
// 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<Border>(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,MouseLeave3개만 등록- 각 프리셋 카드의
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 전에 명시적 해제 방식 적용:
private void DetachFileTreeHandlers(ItemCollection items)
{
foreach (var item in items.OfType<TreeViewItem>())
{
item.Expanded -= FileTreeItem_Expanded;
item.MouseDoubleClick -= FileTreeItem_DoubleClick;
item.MouseRightButtonUp -= FileTreeItem_RightClick;
DetachFileTreeHandlers(item.Items); // 재귀
}
}
// BuildFileTree() 시작 시 DetachFileTreeHandlers(FileTreeView.Items) 호출
3-2. 공통 헬퍼 추가
// ChatWindow.VisualInteractionHelpers.cs에 추가
private static T? FindAncestorWithTag<T>(DependencyObject? source) where T : FrameworkElement
{
while (source != null)
{
if (source is T fe && fe.Tag != null)
return fe;
source = VisualTreeHelper.GetParent(source);
}
return null;
}
검증 방법
- 수정 전/후 Visual Studio Memory Profiler로 GC Gen2 객체 수 비교
- 대화 탭 100회 전환 후 핸들러 참조 수 변화 측정
- 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를 고정하여 비교 안정화.
// 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 패널 밖의 별도 오버레이로 분리.
<!-- ChatWindow.xaml 수정 -->
<Grid>
<!-- 기존 transcript -->
<ScrollViewer x:Name="TranscriptScroll">
<StackPanel x:Name="TranscriptPanel" />
</ScrollViewer>
<!-- 라이브 진행 카드 — transcript 밖 하단 오버레이 -->
<Border x:Name="AgentLiveOverlay"
VerticalAlignment="Bottom"
Margin="0,0,0,8" />
</Grid>
변경 사항:
_agentLiveContainer를AgentLiveOverlay.Child로 설정 (transcript 패널에서 분리)ApplyFullTranscriptRender에서_agentLiveContainer삽입 코드 제거hasExternalChildren체크 불필요 →canIncremental조건 단순화- 스크롤 위치 조정 로직은 오버레이 높이를 반영
Phase 3: 스트리밍 전용 append-only 경로 (추정 공수: 1.5시간)
목표: 스트리밍 중에는 TryApplyIncrementalTranscriptRender의 prefix 비교를 우회하고, 새 항목만 추가하는 빠른 경로 사용.
// TranscriptRenderExecution.cs에 추가
private bool TryApplyStreamingAppendRender(TranscriptRenderPlan renderPlan)
{
// 스트리밍 전용: prefix 비교 대신 stable 키 집합의 부분집합 관계만 확인
if (!_isStreaming || _lastRenderedTimelineKeys.Count == 0)
return false;
// stable 키가 변하지 않았는지 확인 (순서 무관, 존재 여부만)
var lastStableKeys = new HashSet<string>(
_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):
if (!TryApplyStreamingAppendRender(renderPlan) // 스트리밍 전용 빠른 경로
&& !TryApplyIncrementalTranscriptRender(renderPlan) // 일반 인크리멘탈
)
ApplyFullTranscriptRender(renderPlan); // 최후 수단
Phase 4: 비가시 상태 렌더 차단 (추정 공수: 15분)
// RenderMessages 최상단에 추가
if (WindowState == WindowState.Minimized || !IsVisible)
return;
검증 방법
- 스트리밍 중
ApplyFullTranscriptRender호출 횟수를 성능 로그로 측정 (수정 전 vs 후) - 스트리밍 중 프레임 드롭 수 비교 (WPF Performance Toolkit)
- 50개 이상 메시지가 있는 대화에서 스트리밍 응답 시 스크롤 버벅임 확인
실행 계획 요약
| 단계 | 과제 | 작업 | 공수 | 우선순위 |
|---|---|---|---|---|
| A-1 | 핸들러 | ConversationList + TopicPreset 이벤트 위임 | 2h | P1 |
| B-1 | 렌더 | hiddenCount 안정화 | 30m | P1 |
| B-4 | 렌더 | 비가시 상태 렌더 차단 | 15m | P1 |
| B-2 | 렌더 | _agentLiveContainer 인크리멘탈 허용 | 1h | P2 |
| B-3 | 렌더 | 스트리밍 append-only 경로 | 1.5h | P2 |
| A-2 | 핸들러 | Permission + Preview + GitBranch 위임 | 2h | P2 |
| A-3 | 핸들러 | FileBrowser 명시적 해제 + 공통 헬퍼 | 1h | P3 |
총 예상 공수: ~8시간
위험 요소
| 위험 | 영향 | 대응 |
|---|---|---|
| 이벤트 위임 시 hover 효과 깨짐 | UI 품질 저하 | 기존 CSS-style 직접 설정 대신 VisualStateManager 활용 |
| 인크리멘탈 렌더 중 DOM 불일치 | 렌더링 오류 | try/catch + 전체 재빌드 폴백 유지 |
| 라이브 카드 오버레이 분리 시 스크롤 동기화 | 라이브 카드가 transcript와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |