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>
This commit is contained in:
parent
9c0106be57
commit
c46abbd99a
@ -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. **실제 저장소는 열지 않았다.**
|
||||
|
||||
@ -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 이 새던 자리 셋을 막음 | 아래 |
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
| 상태 | 항목 |
|
||||
|---|---|
|
||||
| **완료** | B-1 XAML 색칠이 사실상 없다 (400개 · 42,477줄) |
|
||||
| 대기 | B-2 폴더 비교가 실제 저장소에서 12초 걸린다 |
|
||||
| **완료** | B-2 폴더 비교가 오래 걸린다 — 전체 비교 3.4배 · 빠른 비교 4.7배 |
|
||||
|
||||
### C. 실전 투입
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
// 세 걸음으로 나눈다.
|
||||
// ① 훑기 - 폴더를 돌며 트리를 세운다. 파일은 "아직 안 가린 것"으로 모아 둔다.
|
||||
// ② 가리기 - 모아 둔 것을 여러 갈래로 나눠 가린다. 여기가 파일을 여는 자리다.
|
||||
// ③ 세기 - 트리 차례대로 폴더 상태를 정하고 개수를 센다.
|
||||
//
|
||||
// 예전에는 훑으면서 그 자리에서 가렸다. 그러면 <b>파일을 여는 동안 다음 폴더로 못 간다.</b>
|
||||
// 나눠 놓으면 가리는 일만 여러 갈래로 돌릴 수 있고, <b>결과 차례는 트리가 정하므로
|
||||
// 몇 갈래로 돌리든 똑같이 나온다</b> - 나눠 돌린 것이 화면에 티가 나면 안 된다.
|
||||
var pending = new List<Pending>();
|
||||
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<FolderCompareItem> into, ref int count,
|
||||
CancellationToken cancel)
|
||||
/// <summary>
|
||||
/// 여기부터 여러 갈래로 나눈다. 그 아래는 나눠 봐야 <b>갈래를 세우는 값이 더 든다.</b>
|
||||
/// </summary>
|
||||
private const int ParallelThreshold = 64;
|
||||
|
||||
/// <summary>아직 무엇인지 가리지 않은 파일 하나. 경로를 다시 만들지 않으려고 같이 들고 다닌다.</summary>
|
||||
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<string>(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)
|
||||
/// <summary>모아 둔 파일을 가린다. 몇 개 안 되면 그냥 차례로 한다.</summary>
|
||||
private static void ClassifyAll(List<Pending> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 트리를 차례대로 돌며 폴더 상태를 정하고 개수를 센다.
|
||||
/// <b>세는 일은 반드시 한 갈래로 한다</b> - 여러 갈래로 더하면 값이 어긋난다.
|
||||
/// </summary>
|
||||
private static void Tally(List<FolderCompareItem> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 폴더 하나를 훑어 트리를 세운다. <b>파일은 아직 가리지 않고</b> <paramref name="pending"/> 에 모아 둔다.
|
||||
///
|
||||
/// 이름만 얻고 크기·시각을 다시 묻는 대신 <see cref="DirectoryInfo"/> 로 훑는다.
|
||||
/// 목록을 얻을 때 크기와 수정 시각이 <b>딸려 오기 때문</b>이다. 예전에는 파일 하나마다
|
||||
/// <c>File.Exists</c> 한 번과 <c>new FileInfo(...)</c> 한 번을 양쪽에 물었다 -
|
||||
/// 4,500개면 그것만 만 팔천 번이고, 실제로 훑는 시간의 열에 아홉이 여기였다.
|
||||
/// </summary>
|
||||
private static void Walk(string leftRoot, string rightRoot, string relative,
|
||||
FolderCompareOptions options, Regex? include, Regex? exclude,
|
||||
List<FolderCompareItem> into, List<Pending> 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<string, SidePair>(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<string>(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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>같은 이름의 양쪽 파일. 없는 쪽은 null.</summary>
|
||||
private readonly struct SidePair
|
||||
{
|
||||
public SidePair(FileInfo? left, FileInfo? right)
|
||||
{
|
||||
Left = left;
|
||||
Right = right;
|
||||
}
|
||||
|
||||
public FileInfo? Left { get; }
|
||||
public FileInfo? Right { get; }
|
||||
}
|
||||
|
||||
/// <summary>폴더 상태는 아래에 담긴 것들로 정한다.</summary>
|
||||
private static FolderEntryStatus FolderStatus(FolderCompareItem folder)
|
||||
{
|
||||
@ -459,20 +552,30 @@ namespace MergeHub.Services
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeFiles(string dir)
|
||||
/// <summary>
|
||||
/// 크기·수정 시각까지 한 번에 얻는다. <c>Directory.GetFiles</c> 는 이름만 주므로
|
||||
/// 나중에 파일마다 한 번씩 더 물어야 한다 - 그것이 훑는 시간의 대부분이었다.
|
||||
/// </summary>
|
||||
private static FileInfo[] SafeFiles(string dir)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return Array.Empty<string>();
|
||||
try { return Directory.GetFiles(dir); }
|
||||
catch (UnauthorizedAccessException) { return Array.Empty<string>(); }
|
||||
catch (IOException) { return Array.Empty<string>(); }
|
||||
try
|
||||
{
|
||||
var info = new DirectoryInfo(dir);
|
||||
return info.Exists ? info.GetFiles() : Array.Empty<FileInfo>();
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Array.Empty<FileInfo>(); }
|
||||
catch (IOException) { return Array.Empty<FileInfo>(); }
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SafeDirs(string dir)
|
||||
private static DirectoryInfo[] SafeDirs(string dir)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return Array.Empty<string>();
|
||||
try { return Directory.GetDirectories(dir); }
|
||||
catch (UnauthorizedAccessException) { return Array.Empty<string>(); }
|
||||
catch (IOException) { return Array.Empty<string>(); }
|
||||
try
|
||||
{
|
||||
var info = new DirectoryInfo(dir);
|
||||
return info.Exists ? info.GetDirectories() : Array.Empty<DirectoryInfo>();
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Array.Empty<DirectoryInfo>(); }
|
||||
catch (IOException) { return Array.Empty<DirectoryInfo>(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user