Ubisam-MergeTool/doc/history/2026-09-11-5차.md
ksj09406 c46abbd99a 5차 B-2: 폴더 비교를 세 걸음으로 나눠 3~5배 빠르게
훑기 - 가리기 - 세기로 나눴다. 훑으면서 그 자리에서 파일을 열어
견주던 것을, 트리를 먼저 세우고 가리는 일만 여러 갈래로 돌린다.
결과 차례는 트리가 정하므로 몇 갈래로 돌리든 같은 순서로 나온다.
세는 일만은 한 갈래로 한다. 파일이 64개 아래면 나누지 않는다 -
그 아래는 갈래를 세우는 값이 더 든다.

폴더를 훑는 방식도 바꿨다. Directory.GetFiles 는 이름만 주므로
파일마다 File.Exists 와 FileInfo 를 양쪽에 다시 물었다. 4,500개면
만 팔천 번이다. DirectoryInfo.GetFiles 는 크기와 수정 시각이
딸려 오므로 한 번으로 끝난다. 974ms 가 89ms 가 됐다.

재는 데 쓴 묶음은 우리가 만들었다(실제 소스를 열지 않는다).
2026-09-10 에 재 둔 모양을 흉내 냈고 씨앗을 고정해서 몇 번을
돌려도 똑같다.

  전체 비교  3,077ms -> 901ms (3.4배)
  빠른 비교  1,832ms -> 392ms (4.7배)
  앱에서 기다리는 시간 2,078ms -> 1,086ms (1.9배)

결과는 글자 하나까지 같다. 앱 쪽이 덜 줄어든 것은 비교가 끝난 뒤
화면 트리를 세우는 데 따로 드는 시간 때문이고, 거기는 손대지 않았다.
빠른 비교를 기본으로 켜지도 않았다 - 틀릴 수 있는 방법이다.

FolderTest 41 -> 51 (갈래 나누는 길, 열 번 돌려 같은 결과, 취소).
전체 698건 통과(하네스 20벌). 경고 0 · 오류 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:35:16 +09:00

18 KiB

5차 — 실제 소스에 맞추기 (2026-09-11 ~)

지시서: 작업지시/5차.md


A-1. CP949 파일 67개를 잘못 읽으면 한글이 깨진다 — 완료 (2026-09-11)

지시서가 먼저 확인만 하고 틀리면 고치라고 했다. 확인부터 했다.

잰 것 — 실제 저장소 2,091개 전부

TextFile.Read 로 실제 파일을 한 개씩 읽어 보는 도구를 만들어 돌렸다(원본은 읽기만 하고, 저장 왕복은 사본에만 했다).

실제 인코딩 개수
UTF-8 (BOM) 1,441
UTF-8 (BOM 없음) 353
CP949 67
ASCII 만 230
MergeHub 로 읽었을 때 결과
한글이 깨진 파일 0개
사본에 그대로 저장했을 때 바이트가 달라진 파일 (한글 파일 1,861개) 0개

판정 기준을 이미 만족하고 있었다. 판별 규칙(BOM → 엄격한 UTF-8 → 실패하면 시스템 ANSI)이 실제 소스에서 맞게 돈다. 고칠 것이 없었다.

그래도 고친 것 둘

① 판별 코드가 두 군데에 복사돼 있었다. TextFile.Detect 와 GitService.DetectEncoding 이 글자 하나까지 같은 코드였다. TextFile 주석에는 "판별 규칙은 GitService 와 같다 - 같은 파일을 어느 경로로 열든 결과가 같아야 한다" 고 적혀 있었는데, 지키는 방법이 복사뿐이었다. 한쪽만 고치면 조용히 어긋나고, 그러면 같은 파일이 워킹트리에서는 멀쩡한데 커밋에서 열면 깨져 보인다. 어느 쪽이 맞는지 알 방법도 없다. TextFile.DetectEncoding 하나로 모으고 GitService 는 그것을 부른다.

② 같은 인코딩을 두 이름으로 부르고 있었다. 상태바는 KS_C_5601-1987(코드페이지 949 의 공식 이름), 저장 형식 목록은 CP949. 실제 CP949 파일을 열어 보다가 눈에 띄었다 - "지금 이 파일이 그 CP949 라는 건가" 를 되묻게 된다. FriendlyName 으로 949 는 언제나 CP949 라고 적는다. 두 자리 모두 고쳤다.

확인한 것

확인 결과
실제 CP949 파일(AXA.cs 사본)을 열었을 때 //========== 보드 및 모듈 정보 확인 함수 ... 그대로 읽힌다
깨진 글자(U+FFFD) 없다
상태바 인코딩 CP949 (전에는 KS_C_5601-1987)
고친 뒤 실제 저장소 2,091개 재확인 깨짐 0개 · 왕복 어긋남 0개 (고치기 전과 같다)

검사를 새로 만들었다(EncTest 16건). 실제 저장소가 없어도 도는 것이 요점이다 - BOM · BOM 없는 UTF-8 · CP949 · ASCII 를 만들어 놓고 읽기 · 왕복 저장 · 형식 바꿔 저장까지 본다. 규칙을 여기 못박아 두면 다음에 누가 고쳐도 바로 걸린다.

전체 587건 통과(하네스 18벌) · 빌드 경고 0 · 오류 0. 실제 저장소는 건드리지 않았다 - 작업 전후 모두 변경 1건(7월부터 있던 것)으로 같다.


A-2. 커밋·체크아웃 뒤에 저장 안 한 편집이 조용히 사라진다 (2026-09-11, 완료)

어디서 사라지고 있었나

4차 A-1 에서 편집이 말없이 사라지던 길 여덟 군데를 막았지만, Git 탭은 그대로 두었다. RefreshCommits → LoadCommitAsync 는 프로그램이 목록을 다시 세우는 한가운데라 거기서 확인 창을 띄우면 무엇에 답하는 창인지 알 수 없고, 취소해도 목록은 이미 새로 세워진 뒤였다.

그래서 막는 자리를 옮겼다. 목록을 다시 세우는 자리가 아니라 Git 작업을 시작하는 자리에서 묻는다. 그 순간에는 아직 아무것도 바뀌지 않았으므로 취소가 뜻을 가진다.

GitWorkspace.RunAndRefreshAsync 는 Git 탭의 작업 서른 가지가 모두 지나가는 문이다. 여기에 ConfirmDiscardEdits 갈고리를 달고, 파일 비교 화면이 그 갈고리를 채운다.

public async Task<bool> RunAndRefreshAsync(string operation, Action action)
{
    if (GitOperationKinds.TouchesFiles(operation) && ConfirmDiscardEdits is { } ask && !ask(operation)) return false;
    var ok = await RunAsync(operation, action);
    if (ok) await RefreshAsync();
    return ok;
}

처음 고른 답이 틀렸다 — 가려서 묻기

처음에는 작업을 가려서 물으려 했다. fetch · 태그 생성 · 전체 스테이징처럼 파일 내용을 안 건드리는 열두 가지는 묻지 않는 쪽이 낫다고 봤다. 쓸데없이 뜨는 창만큼 사람을 지치게 하는 것이 없기 때문이다.

첫 시도는 호출 자리마다 touchesFiles: false 를 붙이는 것이었는데, 줄을 훑어 고치는 스크립트가 엉뚱한 닫는 괄호(예: throw new InvalidOperationException(...))에 붙였다. 되돌리고, 판단을 이름표 하나(GitOperationKinds)로 모았다. 부르는 자리는 손대지 않는다.

그런데 화면에서 해 보니 안 묻고 지나간 뒤에도 편집이 사라졌다. 파일은 그대로인데도 저장소 상태를 다시 읽으면서 본문을 새로 그리기 때문이다.

다시 그리지 않게 막는 쪽으로도 해 봤다.

if (HasChanges && !threeWay && string.Equals(localText, _originalLocalText, StringComparison.Ordinal))
    return;

이것도 안 들었다. 다시 읽는 김에 고른 파일까지 바뀌는 경우가 있어 "안 바뀌었다" 를 정확히 가릴 조건을 세울 수가 없었다. 이 코드는 지웠다.

골라잡은 답 — 전부 묻는다

잃지 않는 쪽으로 갔다. TouchesFiles 는 이제 언제나 true 다.

  • 확인 창은 고치던 것이 있을 때만 뜬다. 평소에는 한 번도 보이지 않는다.
  • 틀리더라도 한 번 더 묻는 것으로 끝나지, 작업을 잃지는 않는다. 가려서 묻다 틀리면 사람이 고친 것을 잃는다. 두 실수의 값이 다르다.

다만 무엇이 안전한지 알아낸 것은 버리지 않았다. 열두 가지 이름을 GitOperationKinds.KnownSafeCandidates 에 남겨 두고, 왜 지금 쓰지 않는지를 클래스 주석에 적었다. "다시 그려도 편집이 살아남는" 구조가 되면 거기서부터 다시 시작한다.

덤으로 잡은 것 — 4차 A-1 이 새던 자리 셋

전부 묻게 만들고 나니 Git 탭을 그냥 열기만 해도 확인 창이 떴다. 사람이 한 일이 아닌데 4차 A-1 의 확인 창이 불려 나오고 있었다. 셋을 잡았다.

새던 자리 왜 떴나 어떻게 막았나
OnSelectedNodeChanged 커밋 목록을 다시 세우면 잠깐 null 이 밀려 들어온다 value is not null && !sameCommit 일 때만 묻는다
OnRemoteBranchChanged RefreshRemoteBranches 가 목록을 갈아 끼우며 값을 바꾼다 _syncingBranches 동안은 묻지 않는다
OnSelectedFileChanged Files.Clear() 가 가드 밖에 있었다 _suspendGuard 안으로 옮겼다

목록을 다시 세우면 화면이 값을 되밀어 넣는다. 그것은 사람이 고른 것이 아니다. 4차 A-1 을 넣을 때 놓친 것인데, A-2 를 화면에서 시험하다 드러났다.

확인한 것 (실제 화면, 데모 저장소)

확인 결과
LOCAL 을 고친 줄이 들어간다 OK
전체 스테이징이 묻는다 OK
취소하면 스테이징도 안 한다 (git diff --cached 비었다) OK
취소하면 고친 줄이 그대로다 OK
체크아웃이 묻는다 OK
취소하면 브랜치가 그대로다 (master) OK
체크아웃을 취소해도 고친 줄이 그대로다 OK
"저장하고 계속" 을 고르면 고친 내용이 파일에 남는다 OK
고친 것이 없으면 안 묻는다 OK

열 가지 전부 통과. 화면 시험은 UI Automation 으로 몰았다(a2screen.ps1). 확인 창이 따로 뜨는 창이 아니라 본 창 안에 덮어 그리는 판이라, 바깥 창 목록을 뒤지면 못 찾는다. 처음 두 번은 이것 때문에 "안 묻는다" 고 잘못 읽었다 — 시험 도구가 틀려서 제품을 의심했다.

검사를 GitOpTest 36건으로 고쳤다. 전체 623건 통과(하네스 19벌) · 빌드 경고 0 · 오류 0. 실제 저장소는 건드리지 않았다 — 데모 저장소(mergehub-demo) 사본으로만 했다.


B-1. XAML 색칠이 사실상 없다 (2026-09-11, 완료)

무엇이 문제였나

실제 소스에 XAML 이 400개 · 42,477줄이다. C# 다음으로 많다. 그런데 지금까지 XAML 은 LanguageProfile.Markup 으로 열렸고, 그것이 아는 것은 주석과 따옴표뿐이었다. 태그 이름 · 속성 이름 · 속성 값이 전부 같은 색이라 사실상 평문이었다.

왜 기존 토크나이저에 못 얹었나

CodeTokenizer 는 낱말을 읽어 예약어 표와 맞춰 보는 방식이다. 마크업은 규칙이 다르다. 같은 Content 가 꺾쇠 안에서는 속성 이름이고 밖에서는 그냥 글자다. 낱말이 무엇이냐가 아니라 어디에 있느냐로 갈린다. 그래서 마크업만 따로 뒀다(MarkupTokenizer).

줄을 넘어가는 상태를 넓혀야 했다

토크나이저는 한 줄씩 따로 훑는다. 앞 줄에서 넘어오는 것은 여태 bool inBlockComment 하나였다. 그런데 XAML 은 여는 태그가 여러 줄에 걸치는 것이 예외가 아니라 보통이다. 시험에 쓴 실제 파일(UCManualRCCOperation.xaml)은 첫 태그 하나가 14줄이다.

<Button Grid.Row="1"
        Content="비교"
        Click="OnCompare" />

둘째·셋째 줄은 < 로 시작하지 않는다. "지금 꺾쇠 안이다" 를 넘겨주지 않으면 속성 이름이 그냥 글자로 보인다 - XAML 은 이런 줄이 대부분이라 색칠이 없는 것과 같다.

그래서 bool 을 깃발 묶음(CarryOver)으로 넓혔다. None · BlockComment · Tag 셋이다. 부르는 자리 다섯(검사기 · 폴더 비교 · 화면 VM · 화면 컨트롤 · 행 모델)을 모두 바꿨다.

무엇을 무슨 색으로 보는가

무엇 색 까닭
태그 이름 예약어 (파랑) 구조를 이루는 것이라 가장 먼저 눈에 들어와야 한다
속성 이름 메서드 (황토/연노랑) 태그와 값 사이에서 따로 보여야 한다
속성 값 문자열 (빨강/주황) 다른 언어의 문자열과 같은 색 - 같은 것이다
앞가지(x: · local:) 타입 예약어 (청록) XAML 에서 "어느 네임스페이스냐"는 정보다. 이름에 묻으면 x:Name 과 Name 이 같아 보인다
< > / : 구두점 (회색) 뒤로 물러나야 한다
= 연산자 (회색) 위와 같다
<?xml ?> · <!DOCTYPE> · <![CDATA[ 지시문 (회색) 파일마다 한두 줄이고 고칠 일이 없다. 안을 더 쪼개지 않는다
태그 밖의 본문 칠하지 않는다 물려받은 색 그대로 두어야 본문이 눈에 띈다

색을 새로 만들지 않고 이미 있는 갈래에 얹었다. 팔레트를 늘리면 밝은/어두운 두 벌을 다 손봐야 하고, 같은 화면에서 C# 과 XAML 이 서로 다른 색 체계를 쓰면 오히려 읽기 어렵다.

못 하는 것 (일부러)

속성 값이 줄을 넘어가면(닫는 따옴표가 다음 줄에 있으면) 그 줄까지만 문자열로 보고 다음 줄은 속성 자리로 돌아간다. XAML 에서 드물고, 틀려도 색만 어긋난다. 깃발을 하나 더 늘릴 만한 값이 아니라고 봤다.

하루를 날릴 뻔한 것 - 빈 화면

붙이고 나니 파일 비교 화면이 통째로 비어서 떴다. 단추 하나 없이 하얀 판이었다. 빌드는 경고 0 · 오류 0 이고 로그에도 아무것도 안 남았다.

까닭은 한 줄이었다. 의존 속성의 기본값 타입이 속성 타입과 달랐다.

DependencyProperty.Register(nameof(InBlockComment), typeof(CarryOver), typeof(CodeTextBlock),
    new FrameworkPropertyMetadata(false, OnVisualInputChanged));   // ← false 는 bool 이다

타입을 bool 에서 CarryOver 로 바꾸면서 기본값을 같이 안 고쳤다. 이러면 이 컨트롤을 처음 쓰는 순간 정적 초기화에서 터지고, 화면 전체가 안 뜬다. 빌드는 통과한다 - object 를 받는 자리라 컴파일러가 잡을 수 없다.

찾은 방법은 고치기 전 상태로 되돌려 보는 것이었다(git stash). 되돌리니 화면이 나왔고, 그래서 내 변경이 원인인 줄 알았다. 그 다음에 파일을 바이트로 들여다보다 false 를 발견했다. 같은 주의를 코드 옆에 적어 두었다.

확인한 것

실제 저장소의 XAML 사본(UCManualRCCOperation.xaml · 638줄)을 열어 눈으로 봤다.

확인 결과
14줄짜리 여는 태그의 이어지는 줄 속성(d:DesignHeight · x:Name) 속성 색으로 나온다
태그 이름(Setter · ControlTemplate · Border) 파랑
앞가지(view: · x: · d:) 청록
속성 값("White" · "#1A22...") 빨강
주석 줄(<!--<Setter .../>-->) 초록 한 덩어리
C# 파일 색칠 달라지지 않았다 (문서 주석 초록 · public 굵은 자홍 · 타입 청록)

검사를 새로 만들었다(MarkupTest 65건). 여러 줄 태그 · 주석 이어짐 · 앞가지 · CDATA 와 함께, C# · C++ · JSON 색칠이 안 망가졌는지도 같이 본다. 토큰이 겹치거나 거꾸로 가지 않는지도 본다.

전체 688건 통과(하네스 20벌) · 빌드 경고 0 · 오류 0. 실제 저장소는 읽기만 했다 - 사본을 떠서 시험했다.


B-2. 폴더 비교가 큰 저장소에서 오래 걸린다 (2026-09-11, 완료)

먼저 잴 것을 만들었다

실제 소스를 더 열지 않기로 했으므로(2026-09-11 결정) 시험용 묶음을 만들었다(FolderPerf/Corpus.cs). 2026-09-10 에 재 둔 실제 저장소의 모양을 흉내 냈다.

맞춘 것 값
파일 수 4,500개 (양쪽 합쳐 8,922개 · 136MB)
갈래 C# 과 XAML 을 4:1
줄 수 분포 대부분 30200줄, 가끔 2,0007,500줄
폴더 깊이 뿌리 5개 아래 2~3단
같고 다름 같음 4,063 · 다름 359(주석만 294) · 한쪽만 78

씨앗을 고정했다. 몇 번을 돌려도 똑같은 묶음이 나온다. 실제 폴더는 백신·인덱서가 건드려서 잴 때마다 값이 흔들리는데, 만든 묶음은 그렇지 않다 - 고치기 전후를 견주기에는 오히려 낫다.

재 보니 짚이는 데가 둘이었다

① 파일 하나마다 같은 것을 세 번 물었다.

Directory.GetFiles(dir)          // 이름만 준다
File.Exists(leftPath)            // 다시 묻는다
new FileInfo(leftPath).Length    // 또 묻는다

Directory.GetFiles 는 이름만 준다. 그래서 파일마다 File.Exists 한 번과 FileInfo 한 번을 양쪽에 다시 물었다. 4,500개면 그것만 만 팔천 번이다.

DirectoryInfo.GetFiles() 는 목록을 얻을 때 크기와 수정 시각이 딸려 온다. 한 번으로 끝난다.

폴더 훑기만 따로 재면
예전 방식 974ms
DirectoryInfo 89ms

② 파일을 여는 동안 아무것도 못 했다.

훑으면서 그 자리에서 파일을 열어 견줬다. 그러면 한 파일을 읽는 동안 다음 폴더로 못 간다. 디스크를 기다리는 시간이 통째로 줄줄이 쌓인다.

어떻게 고쳤나 - 세 걸음으로 나눴다

  1. 훑기 — 폴더를 돌며 트리를 세운다. 파일은 "아직 안 가린 것"으로 모아 둔다.
  2. 가리기 — 모아 둔 것을 여러 갈래로 나눠 가린다. 파일을 여는 자리는 여기뿐이다.
  3. 세기 — 트리 차례대로 폴더 상태를 정하고 개수를 센다.

결과 차례는 트리가 정한다. 몇 갈래로 돌리든 같은 순서로 나온다 - 나눠 돌린 것이 화면에 티가 나면 안 된다. 세는 일만은 반드시 한 갈래로 한다.

파일이 64개 아래면 나누지 않는다. 그 아래는 갈래를 세우는 값이 더 든다.

얼마나 빨라졌나

같은 묶음을 한 프로그램 안에서 나란히 돌려 견줬다. 따로 재면 디스크 캐시 상태가 달라 못 견준다.

고치기 전 고친 뒤
전체 비교 3,077ms 901ms 3.4배
빠른 비교 1,832ms 392ms 4.7배
앱에서 사람이 기다리는 시간 2,078ms 1,086ms 1.9배

결과는 글자 하나까지 같다. 검사가 전후 요약을 맞춰 본다.

앱 쪽이 덜 줄어든 까닭은 비교가 끝난 뒤 화면 트리를 세우는 데 따로 시간이 들기 때문이다. 4,500개짜리 노드를 만드는 일이라 비교와는 다른 문제다. 여기는 손대지 않았다 - 지금도 1초 남짓이고, 건드리면 트리 동작이 바뀔 위험이 있다. 더 줄일 자리로 적어 둔다.

기본 동작은 바꾸지 않았다

빠른 비교(크기·수정 시각만 보기)는 틀릴 수 있어 그대로 사용자가 고르는 것으로 남겼다. 빨라졌다고 기본으로 켜면, 내용이 바뀌었는데 크기와 시각이 우연히 같은 파일을 놓친다.

확인한 것

  • 전후 요약이 글자 하나까지 같다(전체 비교 · 빠른 비교 둘 다).
  • 검사를 열 개 늘렸다(FolderTest 41 → 51). 파일 200개짜리로 갈래를 나누는 길을 지나게 하고, 열 번을 돌려도 개수와 차례가 똑같은지, 많은 파일에서도 취소가 듣는지를 본다.
  • 앱을 띄워 실제로 비교해 봤다. 세 번 모두 같은 요약이 나온다.

전체 698건 통과(하네스 20벌) · 빌드 경고 0 · 오류 0. 실제 저장소는 열지 않았다.