diff --git a/doc/history/2026-09-11-5차.md b/doc/history/2026-09-11-5차.md index 399a5b0..7a9da2b 100644 --- a/doc/history/2026-09-11-5차.md +++ b/doc/history/2026-09-11-5차.md @@ -249,3 +249,90 @@ DependencyProperty.Register(nameof(InBlockComment), typeof(CarryOver), typeof(Co 전체 **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 | +| 줄 수 분포 | 대부분 30~200줄, 가끔 2,000~7,500줄 | +| 폴더 깊이 | 뿌리 5개 아래 2~3단 | +| 같고 다름 | 같음 4,063 · 다름 359(주석만 294) · 한쪽만 78 | + +**씨앗을 고정했다.** 몇 번을 돌려도 똑같은 묶음이 나온다. 실제 폴더는 백신·인덱서가 건드려서 +잴 때마다 값이 흔들리는데, 만든 묶음은 그렇지 않다 - 고치기 전후를 견주기에는 오히려 낫다. + +### 재 보니 짚이는 데가 둘이었다 + +**① 파일 하나마다 같은 것을 세 번 물었다.** + +```csharp +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. **실제 저장소는 열지 않았다.** diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 02e09af..c2ba5fa 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-11 | **5차 B-2** — 폴더 비교를 훑기·가리기·세기 세 걸음으로 나눔. 메타데이터를 한 번만 묻고 가리는 일은 여러 갈래로. 전체 3.4배 · 빠른 4.7배 | 아래 | | 2026-09-11 | **지시서 5차 고침** — 실제 소스를 더 읽지 않기로 함. 시험 재료는 우리가 만든다. B-2·C-1 을 그에 맞게 다시 씀 | 아래 | | 2026-09-11 | **5차 B-1** — XAML 태그·속성·값을 갈라 칠함. 여러 줄에 걸친 여는 태그도 이어진다. 줄 넘김 상태를 깃발 묶음으로 넓혔다 | 아래 | | 2026-09-11 | **5차 A-2** — Git 작업을 시작하는 자리에서 묻게 함. 가려서 묻는 것은 접고 전부 묻는다. 덤으로 4차 A-1 이 새던 자리 셋을 막음 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 8782cc9..5f2767c 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -73,7 +73,7 @@ | 상태 | 항목 | |---|---| | **완료** | B-1 XAML 색칠이 사실상 없다 (400개 · 42,477줄) | -| 대기 | B-2 폴더 비교가 실제 저장소에서 12초 걸린다 | +| **완료** | B-2 폴더 비교가 오래 걸린다 — 전체 비교 3.4배 · 빠른 비교 4.7배 | ### C. 실전 투입 diff --git a/src/MergeHub/Services/FolderComparer.cs b/src/MergeHub/Services/FolderComparer.cs index 9f493b5..b121d14 100644 --- a/src/MergeHub/Services/FolderComparer.cs +++ b/src/MergeHub/Services/FolderComparer.cs @@ -4,6 +4,7 @@ using System.IO; using System.Text.RegularExpressions; using MergeHub.Views.Controls; using System.Threading; +using System.Threading.Tasks; namespace MergeHub.Services { @@ -140,65 +141,77 @@ namespace MergeHub.Services var include = Compile(options.IncludePattern); var exclude = Compile(options.ExcludePattern); + // 세 걸음으로 나눈다. + // ① 훑기 - 폴더를 돌며 트리를 세운다. 파일은 "아직 안 가린 것"으로 모아 둔다. + // ② 가리기 - 모아 둔 것을 여러 갈래로 나눠 가린다. 여기가 파일을 여는 자리다. + // ③ 세기 - 트리 차례대로 폴더 상태를 정하고 개수를 센다. + // + // 예전에는 훑으면서 그 자리에서 가렸다. 그러면 파일을 여는 동안 다음 폴더로 못 간다. + // 나눠 놓으면 가리는 일만 여러 갈래로 돌릴 수 있고, 결과 차례는 트리가 정하므로 + // 몇 갈래로 돌리든 똑같이 나온다 - 나눠 돌린 것이 화면에 티가 나면 안 된다. + var pending = new List(); var count = 0; - Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result, result.Items, ref count, cancel); + Walk(leftRoot, rightRoot, string.Empty, options, include, exclude, result.Items, pending, ref count, cancel); + + ClassifyAll(pending, options, cancel); + Tally(result.Items, result); result.Truncated = count >= MaxEntries; return result; } - private static void Walk(string leftRoot, string rightRoot, string relative, - FolderCompareOptions options, Regex? include, Regex? exclude, - FolderCompareResult result, List into, ref int count, - CancellationToken cancel) + /// + /// 여기부터 여러 갈래로 나눈다. 그 아래는 나눠 봐야 갈래를 세우는 값이 더 든다. + /// + private const int ParallelThreshold = 64; + + /// 아직 무엇인지 가리지 않은 파일 하나. 경로를 다시 만들지 않으려고 같이 들고 다닌다. + private readonly struct Pending { - cancel.ThrowIfCancellationRequested(); - if (count >= MaxEntries) return; - - var leftDir = Path.Combine(leftRoot, relative); - var rightDir = Path.Combine(rightRoot, relative); - - // ---- 파일 ---- - var names = new SortedSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in SafeFiles(leftDir)) names.Add(Path.GetFileName(path)); - foreach (var path in SafeFiles(rightDir)) names.Add(Path.GetFileName(path)); - - foreach (var name in names) + public Pending(FolderCompareItem item, string leftPath, string rightPath) { - cancel.ThrowIfCancellationRequested(); - if (count >= MaxEntries) return; + Item = item; + LeftPath = leftPath; + RightPath = rightPath; + } - var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name); - if (IsFilteredOut(name, childRelative, include, exclude)) continue; + public FolderCompareItem Item { get; } + public string LeftPath { get; } + public string RightPath { get; } + } - var leftPath = Path.Combine(leftDir, name); - var rightPath = Path.Combine(rightDir, name); - - var item = new FolderCompareItem(name, childRelative, isDirectory: false) + /// 모아 둔 파일을 가린다. 몇 개 안 되면 그냥 차례로 한다. + private static void ClassifyAll(List pending, FolderCompareOptions options, CancellationToken cancel) + { + if (pending.Count < ParallelThreshold) + { + foreach (var p in pending) { - LeftExists = File.Exists(leftPath), - RightExists = File.Exists(rightPath), - }; - - if (item.LeftExists) - { - var info = new FileInfo(leftPath); - item.LeftSize = info.Length; - item.LeftModified = info.LastWriteTime; + cancel.ThrowIfCancellationRequested(); + p.Item.Status = Classify(p.Item, p.LeftPath, p.RightPath, options); } + return; + } - if (item.RightExists) + var parallel = new ParallelOptions { CancellationToken = cancel }; + Parallel.ForEach(pending, parallel, p => p.Item.Status = Classify(p.Item, p.LeftPath, p.RightPath, options)); + } + + /// + /// 트리를 차례대로 돌며 폴더 상태를 정하고 개수를 센다. + /// 세는 일은 반드시 한 갈래로 한다 - 여러 갈래로 더하면 값이 어긋난다. + /// + private static void Tally(List items, FolderCompareResult result) + { + foreach (var item in items) + { + if (item.IsDirectory) { - var info = new FileInfo(rightPath); - item.RightSize = info.Length; - item.RightModified = info.LastWriteTime; + Tally(item.Children, result); + item.Status = FolderStatus(item); + continue; } - item.Status = Classify(item, leftPath, rightPath, options); - - into.Add(item); - count++; - result.TotalFiles++; switch (item.Status) { @@ -213,13 +226,81 @@ namespace MergeHub.Services default: result.DifferentCount++; break; } } + } + + /// + /// 폴더 하나를 훑어 트리를 세운다. 파일은 아직 가리지 않고 에 모아 둔다. + /// + /// 이름만 얻고 크기·시각을 다시 묻는 대신 로 훑는다. + /// 목록을 얻을 때 크기와 수정 시각이 딸려 오기 때문이다. 예전에는 파일 하나마다 + /// File.Exists 한 번과 new FileInfo(...) 한 번을 양쪽에 물었다 - + /// 4,500개면 그것만 만 팔천 번이고, 실제로 훑는 시간의 열에 아홉이 여기였다. + /// + private static void Walk(string leftRoot, string rightRoot, string relative, + FolderCompareOptions options, Regex? include, Regex? exclude, + List into, List pending, ref int count, + CancellationToken cancel) + { + cancel.ThrowIfCancellationRequested(); + if (count >= MaxEntries) return; + + var leftDir = Path.Combine(leftRoot, relative); + var rightDir = Path.Combine(rightRoot, relative); + + // ---- 파일 ---- + var files = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var info in SafeFiles(leftDir)) + { + files[info.Name] = new SidePair(info, null); + } + + foreach (var info in SafeFiles(rightDir)) + { + files[info.Name] = files.TryGetValue(info.Name, out var got) + ? new SidePair(got.Left, info) + : new SidePair(null, info); + } + + foreach (var pair in files) + { + cancel.ThrowIfCancellationRequested(); + if (count >= MaxEntries) return; + + var name = pair.Key; + var childRelative = string.IsNullOrEmpty(relative) ? name : Path.Combine(relative, name); + if (IsFilteredOut(name, childRelative, include, exclude)) continue; + + var item = new FolderCompareItem(name, childRelative, isDirectory: false) + { + LeftExists = pair.Value.Left is not null, + RightExists = pair.Value.Right is not null, + }; + + if (pair.Value.Left is { } left) + { + item.LeftSize = left.Length; + item.LeftModified = left.LastWriteTime; + } + + if (pair.Value.Right is { } right) + { + item.RightSize = right.Length; + item.RightModified = right.LastWriteTime; + } + + pending.Add(new Pending(item, Path.Combine(leftDir, name), Path.Combine(rightDir, name))); + + into.Add(item); + count++; + } if (!options.IncludeSubfolders) return; // ---- 하위 폴더 ---- var dirNames = new SortedSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in SafeDirs(leftDir)) dirNames.Add(Path.GetFileName(path)); - foreach (var path in SafeDirs(rightDir)) dirNames.Add(Path.GetFileName(path)); + foreach (var info in SafeDirs(leftDir)) dirNames.Add(info.Name); + foreach (var info in SafeDirs(rightDir)) dirNames.Add(info.Name); foreach (var name in dirNames) { @@ -236,11 +317,10 @@ namespace MergeHub.Services RightExists = Directory.Exists(Path.Combine(rightDir, name)), }; - Walk(leftRoot, rightRoot, childRelative, options, include, exclude, result, item.Children, ref count, cancel); - - item.Status = FolderStatus(item); + Walk(leftRoot, rightRoot, childRelative, options, include, exclude, item.Children, pending, ref count, cancel); // 알맹이가 하나도 없는 폴더는 목록만 어지럽힌다. 한쪽에만 있는 폴더는 남긴다. + // 폴더 상태는 아래 것들이 다 가려진 뒤에 정한다(Tally). if (item.Children.Count == 0 && item.LeftExists && item.RightExists) continue; into.Add(item); @@ -248,6 +328,19 @@ namespace MergeHub.Services } } + /// 같은 이름의 양쪽 파일. 없는 쪽은 null. + private readonly struct SidePair + { + public SidePair(FileInfo? left, FileInfo? right) + { + Left = left; + Right = right; + } + + public FileInfo? Left { get; } + public FileInfo? Right { get; } + } + /// 폴더 상태는 아래에 담긴 것들로 정한다. private static FolderEntryStatus FolderStatus(FolderCompareItem folder) { @@ -459,20 +552,30 @@ namespace MergeHub.Services } } - private static IEnumerable SafeFiles(string dir) + /// + /// 크기·수정 시각까지 한 번에 얻는다. Directory.GetFiles 는 이름만 주므로 + /// 나중에 파일마다 한 번씩 더 물어야 한다 - 그것이 훑는 시간의 대부분이었다. + /// + private static FileInfo[] SafeFiles(string dir) { - if (!Directory.Exists(dir)) return Array.Empty(); - try { return Directory.GetFiles(dir); } - catch (UnauthorizedAccessException) { return Array.Empty(); } - catch (IOException) { return Array.Empty(); } + try + { + var info = new DirectoryInfo(dir); + return info.Exists ? info.GetFiles() : Array.Empty(); + } + catch (UnauthorizedAccessException) { return Array.Empty(); } + catch (IOException) { return Array.Empty(); } } - private static IEnumerable SafeDirs(string dir) + private static DirectoryInfo[] SafeDirs(string dir) { - if (!Directory.Exists(dir)) return Array.Empty(); - try { return Directory.GetDirectories(dir); } - catch (UnauthorizedAccessException) { return Array.Empty(); } - catch (IOException) { return Array.Empty(); } + try + { + var info = new DirectoryInfo(dir); + return info.Exists ? info.GetDirectories() : Array.Empty(); + } + catch (UnauthorizedAccessException) { return Array.Empty(); } + catch (IOException) { return Array.Empty(); } } } }