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