Files
AX-Copilot-Codex/docs/STRUCTURAL_REFACTORING_PLAN.md
lacvet 33c1db4dae
Some checks failed
Release Gate / gate (push) Has been cancelled
에이전트 선택적 탐색 구조 개선과 경고 정리 반영
- 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) 기준 개발 이력을 반영함
2026-04-09 14:27:59 +09:00

297 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 구조적 리팩토링 개발 계획
> 작성: 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<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`, `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<TreeViewItem>())
{
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<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;
}
```
### 검증 방법
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
<!-- 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 비교를 우회하고, 새 항목만 추가하는 빠른 경로 사용.
```csharp
// 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):
```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와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |