From d55ca39e6a923fd57377c730821ea503d2e72913 Mon Sep 17 00:00:00 2001 From: Msystech Date: Fri, 14 Aug 2026 16:55:33 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EA=B7=9C=EB=AA=A8=20?= =?UTF-8?q?=EC=98=88=EC=82=B0=EC=9D=84=20=EC=9E=B0=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B3=A0=202=EB=8B=A8=EA=B3=84=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=EA=B0=80=20=EC=82=AC=EB=9D=BC=EC=A1=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재설계 1단계 마지막. --scale-budget [출력] [컨트롤수], 기본 1067. --edit-smoke 는 컨트롤 3개 합성 문서에서 배선만 본다. 운영 최대는 1,067개인데 그 규모의 숫자가 CI 에 하나도 없었다. 없으면 재설계가 새 상호작용을 얹었을 때 재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다. 실측(최상위 1,067 + 중첩 자식 533, 마퀴로 1,067개 전부 잡힘): 호버(선택 0) 평균 0.01ms 최악 0.02ms 드래그(선택 1) 평균 1.41ms 최악 7.49ms 드래그(선택 500) 평균 2.70ms 최악 9.73ms 마퀴 전체 선택 7.29ms 되돌리기 1회 7.74ms 예산: 프레임당 16ms(60fps), 한 번 하는 조작 1000ms. 전부 통과. 이 숫자가 재설계 계획의 2단계를 무효로 만든다. 계획은 "좌표 캐시"를 3단계(상호작용) 앞에 필수로 두었고 근거는 "PageOf 가 O(N) 이고 O(N) 루프 안에 있다 — 드래그 프레임당 약 107만 회 참조 비교, 마퀴 전체 선택 한 번에 5~6백만 회"였다. 연산 횟수 산술은 맞다. 틀린 것은 그 횟수에 연산당 비용을 곱하지 않은 것이다 — 참조 비교는 1ns 수준이라 5백만 회가 곧 5ms 이고, 실제로 마퀴가 7.29ms 로 그 예측과 맞는다. 즉 분석은 옳았고 결론("느리다")만 틀렸다. 좌표 캐시는 지금 근거가 없으므로 뺀다. 필요해지면 이 게이트가 먼저 알려 준다. 규모를 두 번 틀리게 잡았다. 처음엔 자식 절반을 최상위에서 빼서 N=534 를 재고 있었다. O(N) 원시연산이 보는 N 은 최상위 개수라 그 상태로는 최악을 못 잰다. '만들려던 수와 다르면 실패'라는 가드가 그것을 잡았다. 재지 못하는 것을 클래스 주석에 적었다 — 여기 도는 것은 뷰모델 계층뿐이고 WPF Measure/Arrange·바인딩 재평가·렌더가 빠져 있다. 줌이 LayoutTransform 이라 눈금마다 서브트리를 다시 재는 비용, Undo 뒤 시각 트리를 다시 세우는 비용은 이 숫자에 없다. 게이트: --scale-budget 5/5, 테스트 277/277, --dialog-shots 넘침 0(대조군 4/4), --edit-smoke 0실패, --maxrect 0실패, --cleartype 11/11, --modal-check 0실패, 빌드 경고 0, --db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. Co-Authored-By: Claude Opus 5 --- src/SheetMe.Designer/App.xaml.cs | 6 + .../Diagnostics/ScaleBudget.cs | 199 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 src/SheetMe.Designer/Diagnostics/ScaleBudget.cs 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) 루프 안에 있다: +/// PageOfPages.FirstOrDefault(p => p.Controls.Contains(root)) 이고 +/// ParentWorldOffsetWorldBoundsOf 가 전부 이걸 탄다. +/// 그래서 호버 한 번, 드래그 한 프레임, 마퀴 전체 선택이 각각 몇 밀리초인지가 이 진단의 답이다. +/// +/// 시간 예산은 넉넉하게 잡는다. 이 숫자는 기계와 부하에 따라 흔들리므로, +/// 좁게 잡으면 멀쩡한 커밋에서 거짓 실패가 난다. 목적은 "빠른지"가 아니라 +/// "사람이 못 쓸 정도로 느려지는 회귀를 잡는 것"이다. +/// 그래서 예산은 체감 한계(호버·드래그는 프레임당 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 +}