에이전트 선택적 탐색 구조 개선과 경고 정리 반영
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) 기준 개발 이력을 반영함
This commit is contained in:
2026-04-09 14:27:59 +09:00
parent 7931566212
commit 33c1db4dae
119 changed files with 4453 additions and 6943 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
# 구조적 리팩토링 개발 계획
> 작성: 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와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |