diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs
index 9f4d246..1bbb001 100644
--- a/src/SheetMe.Designer/App.xaml.cs
+++ b/src/SheetMe.Designer/App.xaml.cs
@@ -288,6 +288,12 @@ public partial class App : Application
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
}
+ if (args.Length >= 1 && args[0] == "--scale-budget")
+ {
+ var count = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 1067;
+ return Diagnostics.ScaleBudget.Run(args.Length >= 2 ? args[1] : null, count);
+ }
+
if (args.Length >= 1 && args[0] == "--cleartype")
{
return Diagnostics.ClearTypeCheck.Run();
diff --git a/src/SheetMe.Designer/Diagnostics/ScaleBudget.cs b/src/SheetMe.Designer/Diagnostics/ScaleBudget.cs
new file mode 100644
index 0000000..21b6bc5
--- /dev/null
+++ b/src/SheetMe.Designer/Diagnostics/ScaleBudget.cs
@@ -0,0 +1,199 @@
+using System.Diagnostics;
+using System.Text;
+using System.Windows;
+using SheetMe.Core.Models;
+using SheetMe.Designer.Services;
+using SheetMe.Designer.ViewModels;
+
+namespace SheetMe.Designer.Diagnostics;
+
+///
+/// 운영 규모에서 상호작용이 얼마나 걸리는지 잰다 — --scale-budget [출력파일] [컨트롤수].
+///
+/// 왜 필요한가. --edit-smoke 는 컨트롤 3개 합성 문서에서 배선만 확인한다.
+/// 운영 최대 서식은 1,067개다. 그 규모에서 무엇이 느린지 지금 CI 에 보이는 숫자가 하나도 없어서,
+/// 재설계가 새 상호작용을 얹으면 재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다.
+///
+/// 무엇을 겨누는가. 월드 좌표 조회가 O(N) 이고 그것이 O(N) 루프 안에 있다:
+/// PageOf 가 Pages.FirstOrDefault(p => p.Controls.Contains(root)) 이고
+/// ParentWorldOffset → WorldBoundsOf 가 전부 이걸 탄다.
+/// 그래서 호버 한 번, 드래그 한 프레임, 마퀴 전체 선택이 각각 몇 밀리초인지가 이 진단의 답이다.
+///
+/// 시간 예산은 넉넉하게 잡는다. 이 숫자는 기계와 부하에 따라 흔들리므로,
+/// 좁게 잡으면 멀쩡한 커밋에서 거짓 실패가 난다. 목적은 "빠른지"가 아니라
+/// "사람이 못 쓸 정도로 느려지는 회귀를 잡는 것"이다.
+/// 그래서 예산은 체감 한계(호버·드래그는 프레임당 16ms, 한 번 하는 조작은 1초) 기준으로 둔다.
+///
+/// 재지 못하는 것. 여기서 도는 것은 뷰모델 계층뿐이다 — 시각 트리가 없으므로
+/// WPF 의 Measure/Arrange, 바인딩 재평가, 렌더가 빠져 있다.
+/// 줌이 LayoutTransform 이라 눈금마다 서브트리 전체를 다시 재는 비용,
+/// Undo 뒤 시각 트리를 통째로 다시 세우는 비용은 이 숫자에 들어 있지 않다.
+/// 이 진단이 답하는 질문은 "상호작용 산술이 규모에서 터지는가"이고, 답은 아니다 —
+/// 실측으로 O(N)-in-O(N) 경고가 과장이었음이 드러났다(§커밋 메시지 참조).
+///
+internal static class ScaleBudget
+{
+ #region Member Fields
+ /// 프레임당 이 이상이면 60fps 를 못 지킨다 — 드래그가 끊겨 보인다
+ private const double FrameBudgetMs = 16;
+
+ /// 한 번 하는 조작(마퀴 전체 선택, 되돌리기)이 이 이상이면 멈춘 것으로 보인다
+ private const double GestureBudgetMs = 1000;
+
+ private const int HoverFrames = 100;
+ private const int DragFrames = 60;
+ #endregion
+
+ #region Methods
+ public static int Run(string? outputPath, int controlCount)
+ {
+ var report = new StringBuilder();
+ var failures = 0;
+
+ report.AppendLine($"운영 규모 예산 — 컨트롤 {controlCount}개");
+ report.AppendLine();
+
+ var designer = BuildPage(controlCount);
+ var controls = designer.Pages[0].Controls;
+ report.AppendLine($"최상위 {controls.Count}개 + 중첩 자식 {controls.Sum(c => c.Model.Children.Count)}개,"
+ + $" 페이지 {designer.Pages.Count}장");
+ if (controls.Count != controlCount)
+ {
+ report.AppendLine($"★ 만들려던 수와 다르다 — 이 상태의 숫자는 비교 대상이 못 된다");
+ failures++;
+ }
+ report.AppendLine();
+
+ var controller = new InteractionController(designer);
+ var none = new PointerContext(false, false, false, 1);
+ var ctrl = new PointerContext(true, false, false, 1);
+
+ // ① 호버 — 드래그가 아니어도 마우스만 움직이면 매 프레임 히트테스트가 돈다
+ failures += Budget(report, "호버(선택 0)", HoverFrames, FrameBudgetMs, frame =>
+ designer.Interaction.CursorAt(new Point(60 + (frame % 40), 60 + (frame % 40))));
+
+ // ② 드래그 1개 — 가장 흔한 조작
+ designer.Selection.SetSingle(controls[0]);
+ failures += Budget(report, "드래그(선택 1)", DragFrames, FrameBudgetMs, frame =>
+ {
+ if (frame == 0)
+ {
+ controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
+ }
+ controller.PointerMove(new Point(controls[0].X + 6 + frame, controls[0].Y + 6 + frame), none);
+ });
+ controller.CancelDrag();
+
+ // ③ 드래그 다수 — 정렬·같은 크기·간격 균등이 겨냥하는 바로 그 상황이다
+ var many = Math.Min(500, controls.Count);
+ designer.Selection.Set(controls.Take(many).ToList());
+ var anchor = controls[0];
+ failures += Budget(report, $"드래그(선택 {many})", DragFrames, FrameBudgetMs, frame =>
+ {
+ if (frame == 0)
+ {
+ controller.PointerDown(new Point(anchor.X + 2, anchor.Y + 2), none);
+ }
+ controller.PointerMove(new Point(anchor.X + 6 + frame, anchor.Y + 6 + frame), none);
+ });
+ controller.CancelDrag();
+ designer.Selection.Clear();
+
+ // ④ 마퀴로 전부 선택 — 한 제스처 안에서 O(N²) 가 터지는 자리
+ failures += Budget(report, "마퀴 전체 선택", 1, GestureBudgetMs, _ =>
+ {
+ controller.PointerDown(new Point(-40, -40), ctrl);
+ controller.PointerMove(new Point(4000, 40000), ctrl);
+ controller.PointerUp(new Point(4000, 40000), ctrl);
+ });
+ report.AppendLine($" (마퀴로 잡힌 것 {designer.Selection.Items.Count}개)");
+
+ // ⑤ 되돌리기 — 문서 전체 딥클론 + VM 전체 재생성이라 앱에서 가장 비싼 키 한 방이다
+ designer.Selection.SetSingle(controls[0]);
+ controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
+ controller.PointerMove(new Point(controls[0].X + 40, controls[0].Y + 40), none);
+ controller.PointerUp(new Point(controls[0].X + 40, controls[0].Y + 40), none);
+ failures += Budget(report, "되돌리기 1회", 1, GestureBudgetMs, _ => designer.Undo.Undo());
+
+ report.AppendLine();
+ report.AppendLine($"결과: 실패 {failures}건");
+ var text = report.ToString();
+ Console.Error.Write(text);
+ if (outputPath is { Length: > 0 })
+ {
+ System.IO.File.WriteAllText(outputPath, text);
+ }
+ return failures == 0 ? 0 : 1;
+ }
+
+ ///
+ /// 프레임당 평균으로 판정한다 — 최악값은 GC 한 번에 튀어서 거짓 실패를 낸다.
+ /// 최악값은 함께 적어 사람이 볼 수 있게만 남긴다.
+ ///
+ private static int Budget(StringBuilder report, string name, int frames, double budgetMs, Action work)
+ {
+ // 첫 회는 JIT·지연 초기화가 섞여 본 측정과 성격이 다르다 — 버리는 대신 따로 적는다
+ var warmUp = Stopwatch.StartNew();
+ work(0);
+ warmUp.Stop();
+
+ var worst = 0.0;
+ var watch = Stopwatch.StartNew();
+ for (var frame = 0; frame < frames; frame++)
+ {
+ var one = Stopwatch.StartNew();
+ work(frame);
+ one.Stop();
+ worst = Math.Max(worst, one.Elapsed.TotalMilliseconds);
+ }
+ watch.Stop();
+
+ var average = watch.Elapsed.TotalMilliseconds / Math.Max(1, frames);
+ var ok = average <= budgetMs;
+ report.AppendLine($"{(ok ? "PASS" : "FAIL")} {name,-18} 평균 {average,8:0.00}ms"
+ + $" (예산 {budgetMs}ms, 최악 {worst:0.00}ms, 첫회 {warmUp.Elapsed.TotalMilliseconds:0.00}ms, {frames}회)");
+ return ok ? 0 : 1;
+ }
+
+ ///
+ /// 운영 최대 규모의 페이지를 만든다. 컨테이너에 담긴 자식을 섞는다 —
+ /// 중첩 자식이 28,009개(그 중 RadioButton 15,026개)라 그것이 실제 모양이고,
+ /// 월드 좌표 조회가 부모를 타고 올라가는 비용도 거기서만 나온다.
+ ///
+ private static DesignerViewModel BuildPage(int count)
+ {
+ var document = new FormDocument();
+ var page = new FormPage { Width = 720, Height = 856 };
+ document.Pages.Add(page);
+
+ // 최상위를 정확히 count 개로 만든다. O(N) 원시연산(PageOf 의 Controls.Contains,
+ // ControlsIntersecting)이 보는 N 은 최상위 개수이므로, 자식을 최상위에서 빼면 N 이 절반이 되어
+ // 최악을 재지 못한다(처음에 그렇게 만들어 534 를 재고 있었다).
+ // 컨테이너 자식은 그 위에 더 얹는다 — 중첩 자식 28,009개가 실제 모양이고,
+ // 월드 좌표가 부모를 타고 올라가는 비용은 거기서만 난다.
+ var panel = new ControlElement { Type = "Panel", Id = "Panel1" };
+ panel.Bounds = new LayoutRect { X = 20, Y = 20, W = 660, H = 400 };
+ page.Controls.Add(panel);
+ for (var i = 0; i < count / 2; i++)
+ {
+ panel.Children.Add(new ControlElement
+ {
+ Type = "RadioButton",
+ Id = $"N{i:0000}",
+ Bounds = new LayoutRect { X = 8 + (i % 40) * 16, Y = 8 + (i / 40 % 24) * 16, W = 60, H = 14 },
+ });
+ }
+
+ for (var i = 0; i < count - 1; i++)
+ {
+ page.Controls.Add(new ControlElement
+ {
+ Type = i % 3 == 0 ? "RadioButton" : i % 3 == 1 ? "Label" : "TextBox",
+ Id = $"C{i:0000}",
+ Bounds = new LayoutRect { X = 20 + (i % 40) * 17, Y = 440 + (i / 40 % 24) * 17, W = 60, H = 14 },
+ });
+ }
+ return new DesignerViewModel(document);
+ }
+ #endregion
+}