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

12 KiB
Raw Blame History

구조적 리팩토링 개발 계획

작성: 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

현재:

// 대화 항목마다 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

동일 패턴 적용:

  • TopicButtonPanelPreviewMouseLeftButtonDown, 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.csCreateFlatPopupRow() 내부 핸들러를 부모 위임으로 전환

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;
}

검증 방법

  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=truecanIncremental=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>

변경 사항:

  • _agentLiveContainerAgentLiveOverlay.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;

검증 방법

  1. 스트리밍 중 ApplyFullTranscriptRender 호출 횟수를 성능 로그로 측정 (수정 전 vs 후)
  2. 스트리밍 중 프레임 드롭 수 비교 (WPF Performance Toolkit)
  3. 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와 분리되어 보임 스크롤 이벤트에서 오버레이 위치 동기화