diff --git a/src/SheetMe.Core/Catalog/ControlRegistry.cs b/src/SheetMe.Core/Catalog/ControlRegistry.cs
index 385f064..a3f50ec 100644
--- a/src/SheetMe.Core/Catalog/ControlRegistry.cs
+++ b/src/SheetMe.Core/Catalog/ControlRegistry.cs
@@ -48,7 +48,7 @@ public static class ControlRegistry
Type = "MaskedTextBox", DisplayName = "마스크입력", DefaultW = 160, DefaultH = 22, CreatesField = true,
Properties = new PropertyDef[]
{
- new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Text },
+ new() { Key = "Mask", Label = "마스크", Editor = PropEditorKind.Mask },
new() { Key = "IsRequiredValue", Label = "필수 입력", Editor = PropEditorKind.Choice, Choices = new[] { "No", "Yes" } },
new() { Key = "DataInterfaceTag", Label = "데이터 태그", Editor = PropEditorKind.DataInterfaceTag },
},
@@ -244,6 +244,9 @@ public enum PropEditorKind
DataActionTag,
/// SQL 쿼리 — 전용 편집기 창(치환 변수 삽입 지원)
SqlQuery,
+
+ /// 입력 마스크 — 프리셋 목록 + 미리보기 대화상자
+ Mask,
}
/// 인스펙터 속성 정의 1건 — PropBag 의 레거시 Property 를 편집
diff --git a/src/SheetMe.Core/Catalog/LegacyMaskCatalog.cs b/src/SheetMe.Core/Catalog/LegacyMaskCatalog.cs
new file mode 100644
index 0000000..d8a5f78
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/LegacyMaskCatalog.cs
@@ -0,0 +1,63 @@
+namespace SheetMe.Core.Catalog;
+
+/// 마스크 프리셋 1건
+/// 분류 — 편집기 목록의 그룹 헤더
+/// Mask 속성에 들어갈 값
+/// 사람이 읽는 설명
+/// 운영 활성 디자인에서의 사용 건수(0 = 운영 미사용, 표준 제공분)
+public sealed record MaskPreset(string Group, string Mask, string Description, int UsageCount);
+
+///
+/// MaskedTextBox 마스크 프리셋 — 레거시 .NET 마스크 디자이너의 대응물.
+///
+/// .NET 기본 프리셋(짧은 날짜/전화번호/우편번호…)은 문화권 일반 서식이라 이 시스템과 안 맞는다.
+/// 대신 운영 활성 디자인 전수 census(2026-08-12)에서 실제로 쓰이는 마스크를 사용 빈도와 함께
+/// 제공한다 — MaskedTextBox 475개 / Mask 보유 460개 / 고유 44종.
+/// 이렇게 하면 디자이너가 기존 서식과 같은 표기를 그대로 고를 수 있어 표기 흔들림이 줄어든다.
+///
+public static class LegacyMaskCatalog
+{
+ #region Member Fields
+ /// 프리셋 — 그룹별, 그룹 안에서는 사용 빈도 내림차순
+ public static readonly IReadOnlyList All = new MaskPreset[]
+ {
+ // ── 날짜 ──
+ new("날짜", "0000년 90월 90일", "연월일 (한글)", 198),
+ new("날짜", "0000-00-00", "연-월-일", 51),
+ new("날짜", "0000.00.00.", "연.월.일.", 21),
+ new("날짜", "0000년 90월 90일", "연월일 (한글, 넓은 간격)", 12),
+ new("날짜", "0000년 00월 00일", "연월일 (한글, 전체 필수)", 6),
+ new("날짜", "0000 년 00월 00일", "연월일 (한글, 연도 뒤 공백)", 4),
+ new("날짜", "0000.90.90.", "연.월.일. (월일 선택)", 4),
+ new("날짜", "90월 90일", "월일", 15),
+ new("날짜", "0000-00", "연-월", 1),
+ new("날짜", @"2\000년 90월 90일", "연월일 (2000년대 고정)", 1),
+
+ // ── 시간 ──
+ new("시간", "90:90", "시:분", 36),
+ new("시간", "90시90분", "시분 (한글)", 22),
+ new("시간", "90:00", "시:분 (분 필수)", 11),
+ new("시간", "00:00", "시:분 (전체 필수)", 8),
+ new("시간", "00시 00분", "시분 (한글, 넓은 간격)", 5),
+ new("시간", "00분 00초", "분초", 1),
+ new("시간", "90시간90분", "소요 시간", 1),
+
+ // ── 날짜+시간 ──
+ new("날짜+시간", "0000-00-00 90:00", "연-월-일 시:분", 5),
+ new("날짜+시간", "0000년 90월 90일 90시 90분", "연월일 시분 (한글)", 4),
+ new("날짜+시간", "####/##/## ##:##", "연/월/일 시:분 (선택 입력)", 1),
+
+ // ── 번호 ──
+ new("번호", "000-9000-0000", "휴대전화", 6),
+ new("번호", "000-00-00000-0", "사업자·기관 번호", 1),
+
+ // ── 표준(운영 미사용, 필요 시) ──
+ new("표준", "000000-0000000", "주민등록번호", 0),
+ new("표준", "00000", "우편번호(5자리)", 0),
+ new("표준", "LLL", "영문 3자", 0),
+ };
+
+ /// 운영에서 실제 쓰이는 프리셋만(사용 건수 > 0)
+ public static IEnumerable InUse => All.Where(p => p.UsageCount > 0);
+ #endregion
+}
diff --git a/src/SheetMe.Core/Serialization/LegacyMask.cs b/src/SheetMe.Core/Serialization/LegacyMask.cs
index b0f7d7e..5578998 100644
--- a/src/SheetMe.Core/Serialization/LegacyMask.cs
+++ b/src/SheetMe.Core/Serialization/LegacyMask.cs
@@ -74,5 +74,108 @@ public static class LegacyMask
}
return result.ToString();
}
+
+ ///
+ /// 마스크에 입력을 채운 결과 — 마스크 편집기의 시험 입력 미리보기용.
+ ///
+ /// 규칙은 둘뿐이라 결과를 예측할 수 있다.
+ ///
+ /// - 리터럴은 항상 그대로 출력되고 입력을 소비하지 않는다.
+ /// - 지시자는 입력에서 자기에게 맞는 첫 문자를 가져온다. 맞지 않는 문자와
+ /// 공백은 구분자로 보고 건너뛴다. 채워지지 않은 자리는 프롬프트 문자로 남는다.
+ ///
+ ///
+ ///
+ /// 덕분에 값만 치든(20260812) 구분자를 같이 치든(2026-08-12, 2026년 08월 12일)
+ /// 같은 결과가 나온다. 공백을 건너뛰는 이유는 9 가 공백도 받아들이기 때문이다 —
+ /// 그대로 두면 0000년 90월 90일 에 2026년 08월 12일 을 넣을 때 구분자 공백이
+ /// 데이터 자리를 먹어 한 칸씩 밀린다. 9 의 공백 허용은 '빈 자리'를 위한 것이고
+ /// 그건 프롬프트 문자가 이미 표현한다.
+ ///
+ ///
+ ///
+ /// 한계: 리터럴이 숫자인 마스크(2\000년 — 운영 460건 중 1건)는 사용자가 리터럴까지 치면
+ /// 어디까지가 데이터인지 원리적으로 가릴 수 없다. 이때는 편집 가능한 자리만 치면 된다(26).
+ /// 미리보기용이므로 규칙을 단순하게 두는 편이 낫다고 판단했다.
+ ///
+ ///
+ public static string Apply(string? mask, string? input, char promptChar = DefaultPromptChar)
+ {
+ if (string.IsNullOrEmpty(mask))
+ {
+ return string.Empty;
+ }
+
+ var source = input ?? string.Empty;
+ var next = 0;
+ var result = new StringBuilder(mask.Length);
+
+ for (var i = 0; i < mask.Length; i++)
+ {
+ var c = mask[i];
+ switch (c)
+ {
+ case '\\':
+ result.Append(i + 1 < mask.Length ? mask[++i] : c);
+ break;
+
+ case '<' or '>' or '|':
+ break;
+
+ case '0' or '9' or '#' or 'L' or '?' or '&' or 'C' or 'A' or 'a':
+ while (next < source.Length
+ && (char.IsWhiteSpace(source[next]) || !Accepts(c, source[next])))
+ {
+ next++;
+ }
+ if (next < source.Length)
+ {
+ result.Append(Transform(mask, i, source[next]));
+ next++;
+ }
+ else
+ {
+ result.Append(promptChar);
+ }
+ break;
+
+ default:
+ result.Append(c);
+ break;
+ }
+ }
+ return result.ToString();
+ }
+
+ /// 지시자가 받아들이는 문자인지 — WinForms MaskedTextBox 규약
+ private static bool Accepts(char directive, char value) => directive switch
+ {
+ '0' => char.IsDigit(value),
+ '9' => char.IsDigit(value) || value == ' ',
+ '#' => char.IsDigit(value) || value is ' ' or '+' or '-',
+ 'L' => char.IsLetter(value),
+ '?' => char.IsLetter(value) || value == ' ',
+ 'A' => char.IsLetterOrDigit(value),
+ 'a' => char.IsLetterOrDigit(value) || value == ' ',
+ '&' => !char.IsControl(value),
+ 'C' => !char.IsControl(value) || value == ' ',
+ _ => false,
+ };
+
+ /// 직전에 나온 대소문자 변환자(< > |)를 적용한다
+ private static char Transform(string mask, int directiveIndex, char value)
+ {
+ for (var i = directiveIndex - 1; i >= 0; i--)
+ {
+ switch (mask[i])
+ {
+ case '>': return char.ToUpperInvariant(value);
+ case '<': return char.ToLowerInvariant(value);
+ case '|': return value;
+ default: continue; // 변환자를 만날 때까지 거슬러 올라간다
+ }
+ }
+ return value;
+ }
#endregion
}
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
index eff8eb5..fb5aabb 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/InspectorViewModel.cs
@@ -257,6 +257,7 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags),
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
+ PropEditorKind.Mask => new MaskRowViewModel(def.Label),
_ => new TextRowViewModel(def.Label),
};
AddRow(row, binding);
diff --git a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
index 93691c6..6bb6ddc 100644
--- a/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
+++ b/src/SheetMe.Designer/ViewModels/Inspector/PropertyRows.cs
@@ -167,6 +167,40 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
}
}
+///
+/// 마스크 행 — 값과 표시 미리보기를 함께 보여주고, 찾아보기로 프리셋 편집기를 연다.
+/// 레거시는 WinForms 기본 마스크 디자이너(프리셋 + 시험 입력)를 제공했다.
+///
+public sealed class MaskRowViewModel : PropertyRowViewModel
+{
+ /// 이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인
+ public string Preview => ValueText.Length == 0
+ ? "(마스크 없음)"
+ : SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
+
+ /// 마스크 편집기 열기
+ public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
+
+ public MaskRowViewModel(string label) : base(label)
+ {
+ BrowseCommand = new M.Framework.WPF.Command((sender, e) => OnBrowse());
+ }
+
+ protected override void OnValueApplied() => OnPropertyChanged(nameof(Preview));
+
+ private void OnBrowse()
+ {
+ var dialog = new Views.MaskPickerDialogView(ValueText)
+ {
+ Owner = System.Windows.Application.Current.MainWindow,
+ };
+ if (dialog.ShowDialog() == true)
+ {
+ ValueText = dialog.Mask;
+ }
+ }
+}
+
/// SQL 쿼리 행 — 요약 표시 + 전용 편집기 창(치환 변수 삽입)
public sealed class QueryRowViewModel : PropertyRowViewModel
{
diff --git a/src/SheetMe.Designer/Views/InspectorView.xaml b/src/SheetMe.Designer/Views/InspectorView.xaml
index 89e39c6..630cd47 100644
--- a/src/SheetMe.Designer/Views/InspectorView.xaml
+++ b/src/SheetMe.Designer/Views/InspectorView.xaml
@@ -106,6 +106,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/MaskPickerDialogView.xaml b/src/SheetMe.Designer/Views/MaskPickerDialogView.xaml
new file mode 100644
index 0000000..2bddcd9
--- /dev/null
+++ b/src/SheetMe.Designer/Views/MaskPickerDialogView.xaml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/MaskPickerDialogView.xaml.cs b/src/SheetMe.Designer/Views/MaskPickerDialogView.xaml.cs
new file mode 100644
index 0000000..86a328b
--- /dev/null
+++ b/src/SheetMe.Designer/Views/MaskPickerDialogView.xaml.cs
@@ -0,0 +1,88 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using SheetMe.Core.Catalog;
+using SheetMe.Core.Serialization;
+
+namespace SheetMe.Designer.Views;
+
+///
+/// 마스크 편집기 — 레거시 .NET 마스크 디자이너의 대응물.
+/// 레거시는 MaskedTextBox 가 WinForms 기본 편집기를 물려받아 프리셋 목록과 시험 입력을 제공했는데,
+/// SheetMe 인스펙터는 평범한 텍스트 상자뿐이라 마스크 문법을 외워야 했다.
+///
+/// 프리셋은 .NET 문화권 기본값이 아니라 (운영 실사용 census)를 쓴다.
+///
+public partial class MaskPickerDialogView : Window
+{
+ #region Types
+ /// 목록 표시용 투영 — 사용 건수를 사람이 읽는 라벨로
+ private sealed record PresetRow(string Group, string Mask, string Description, string UsageLabel);
+ #endregion
+
+ #region Properties
+ /// 확정된 마스크 — 취소 시 의미 없음
+ public string Mask { get; private set; } = string.Empty;
+ #endregion
+
+ #region Constructors
+ public MaskPickerDialogView(string currentMask)
+ {
+ InitializeComponent();
+
+ var rows = LegacyMaskCatalog.All.Select(p => new PresetRow(
+ p.Group, p.Mask, p.Description,
+ p.UsageCount > 0 ? $"{p.UsageCount:N0}건" : "표준"));
+ var view = new CollectionViewSource { Source = rows.ToList() };
+ view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PresetRow.Group)));
+ PresetList.ItemsSource = view.View;
+
+ MaskBox.Text = currentMask;
+ UpdatePreview();
+
+ Loaded += (_, _) =>
+ {
+ MaskBox.Focus();
+ MaskBox.CaretIndex = MaskBox.Text.Length;
+ };
+ }
+ #endregion
+
+ #region Methods
+ private void OnMaskChanged(object sender, TextChangedEventArgs e) => UpdatePreview();
+
+ private void OnPresetSelected(object sender, SelectionChangedEventArgs e)
+ {
+ if (PresetList.SelectedItem is PresetRow row && row.Mask != MaskBox.Text)
+ {
+ MaskBox.Text = row.Mask;
+ }
+ }
+
+ /// 빈 값 표시와 시험 입력 결과를 즉시 갱신 — 마스크 문법을 몰라도 결과로 확인할 수 있게
+ private void UpdatePreview()
+ {
+ // PromptPreview 는 InitializeComponent 이전에는 없다(TextChanged 가 먼저 뛸 수 있음)
+ if (PromptPreview is null || TestPreview is null)
+ {
+ return;
+ }
+
+ var mask = MaskBox.Text;
+ PromptPreview.Text = mask.Length == 0 ? "(마스크 없음 — 자유 입력)" : LegacyMask.ToPromptDisplay(mask);
+ TestPreview.Text = mask.Length == 0 ? string.Empty : LegacyMask.Apply(mask, TestInputBox.Text);
+ }
+
+ private void OnClear(object sender, RoutedEventArgs e)
+ {
+ Mask = string.Empty;
+ DialogResult = true;
+ }
+
+ private void OnConfirm(object sender, RoutedEventArgs e)
+ {
+ Mask = MaskBox.Text;
+ DialogResult = true;
+ }
+ #endregion
+}
diff --git a/tests/SheetMe.Core.Tests/LegacyMaskTests.cs b/tests/SheetMe.Core.Tests/LegacyMaskTests.cs
index 8def566..8f48134 100644
--- a/tests/SheetMe.Core.Tests/LegacyMaskTests.cs
+++ b/tests/SheetMe.Core.Tests/LegacyMaskTests.cs
@@ -1,3 +1,4 @@
+using SheetMe.Core.Catalog;
using SheetMe.Core.Serialization;
namespace SheetMe.Core.Tests;
@@ -92,4 +93,129 @@ public sealed class LegacyMaskTests
{
Assert.AreEqual(mask.Length, LegacyMask.ToPromptDisplay(mask).Length);
}
+
+ // ── 시험 입력 미리보기(Apply) ──
+
+ /// 입력이 마스크 자리를 순서대로 채운다
+ [TestMethod]
+ [DataRow("0000년 90월 90일", "20260812", "2026년 08월 12일")]
+ [DataRow("0000년 90월 90일", "2026", "2026년 __월 __일")] // 부분 입력 — 나머지는 프롬프트
+ [DataRow("0000-00-00", "20260812", "2026-08-12")]
+ [DataRow("90:90", "0930", "09:30")]
+ [DataRow("000-9000-0000", "01012345678", "010-1234-5678")]
+ public void Apply_FillsPositionsInOrder(string mask, string input, string expected)
+ {
+ Assert.AreEqual(expected, LegacyMask.Apply(mask, input));
+ }
+
+ /// 값만 치든 구분자를 같이 치든 결과가 같아야 한다
+ [TestMethod]
+ [DataRow("0000-00-00", "2026-08-12", "2026-08-12")]
+ [DataRow("0000-00-00", "20260812", "2026-08-12")]
+ [DataRow("0000년 90월 90일", "2026년 08월 12일", "2026년 08월 12일")]
+ [DataRow("0000년 90월 90일", "20260812", "2026년 08월 12일")]
+ [DataRow("000-9000-0000", "010-1234-5678", "010-1234-5678")]
+ [DataRow("000-9000-0000", "01012345678", "010-1234-5678")]
+ [DataRow("90시90분", "0930", "09시30분")]
+ public void Apply_SeparatorsInInputAreIgnored(string mask, string input, string expected)
+ {
+ Assert.AreEqual(expected, LegacyMask.Apply(mask, input));
+ }
+
+ /// 지시자에 맞지 않는 문자는 건너뛴다(WinForms 가 거부하는 것과 같은 결과)
+ [TestMethod]
+ public void Apply_RejectsInvalidCharacters()
+ {
+ Assert.AreEqual("12__", LegacyMask.Apply("0000", "ab12")); // 숫자 자리에 영문
+ Assert.AreEqual("AB__", LegacyMask.Apply("LLLL", "12AB")); // 영문 자리에 숫자
+ }
+
+ /// 대소문자 변환자는 입력에 적용된다
+ [TestMethod]
+ public void Apply_AppliesCaseModifiers()
+ {
+ Assert.AreEqual("ABC", LegacyMask.Apply(">LLL", "abc"));
+ Assert.AreEqual("abc", LegacyMask.Apply("
+ /// 이스케이프 리터럴은 입력을 소비하지 않는다.
+ /// 마스크 2\000년 은 리터럴 '2','0' 뒤에 입력 자리 2칸이라 표시가 20__년 이고,
+ /// 편집 가능한 자리(2칸)만 입력하면 된다.
+ ///
+ [TestMethod]
+ public void Apply_EscapedLiteralConsumesNoInput()
+ {
+ Assert.AreEqual("20__년", LegacyMask.ToPromptDisplay(@"2\000년"));
+ Assert.AreEqual("2026년", LegacyMask.Apply(@"2\000년", "26"));
+ Assert.AreEqual("202_년", LegacyMask.Apply(@"2\000년", "2"));
+ }
+
+ ///
+ /// 입력의 공백은 구분자로 보고 건너뛴다 — '9' 가 공백을 허용해서 생기는 밀림 방지.
+ /// 빈 자리는 프롬프트 문자가 표현하므로 공백을 데이터로 받을 이유가 없다.
+ ///
+ [TestMethod]
+ public void Apply_TreatsInputWhitespaceAsSeparator()
+ {
+ Assert.AreEqual("2026년 08월 12일", LegacyMask.Apply("0000년 90월 90일", "2026 08 12"));
+ Assert.AreEqual("09:30", LegacyMask.Apply("90:90", "09 30"));
+ }
+
+ /// 입력이 없으면 빈 값 표시와 같아야 한다
+ [TestMethod]
+ [DataRow("0000년 90월 90일")]
+ [DataRow("000-9000-0000")]
+ public void Apply_WithoutInputEqualsPromptDisplay(string mask)
+ {
+ Assert.AreEqual(LegacyMask.ToPromptDisplay(mask), LegacyMask.Apply(mask, null));
+ Assert.AreEqual(LegacyMask.ToPromptDisplay(mask), LegacyMask.Apply(mask, string.Empty));
+ }
+
+ /// 빈 마스크는 빈 결과 — 자유 입력 필드
+ [TestMethod]
+ public void Apply_EmptyMask()
+ {
+ Assert.AreEqual(string.Empty, LegacyMask.Apply(null, "abc"));
+ Assert.AreEqual(string.Empty, LegacyMask.Apply(string.Empty, "abc"));
+ }
+
+ // ── 프리셋 카탈로그 ──
+
+ /// 프리셋 마스크는 전부 유효해야 한다 — 표시 길이가 보존되고 지시자가 하나 이상 있어야 한다
+ [TestMethod]
+ public void Presets_AreWellFormed()
+ {
+ foreach (var preset in LegacyMaskCatalog.All)
+ {
+ var display = LegacyMask.ToPromptDisplay(preset.Mask);
+ Assert.AreNotEqual(preset.Mask, display,
+ $"[{preset.Mask}] 에 입력 지시자가 없습니다 — 마스크로 쓸 수 없습니다.");
+ Assert.IsFalse(string.IsNullOrWhiteSpace(preset.Description), $"[{preset.Mask}] 설명 누락");
+ Assert.IsFalse(string.IsNullOrWhiteSpace(preset.Group), $"[{preset.Mask}] 그룹 누락");
+ }
+ }
+
+ /// 프리셋 마스크에 중복이 없어야 한다
+ [TestMethod]
+ public void Presets_HaveNoDuplicates()
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var preset in LegacyMaskCatalog.All)
+ {
+ Assert.IsTrue(seen.Add(preset.Mask), $"중복 프리셋: {preset.Mask}");
+ }
+ }
+
+ /// 운영 최다 사용 마스크가 프리셋에 있어야 한다(census 근거)
+ [TestMethod]
+ [DataRow("0000년 90월 90일")] // 198건
+ [DataRow("0000-00-00")] // 51건
+ [DataRow("90:90")] // 36건
+ [DataRow("90시90분")] // 22건
+ [DataRow("0000.00.00.")] // 21건
+ public void Presets_CoverMostUsedProductionMasks(string mask)
+ {
+ Assert.IsTrue(LegacyMaskCatalog.All.Any(p => p.Mask == mask), $"운영 다빈도 마스크 누락: {mask}");
+ }
}