루트 파일 정리: - DXF/P&ID 관련 → dxf-graph/ - fastTable 관련 → fastTable/ - plan/ → plans/ 통합 (최신 버전 유지) - 테스트 출력 파일, 구버전 프로젝트 삭제 - 불필요한 루트 문서 삭제
25 KiB
포인트빌더 개선 방안 - 코딩 2
1. 문제 정의
현재 "테이블 작성하기" 버튼은 조건에 맞는 포인트를 즉시 realtime_table에 TRUNCATE + INSERT 합니다.
문제:
- 빌드 결과가 의도와 다른지 확인할 수 없음
- 잘못된 조건으로 빌드 시 기존 liveValue 데이터 손실
- 521,958개 node_map_master 레코드 중 실수로 전체 선택 시 대규모 데이터 삽입 가능
목표: 빌드 전에 결과 미리보기 → 개별 체크 → 원하는 것만 선택하여 적용
2. 효율적 UI 구성 방안
2.1 왜 모달(팝업)이 아닌 인라인 확장인가?
| 고려사항 | 모달 | 인라인 확장 |
|---|---|---|
| 포인트 수 | 수백~수천 개 | 동일 |
| 스크롤 | 모달 내부 스크롤 + 페이지 스크롤 충돌 | 자연스러운 페이지 흐름 |
| 체크 상태 유지 | 모달 닫으면 초기화 | 유지 가능 |
| 테이블 비교 | 기존 테이블과 별도 창 | 바로 아래에 표시, 비교 용이 |
| 모바일 | 화면 절반 가림 | 자연스럽게 스크롤 |
결정: 인라인 확장 — "테이블 작성하기" 옆에 "미리보기" 버튼 추가, 결과를 기존 포인트 목록 위에 인라인으로 표시.
2.2 UI 플로우
┌─────────────────────────────────────────────────────────────────────┐
│ 조건으로 테이블 작성 │
│ ┌─ 컨트롤러 포인트 #1 ──────────────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌─ 아날로그 모니터링 포인트 #2 ────────────────────────────────────┐ │
│ │ ... (조건 입력) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ (나머지 그룹...) │
│ │
│ [🔍 미리보기] [🔨 테이블 작성하기] [📋 테이블 조회] │
│ │
│ ▼ (미리보기 클릭 후 인라인 표시) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 미리보기 결과 (127개 포인트) │ │
│ │ [전체 선택] [전체 해제] [역전] 선택: 127/127 │ │
│ │ ┌──┬ ID ┬ TagName ┬ NodeType ┬ DataType┐ │ │
│ │ │☑ │ 1 │ FICQ-2113.PV │ pv │ Double │ │ │
│ │ │☑ │ 2 │ FICQ-2113.OP │ op │ Double │ │ │
│ │ │☐ │ 3 │ FICQ-2113.SP │ sp │ Double │ │ │
│ │ │☑ │ 4 │ TIC-2101.PV │ pv │ Double │ │ │
│ │ │ │ ... │ ... │ ... │ ... │ │ │
│ │ └──┴─────┴──────────────────┴──────────┴─────────┘ │ │
│ │ [취소] [선택된 126개 적용하기] │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
2.3 핵심 UX 결정
- 미리보기는 READ-ONLY: DB를 변경하지 않음. node_map_master에서 조건에 맞는 레코드만 조회
- 기본 전체 체크: 미리보기 시 모든 포인트가 체크된 상태로 표시 (대부분의 경우 전체 적용)
- 그룹별 색상 라벨: 어떤 그룹 조건에서 매칭되었는지 표시
- 검색/필터: 미리보기 테이블에 태그명 검색 입력창 제공 (수천 개 중 찾기)
- "테이블 작성하기"는 유지: 기존처럼 조건 → 즉시 빌드 (미리보기 없이 빠른 빌드)
3. 아키텍처 변경
3.1 변경 파일
| 파일 | 변경 내용 |
|---|---|
src/Web/wwwroot/index.html |
미리보기 버튼 + 인라인 미리보기 영역 추가 |
src/Web/wwwroot/js/app.js |
pbPreview(), pbCancelPreview(), pbApplySelected(), pbRenderPreview() 추가 |
src/Web/wwwroot/css/style.css |
.pb-preview 스타일 추가 |
src/Core/Application/DTOs/ExperionDtos.cs |
PointBuilderPreviewResultDto 추가 |
src/Core/Application/Interfaces/IExperionServices.cs |
PreviewRealtimeBuildAsync() 인터페이스 추가 |
src/Infrastructure/Database/ExperionDbContext.cs |
PreviewRealtimeBuildAsync() 구현 |
src/Web/Controllers/ExperionControllers.cs |
POST /api/pointbuilder/preview, POST /api/pointbuilder/apply 추가 |
3.2 API 엔드포인트
GET /api/pointbuilder/points — 기존 유지 (realtime_table 조회)
POST /api/pointbuilder/build — 기존 유지 (즉시 빌드)
POST /api/pointbuilder/preview — NEW: 조건에 맞는 포인트 조회 (DB 변경 없음)
POST /api/pointbuilder/apply — NEW: 선택된 포인트만 realtime_table에 적용
POST /api/pointbuilder/add — 기존 유지 (수동 추가)
DELETE /api/pointbuilder/{id} — 기존 유지 (삭제)
3.3 데이터 흐름
┌────────────────────────────────────────────────────────────────────┐
│ [미리보기] 클릭 │
│ POST /api/pointbuilder/preview ← groups (기존 build와 동일) │
│ ↓ │
│ C#: PreviewRealtimeBuildAsync() │
│ → 각 그룹별 node_map_master 쿼리 (기존 BuildRealtimeTableAsync와 │
│ 동일한 쿼리 로직, 하지만 TRUNCATE/INSERT 없이 결과만 반환) │
│ → 중복 제거 (GroupBy nodeId) │
│ → { count, items: [{ nodeId, tagName, name, dataType, group }] } │
│ ↓ │
│ JS: pbRenderPreview() — 체크박스 테이블 렌더링 │
│ ↓ │
│ 사용자가 체크/해제 → [선택된 N개 적용하기] 클릭 │
│ ↓ │
│ POST /api/pointbuilder/apply ← { selectedNodeIds: [...] } │
│ ↓ │
│ C#: ApplySelectedPointsAsync() │
│ → TRUNCATE realtime_table │
│ → selectedNodeIds만 INSERT │
│ → pbRefresh() → 포인트 목록 갱신 │
└────────────────────────────────────────────────────────────────────┘
4. 코딩 Todo List
Step 1: C# DTO — Preview 결과 DTO 추가
변경 파일: src/Core/Application/DTOs/ExperionDtos.cs
public class PointBuilderPreviewItem
{
public string NodeId { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; // pv, op, sp, md 등
public string DataType { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty; // 어떤 그룹에서 매칭
}
public class PointBuilderPreviewResult
{
public int Count { get; set; }
public List<PointBuilderPreviewItem> Items { get; set; } = new();
}
완성 기준:
- 두 클래스 추가됨
- 빌드 컴파일 성공
Step 2: C# Interface — Preview 메서드 추가
변경 파일: src/Core/Application/Interfaces/IExperionServices.cs
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
참고: (string GroupKey, PointBuilderGroupDto Group) 튜플 사용 — Step 3에서 그룹명 태그에 필요.
완성 기준:
- 두 메서드 시그니처 추가됨
- 빌드 컴파일 성공 (구현체는 다음 Step)
Step 3: C# DB — PreviewRealtimeBuildAsync 구현
변경 파일: src/Infrastructure/Database/ExperionDbContext.cs
구현 로직:
- 기존
BuildRealtimeTableAsync와 동일한 쿼리 로직 재사용 - 각 그룹별로 쿼리 실행 → 결과에
Group속성 태그 GroupBy(NodeId)중복 제거- TRUNCATE/INSERT 없이
PointBuilderPreviewResult반환
public async Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<PointBuilderGroupDto> groups)
{
var activeGroups = groups.Where(g =>
g.TagPatterns != null && g.TagPatterns.Count > 0
).ToList();
if (activeGroups.Count == 0)
return new PointBuilderPreviewResult();
var allSources = new List<(NodeMapMaster Node, string Group)>();
foreach (var g in activeGroups)
{
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
if (patterns.Count == 0) continue;
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
var patternList = patterns;
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
if (attrs.Count > 0)
{
var attrList = attrs;
q = q.Where(x => attrList.Contains(x.Name));
}
if (!string.IsNullOrEmpty(g.DataType))
{
var dt = g.DataType;
q = q.Where(x => x.DataType == dt);
}
var sources = await q.ToListAsync();
var groupName = GetGroupName(g); // Controller1, AnalogMonitor1 등
foreach (var s in sources)
allSources.Add((s, groupName));
}
var distinct = allSources
.GroupBy(x => x.Node.NodeId)
.Select(g => g.First())
.ToList();
var items = distinct.Select(x => new PointBuilderPreviewItem
{
NodeId = x.Node.NodeId,
TagName = ExtractTagName(x.Node.NodeId),
Name = x.Node.Name,
DataType = x.Node.DataType,
Group = x.Group
}).ToList();
return new PointBuilderPreviewResult { Count = items.Count, Items = items };
}
문제: GetGroupName(g) — PointBuilderGroupDto에는 그룹명이 없음. 해결: 컨트롤러에서 그룹 키와 함께 전달하도록 변경.
수정안: 인터페이스 시그니처를 IEnumerable<(string GroupKey, PointBuilderGroupDto Group)>로 변경.
완성 기준:
- 쿼리 로직 구현됨 (기존 BuildRealtimeTableAsync와 동일)
- TRUNCATE/INSERT 없음 (READ-ONLY)
- 중복 제거 (GroupBy nodeId)
- 그룹 정보 포함
- 빌드 컴파일 성공
Step 4: C# DB — ApplySelectedPointsAsync 구현
변경 파일: src/Infrastructure/Database/ExperionDbContext.cs
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds)
{
var nodeIds = selectedNodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (nodeIds.Count == 0) return 0;
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = nodeIds.Select(nodeId => new RealtimePoint
{
TagName = ExtractTagName(nodeId),
NodeId = nodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] realtime_table 적용: {Count}건 (선택)", points.Count);
return points.Count;
}
완성 기준:
- TRUNCATE 후 선택된 nodeId만 INSERT
- 빌드 컴파일 성공
Step 5: C# Controller — preview + apply 엔드포인트 추가
변경 파일: src/Web/Controllers/ExperionControllers.cs
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] PointBuilderBuildDto dto)
{
var groups = new[]
{
("controller1", dto.Controller1),
("analogmon1", dto.AnalogMonitor1),
("digital1", dto.Digital1),
("digital2", dto.Digital2),
("custom", dto.Custom)
};
var result = await _dbSvc.PreviewRealtimeBuildAsync(groups);
return Ok(new
{
count = result.Count,
items = result.Items.Select(i => new
{
nodeId = i.NodeId,
tagName = i.TagName,
name = i.Name,
dataType = i.DataType,
group = i.Group
})
});
}
[HttpPost("apply")]
public async Task<IActionResult> Apply([FromBody] PointBuilderApplyDto dto)
{
if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });
var count = await _dbSvc.ApplySelectedPointsAsync(dto.SelectedNodeIds);
return Ok(new { success = true, count, message = $"{count}개 포인트 적용 완료" });
}
추가 DTO: ExperionDtos.cs에
public class PointBuilderApplyDto
{
public List<string> SelectedNodeIds { get; set; } = new();
}
완성 기준:
- 두 엔드포인트 추가됨
- camelCase JSON 응답
- PointBuilderApplyDto 추가됨
- 빌드 컴파일 성공
Step 6: HTML — 미리보기 버튼 + 영역 추가
변경 파일: src/Web/wwwroot/index.html
6a. 버튼 행 수정 (기존 L600-603):
<div class="btn-row">
<button class="btn-b" onclick="pbPreview()">🔍 미리보기</button>
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
<button class="btn-b" onclick="pbRefresh()">📋 테이블 조회</button>
</div>
6b. 미리보기 영역 추가 (조건으로 테이블 작성 card 내부, 버튼 행 바로 아래):
<div id="pb-preview" class="pb-preview hidden">
<div class="pb-preview-header">
<span>미리보기 결과 <span id="pb-preview-count" class="mut">(0개)</span></span>
<div class="pb-preview-actions">
<button class="btn-sm btn-b" onclick="pbPreviewSelectAll()">전체 선택</button>
<button class="btn-sm btn-b" onclick="pbPreviewDeselectAll()">전체 해제</button>
<button class="btn-sm btn-b" onclick="pbPreviewInvert()">역전</button>
<span id="pb-preview-selected" class="mut">선택: 0/0</span>
</div>
</div>
<div class="fg" style="margin-bottom:8px">
<input class="inp" id="pb-preview-search" placeholder="태그명으로 검색..." oninput="pbPreviewFilter()"/>
</div>
<div id="pb-preview-table" class="tbl-wrap" style="max-height:420px;overflow:auto"></div>
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
</div>
</div>
완성 기준:
- 미리보기 버튼 추가됨
- 미리보기 영역 HTML 추가됨 (hidden 기본)
- 전체 선택/해제/역전 버튼 포함
- 검색 입력창 포함
- 취소 + 적용 버튼 포함
- HTML 유효성 검사 통과
Step 7: CSS — 미리보기 스타일 추가
변경 파일: src/Web/wwwroot/css/style.css
.pb-preview {
background: var(--s3);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
margin-top: 10px;
}
.pb-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-weight: 600;
font-size: 13px;
}
.pb-preview-actions {
display: flex;
gap: 6px;
align-items: center;
}
.pb-preview table th:first-child,
.pb-preview table td:first-child {
width: 36px;
text-align: center;
}
.pb-preview table input[type="checkbox"] {
cursor: pointer;
width: 15px;
height: 15px;
}
.pb-preview .group-badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: var(--ag);
color: var(--a);
font-weight: 600;
}
@media (max-width: 900px) {
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
.pb-preview-actions { flex-wrap: wrap; }
}
완성 기준:
.pb-preview스타일 정의됨- 체크박스 컬럼 너비 조정
- 그룹 배지 스타일
- 반응형 대응
- 기존 스타일과 충돌 없음
Step 8: JS — 미리보기 로직 추가
변경 파일: src/Web/wwwroot/js/app.js
8a. 전역 변수 (L575 근처):
let pbPreviewData = []; // 미리보기 원본 데이터
8b. 새 함수 추가:
async function pbPreview() {
const groups = {};
for (const gk of PB_GROUPS) {
const gd = pbCollectGroupData(gk);
if (gd.tagPatterns.length > 0) {
groups[gk] = gd;
}
}
const activeKeys = Object.keys(groups);
if (activeKeys.length === 0) {
const logEl = document.getElementById('pb-build-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
return;
}
setGlobal('busy', '미리보기 조회 중');
try {
const d = await api('POST', '/api/pointbuilder/preview', groups);
pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx }));
document.getElementById('pb-preview-count').textContent = `(${d.count}개)`;
document.getElementById('pb-preview').classList.remove('hidden');
pbRenderPreview(pbPreviewData);
setGlobal('ok', `미리보기: ${d.count}개 포인트`);
} catch (e) {
setGlobal('err', '미리보기 실패');
}
}
function pbRenderPreview(data) {
const el = document.getElementById('pb-preview-table');
const filtered = pbGetFilteredPreview();
const pts = filtered.length > 0 ? filtered : data;
if (pts.length === 0) {
el.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 포인트가 없습니다.</div>';
pbUpdatePreviewCount();
return;
}
el.innerHTML = `
<table>
<thead>
<tr>
<th><input type="checkbox" onchange="pbPreviewToggleAll(this.checked)" title="전체 선택/해제"/></th>
<th>ID</th>
<th>TagName</th>
<th>NodeType</th>
<th>DataType</th>
<th>Group</th>
</tr>
</thead>
<tbody>
${pts.map((p, i) => `
<tr style="${!p.selected ? 'opacity:0.5' : ''}">
<td><input type="checkbox" ${p.selected ? 'checked' : ''} onchange="pbPreviewToggleItem(${p.idx})"/></td>
<td class="mut">${i + 1}</td>
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
<td>${esc(p?.name || '')}</td>
<td class="mut">${esc(p?.dataType || '')}</td>
<td><span class="group-badge">${esc(p?.group || '')}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
pbUpdatePreviewCount();
}
function pbPreviewToggleItem(idx) {
pbPreviewData[idx].selected = !pbPreviewData[idx].selected;
pbUpdatePreviewCount();
}
function pbPreviewToggleAll(checked) {
pbPreviewData.forEach((item, idx) => {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (searchVal) {
const filtered = pbGetFilteredPreview();
if (filtered.includes(item)) {
item.selected = checked;
}
} else {
item.selected = checked;
}
});
pbRenderPreview(pbPreviewData);
}
function pbPreviewSelectAll() {
pbPreviewData.forEach(p => p.selected = true);
pbRenderPreview(pbPreviewData);
}
function pbPreviewDeselectAll() {
pbPreviewData.forEach(p => p.selected = false);
pbRenderPreview(pbPreviewData);
}
function pbPreviewInvert() {
pbPreviewData.forEach(p => p.selected = !p.selected);
pbRenderPreview(pbPreviewData);
}
function pbGetFilteredPreview() {
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
if (!searchVal) return [];
return pbPreviewData.filter(p =>
(p.tagName || '').toLowerCase().includes(searchVal) ||
(p.nodeId || '').toLowerCase().includes(searchVal) ||
(p.name || '').toLowerCase().includes(searchVal)
);
}
function pbPreviewFilter() {
pbRenderPreview(pbPreviewData);
}
function pbUpdatePreviewCount() {
const selected = pbPreviewData.filter(p => p.selected).length;
const total = pbPreviewData.length;
document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`;
}
function pbCancelPreview() {
document.getElementById('pb-preview').classList.add('hidden');
pbPreviewData = [];
}
async function pbApplySelected() {
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
if (selected.length === 0) {
setGlobal('err', '적용할 포인트를 선택하세요.');
return;
}
setGlobal('busy', `${selected.length}개 포인트 적용 중`);
try {
const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected });
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패');
if (d.success) {
pbCancelPreview();
await pbRefresh();
}
} catch (e) {
setGlobal('err', '적용 오류');
}
}
완성 기준:
pbPreviewData전역 변수 추가됨pbPreview()함수 추가됨pbRenderPreview()함수 추가됨pbPreviewToggleItem(),pbPreviewToggleAll()추가됨pbPreviewSelectAll(),pbPreviewDeselectAll(),pbPreviewInvert()추가됨pbGetFilteredPreview(),pbPreviewFilter()검색 기능 추가됨pbUpdatePreviewCount()카운트 업데이트pbCancelPreview()취소pbApplySelected()선택된 것만 적용pbPreviewData에idx속성 부여 (indexOf 버그 방지)- 콘솔 에러 없음
Step 9: 빌드 및 검증
작업 내용:
dotnet build src/Web/ExperionCrawler.csproj- 빌드 성공 (0 error, 0 warning)
- 브라우저에서 테스트:
- 조건 입력 → 미리보기 → 결과 확인
- 체크/해제/역전 동작
- 검색 필터 동작
- 선택된 것만 적용 → 포인트 목록 갱신
- 기존 "테이블 작성하기" 버튼도 정상 동작
완성 기준:
- 빌드 성공 (0 error, 0 warning)
- 미리보기 버튼 클릭 시 결과 표시
- 전체 선택/해제/역전 정상
- 검색 필터 정상
- 선택 적용 후 포인트 목록 갱신
- 기존 빌드 버튼 영향 없음
- 기존 pbRefresh/pbDelete 영향 없음
5. 진행 상태 추적
| Step | 상태 | 비고 |
|---|---|---|
| 1. DTO | ⬜ 미완료 | PointBuilderPreviewItem, PointBuilderPreviewResult, PointBuilderApplyDto |
| 2. Interface | ⬜ 미완료 | PreviewRealtimeBuildAsync, ApplySelectedPointsAsync |
| 3. DB Preview | ⬜ 미완료 | READ-ONLY 쿼리 |
| 4. DB Apply | ⬜ 미완료 | 선택된 nodeId만 TRUNCATE + INSERT |
| 5. Controller | ⬜ 미완료 | preview + apply 엔드포인트 |
| 6. HTML | ⬜ 미완료 | 미리보기 버튼 + 영역 |
| 7. CSS | ⬜ 미완료 | .pb-preview 스타일 |
| 8. JS | ⬜ 미완료 | 미리보기 로직 (중복 pbBuild 이미 삭제 완료) |
| 9. 빌드 | ⬜ 미완료 | 검증 |
6. 부가 사항
6.1 기존 버그 수정 (이미 완료)
- 중복
pbBuild()함수 (app.js L671-686): ✅ 이미 삭제 완료 (코딩.md 반영 시) pbRender()테이블 컬럼 불일치 (app.js L660-662): ✅ 컬럼 순서 확인 완료
6.2 향후 개선 (별도 작업)
- 그룹별 색상 배지 (컨트롤러=파랑, 아날로그=초록, 디지털=주황)
- 미리보기 결과 Excel/PDF 내보내기
- 최근 미리보기 조건 저장 (localStorage)
- 페이지네이션 (1000개 이상 시)