태그 선택 — 타입별로 자주 쓰는 것을 그 자리에 깐다

태그를 하나 걸려면 매번 모달을 열어 500여 개(액션 160여 개) 목록에서 찾아야 했다.
한 서식이 쓰는 서로 다른 태그는 중앙값 6개, 최대 27개다(--db-tags) — 서식 하나 만들 때마다
그 짓을 예닐곱 번 한다.

■ 실측이 설계를 정했다

--db-tags 진단을 신설해 운영 디자인 1,271건의 태그 사용 8,032건을 컨트롤 타입별로 집계했다.
후보가 타입마다 거의 완전히 갈린다:

  이미지 · 데이터 태그        343건 / 7종 — 상위 16종이 100%  (서명·직인·로고가 전부)
  라디오 · 액션 태그          736건 / 7종 — 100%             (RadCheckControl 이 580건)
  체크박스 · 액션 태그        357건 / 7종 — 100%
  텍스트박스 · 액션 태그      200건 / 6종 — 100%
  마스크입력 · 데이터 태그     57건 / 13종 — 100%
  버튼 · 액션 태그            393건 / 46종 — 상위 16종이 83%
  텍스트박스 · 데이터 태그   5,927건 / 144종 — 상위 16종이 65%  (여기만 꼬리가 길다)

즉 일곱 조합 중 다섯은 **짧은 목록이 운영 전체를 덮는다**. 이미지 하나 고르려고
500개를 뒤지고 있었다는 뜻이다.

■ 바꾼 것

· 인스펙터 행 아래에 그 타입에서 자주 쓰는 태그를 빈도순 칩으로 깐다(최대 8개). 누르면 바로 값이 된다.
  칩은 **값이 비어 있을 때만** 뜬다 — 이미 정해진 행 아래에도 깔면 좁은 패널이 금방 길어진다.
· 피커를 열면 자주 쓰는 것이 맨 위로 올라온다(그 안에서는 빈도순, 나머지는 원래 가나다순 유지).
  건수 표시에 '자주 쓰는 것 N건이 위에' 를 덧붙였다.
· 전체 카탈로그와 자유 입력은 그대로다. 제안은 제안이지 제한이 아니다 —
  타입별 자료가 없는 조합의 액션 태그는 아예 비워 둔다. 추측으로 권하면
  그 칸은 배포된 뒤에야 빈칸으로 드러난다.

순서를 가나다순이 아니라 빈도순으로 둔 것이 핵심이다. 가나다순은 운영의 절반을 차지하는
RadCheckControl 과 한 번도 안 쓰인 태그를 같은 무게로 보여 준다.

■ 곁다리로 확인한 것

카탈로그에 없는 값이 데이터 태그 1종(PAT_전체주소 ×11), 액션 태그 1종(OprEmrList ×5) 나왔다.
둘 다 레거시 bzDataInterface / EN_DataActionTyp 에 존재하지 않는다 —
우리 카탈로그가 맞고, 저 값들은 운영 서식에 이미 박힌 죽은 태그다(런타임이 해석하지 못한다).
우리가 만든 것이 아니라 손대지 않았다.

편집 스모크 17건 추가(타입별 좁힘 / 빈도 순서 / 자료 없는 타입은 빈 목록 /
제안이 전부 카탈로그에 있는지 12조합 검사 / 값이 정해지면 칩 감춤).
회귀: 테스트 142/142, 편집 스모크 210건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 13:52:54 +09:00
co-authored by Claude Opus 5
parent ddf2bdaee8
commit de01012b7e
9 changed files with 432 additions and 22 deletions
+142
View File
@@ -615,6 +615,148 @@ public static class DbSmoke
}
}
/// <summary>
/// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용).
///
/// 카탈로그는 수백 종인데 목록이 가나다순이면 자주 쓰는 태그가 어디 있는지 알 수 없다.
/// 상위 몇 개가 전체의 몇 %를 덮는지 알아야 '자주 쓰는 것 먼저' 가 의미 있는지 판단할 수 있다.
/// 타입별로도 나눈다 — 이미지에 붙는 태그와 텍스트박스에 붙는 태그는 겹치지 않는다.
/// </summary>
public static int RunTagUsageReport(string reportPath)
{
var lines = new List<string>();
try
{
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList();
var byKey = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
// 태그 → 이 태그를 쓰는 컨트롤 타입 분포
var byType = new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
// 한 서식이 쓰는 서로 다른 태그 수 — 피커를 몇 번 여는지의 대리 지표
var perSheet = new List<int>();
foreach (var sheet in sheets)
{
var sheetTags = new HashSet<string>(StringComparer.Ordinal);
try
{
var raw = store.LoadActiveDesignRaw(sheet.ShtCod);
if (raw is null)
{
continue;
}
void Visit(Core.Models.ControlElement element)
{
foreach (var key in new[] { "DataInterfaceTag", "DataActionTag" })
{
var value = element.Props.GetText(key);
// "None" 은 액션 없음의 기본값이라 사용으로 치지 않는다
if (value is null || value.Length == 0 || value == "None")
{
continue;
}
if (!byKey.TryGetValue(key, out var bucket))
{
bucket = new Dictionary<string, int>(StringComparer.Ordinal);
byKey[key] = bucket;
}
bucket[value] = bucket.GetValueOrDefault(value) + 1;
sheetTags.Add(key + "/" + value);
var typeKey = key + "|" + element.Type;
if (!byType.TryGetValue(typeKey, out var types))
{
types = new Dictionary<string, int>(StringComparer.Ordinal);
byType[typeKey] = types;
}
types[value] = types.GetValueOrDefault(value) + 1;
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var page in serializer.Read(raw.Value.Xml).Pages)
{
Visit(page.Root);
}
if (sheetTags.Count > 0)
{
perSheet.Add(sheetTags.Count);
}
}
catch
{
// 통계용
}
}
foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal))
{
var total = bucket.Values.Sum();
var ranked = bucket.OrderByDescending(p => p.Value).ToList();
lines.Add(string.Empty);
lines.Add($"[{key}] 사용 {total}건 / 서로 다른 값 {bucket.Count}종");
foreach (var n in new[] { 5, 10, 20, 30, 50 })
{
var covered = ranked.Take(n).Sum(p => p.Value);
lines.Add($" 상위 {n,2}종이 전체의 {100.0 * covered / Math.Max(1, total),5:F1}% 를 덮는다");
}
lines.Add(" ── 상위 30 ──");
var rank = 0;
foreach (var (value, count) in ranked.Take(30))
{
rank++;
lines.Add($" {rank,2}. {value,-34} {count,6}건 ({100.0 * count / total,4:F1}%)");
}
// 카탈로그에 없는 값 = 사이트가 추가했거나 오타
var catalog = key == "DataInterfaceTag"
? (IReadOnlyList<string>)Core.Catalog.LegacyTagCatalog.DataInterfaceTags
: Core.Catalog.LegacyTagCatalog.DataActionTags;
var unknown = ranked.Where(p => !catalog.Contains(p.Key)).ToList();
lines.Add($" 카탈로그에 없는 값: {unknown.Count}종 / {unknown.Sum(p => p.Value)}건");
lines.AddRange(unknown.Take(10).Select(p => $" {p.Key} ×{p.Value}"));
}
if (perSheet.Count > 0)
{
perSheet.Sort();
lines.Add(string.Empty);
lines.Add($"태그를 쓰는 서식 {perSheet.Count}건 — 한 서식이 쓰는 서로 다른 태그 수: " +
$"중앙값 {perSheet[perSheet.Count / 2]}, 최대 {perSheet[^1]}, 평균 {perSheet.Average():F1}");
}
lines.Add(string.Empty);
lines.Add("[컨트롤 타입별 상위 태그] — 타입을 알면 후보를 좁힐 수 있는지 확인용");
foreach (var (typeKey, values) in byType
.Where(p => p.Value.Values.Sum() >= 20)
.OrderByDescending(p => p.Value.Values.Sum()))
{
var ranked = values.OrderByDescending(p => p.Value).ToList();
var sum = values.Values.Sum();
var top16 = ranked.Take(16).Sum(p => p.Value);
lines.Add(string.Empty);
lines.Add($" {typeKey} {sum}건 / {ranked.Count}종 — 상위 16종이 {100.0 * top16 / sum:F1}%");
foreach (var (value, count) in ranked.Take(16))
{
lines.Add($" {value,-34} {count,5}");
}
}
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
lines.Add("실패: " + ex);
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 1;
}
}
/// <summary>
/// 3단계 판단용 실측(--db-phase3) — 목록·정렬 관련 컬럼이 실제로 채워져 있는지(읽기 전용).
///