온도프로파일/PV일관성/PointBuilder/history 작업지시, 신호태그·스팀유량 진단, 베이직아키텍처 재설계, MSDS, LLM채팅 구조 등. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 KiB
포인트빌더 페이지 구현 명세
ExperionCrawler 웹 UI의 Tab #06 — OPC UA node_map_master에서 실시간 모니터링할 포인트를 선택해
realtime_table을 구성하는 페이지
1. 개요
목적: OPC UA 서버의 노드맵(node_map_master 테이블)에서 조건(태그명 패턴, 속성, 데이터타입)으로 필터링하여 실시간 구독할 포인트를 realtime_table에 등록/관리한다.
사용자 플로우:
- 그룹별 태그 패턴 입력 → 미리보기로 대상 확인
- 원하는 포인트 선택 → 적용(전체 교체) 또는 추가(기존 유지)
- 등록된 포인트 목록 확인 / 개별 삭제
- 실시간 구독 시작/중지
- 메타데이터(desc/area) 갱신
- Sub-Area 분류 관리
2. API 엔드포인트
Base: /api/pointbuilder
2.1 POST /api/pointbuilder/preview
조건에 맞는 포인트를 미리보기 (읽기 전용, DB 변경 없음)
Request Body (PointBuilderBuildDto):
{
"controller1": { "tagPatterns": ["%ctl-61%.pv","%ctl-62%.sp"], "attributes": ["pv","sp"], "dataType": null },
"analogmon1": { "tagPatterns": ["%ti-61%.pv","%pi-62%.pv"], "attributes": ["pv"], "dataType": null },
"digital1": { "tagPatterns": ["%ys-61%.pv","%yt-62%.pv"], "attributes": ["pv"], "dataType": "Boolean" },
"digital2": { "tagPatterns": [], "attributes": [], "dataType": null },
"custom": { "tagPatterns": [], "attributes": [], "dataType": null }
}
| 필드 | 타입 | 설명 |
|---|---|---|
tagPatterns |
string[] |
SQL LIKE 패턴 (쉼표 구분 아님 — 프론트에서 분할하여 배열로 전달). node_map_master.NodeId에 대해 LIKE 검색 |
attributes |
string[] |
node_map_master.Name 필터 (pv/sp/op/md 등). 커스텀 속성 입력도 여기에 병합 |
dataType |
string? |
node_map_master.DataType 필터. null이면 전체 |
group keys: controller1, analogmon1, digital1, digital2, custom
DB 조건:
SELECT ... FROM node_map_master WHERE Level = 3
AND (NodeId LIKE <pattern1> OR NodeId LIKE <pattern2> ...)
AND Name IN (<attrs>)
AND DataType = <dataType> -- dataType이 null이면 생략
Response:
{
"count": 42,
"items": [
{ "nodeId": "ns=1;s=ti-6101.pv", "tagName": "ti-6101.pv", "name": "pv", "dataType": "Double", "group": "analogmon1" }
]
}
2.2 POST /api/pointbuilder/build
조건에 맞는 포인트로 기존 realtime_table 전체 교체 (TRUNCATE → INSERT)
- Request Body:
PointBuilderBuildDto(preview와 동일한 구조) - 동작:
TRUNCATE TABLE realtime_table RESTART IDENTITY후 조건 매칭된 포인트 INSERT - 메타데이터 자동 로드: 빌드 완료 후
IMetadataLoaderService.ReloadMetadataAsync()호출 (실패해도 Warning 로그만, 빌드는 완료)
Response:
{
"success": true,
"count": 42,
"metaCount": 40,
"message": "42개 포인트 생성 완료 (메타데이터: 40개)"
}
2.3 POST /api/pointbuilder/apply
미리보기에서 선택한 포인트만으로 realtime_table 전체 교체
- Request Body:
{ "selectedNodeIds": ["ns=1;s=ti-6101.pv", "ns=1;s=ficq-6113.pv"] } - 동작: TRUNCATE → 선택된 NodeId만 INSERT
- 메타데이터 자동 로드 포함
Response: { "success": true, "count": 42, "metaCount": 40, "message": "..." }
2.4 POST /api/pointbuilder/append
미리보기에서 선택한 포인트를 기존 데이터에 추가 (중복 제외, 기존 유지)
- Request Body:
{ "selectedNodeIds": [...] } - 동작:
NodeId기준 중복 체크 후 없는 것만 INSERT - 메타데이터 자동 로드 포함
2.5 GET /api/pointbuilder/points
등록된 모든 realtime 포인트 조회
Response:
{
"total": 42,
"items": [
{ "id": 1, "tagName": "ti-6101.pv", "nodeId": "ns=1;s=ti-6101.pv", "liveValue": "25.3", "timestamp": "2026-06-08T12:34:56Z" }
]
}
2.6 POST /api/pointbuilder/add
Node ID를 직접 입력하여 수동 포인트 추가
Request: { "nodeId": "ns=1;s=my-custom-tag.pv" }
동작:
IExperionDbService.AddRealtimePointAsync()— DB에 INSERT (중복이면 기존 반환)IExperionRealtimeService.AddMonitoredItemAsync()— 구독 중이면 OPC UA에 핫 추가 + Node ID 유효성 검증- OPC UA 서버가 거부하면 DB 롤백
Response:
{ "success": true, "point": { "id": 43, "tagName": "my-custom-tag.pv", "nodeId": "ns=1;s=my-custom-tag.pv" } }
2.7 DELETE /api/pointbuilder/{id}?purgeHistory=false
포인트 삭제
| 파라미터 | 기본값 | 설명 |
|---|---|---|
purgeHistory |
false |
true이면 해당 tagname의 history_table 이력까지 영구 삭제 (복구 불가, 명시적 opt-in) |
동작:
- realtime_table 행 삭제
- 같은 base_tag의 잔여 행이 0이면 →
tag_metadata(desc/area/sub_area) 고아 정리 - purgeHistory=true →
DELETE FROM history_table WHERE tagname = ?
Response:
{
"success": true,
"message": "삭제 완료",
"baseTag": "ti-6101",
"metadataPurged": true,
"historyRowsDeleted": 1440
}
3. 보조 API 엔드포인트 (같은 페이지에서 사용)
3.1 실시간 구독 제어
| Method | Endpoint | 설명 | Request |
|---|---|---|---|
POST |
/api/realtime/start |
구독 시작 | { serverHostName, port, clientHostName, userName, password } |
POST |
/api/realtime/stop |
구독 중지 | — |
GET |
/api/realtime/status |
상태 조회 | — |
status response: { "running": true, "subscribedCount": 42, "message": "..." }
3.2 메타데이터 관리
| Method | Endpoint | 설명 |
|---|---|---|
POST |
/api/tags/metadata/reload |
OPC UA에서 desc/area 재조회 → tag_metadata UPDATE |
GET |
/api/tags/metadata |
모든 tag_metadata 조회 |
3.3 Sub-Area 관리
| Method | Endpoint | 설명 |
|---|---|---|
GET |
/api/tags/sub-area?area=P6&page=1&pageSize=500 |
area별 sub_area 현황 조회 |
PUT |
/api/tags/sub-area |
단일 태그 sub_area 수정. Body: { baseTag, subArea } (null=미분류) |
POST |
/api/tags/sub-area/seed |
번호 prefix + pid_equipment로 일괄 분류. Body: { dryRun: bool } |
4. UI 레이아웃 (2-Column Grid + 하단 포인트 목록)
┌──────────────────────────────────────────────────────────────┐
│ [pane-hdr] 포인트빌더 — node_map_master → realtime_table 구성 │
├─────────────────────────────┬────────────────────────────────┤
│ [card: 빌더] │ [card: 수동 추가] │
│ ┌────────────────────────┐ │ ┌──────────────────────────┐ │
│ │ 카드 제목 │ │ │ Node ID 직접 입력 │ │
│ │ 조건으로 테이블 작성 │ │ │ [inp] │ │
│ │ │ │ │ [btn] + 추가 │ │
│ │ 그룹 카드 × 5개 │ │ │ [log] │ │
│ │ · 컨트롤러 #1 │ │ └──────────────────────────┘ │
│ │ · 아날로그 모니터 #2 │ │ [card: 구독 제어] │
│ │ · 디지털 #1 │ │ ┌──────────────────────────┐ │
│ │ · 디지털 #2 │ │ │ 서버IP/포트/계정/비번 │ │
│ │ · 사용자 정의 │ │ │ [btn] ▶ 구독 시작 │ │
│ │ ┌─────────────────┐ │ │ │ [btn] ■ 구독 중지 │ │
│ │ │ 태그명 패턴(inp)│ │ │ │ [btn] 상태 확인 │ │
│ │ │ [pv] [op] [sp] │ │ │ │ [log: 상태] │ │
│ │ │ [md] 체크박스 │ │ │ └──────────────────────────┘ │
│ │ │ [추가속성1][추가2]│ │ │ │
│ │ │ [데이터타입 선택] │ │ │ [card: 메타데이터 관리] │
│ │ └─────────────────┘ │ │ ┌──────────────────────────┐ │
│ │ │ │ │ [btn] 🔄 메타데이터 갱신 │ │
│ │ [btn] 미리보기 │ │ │ [btn] 📋 메타데이터 조회 │ │
│ │ [btn] 테이블 작성하기 │ │ │ [log] │ │
│ │ [btn] 테이블 조회 │ │ │ [view: metadata table] │ │
│ │ │ │ └──────────────────────────┘ │
│ │ [preview: 미리보기] │ │ │
│ │ ┌────────────────┐ │ │ [card: Sub-Area 관리] │
│ │ │ 전체선택/해제 │ │ │ ┌──────────────────────────┐ │
│ │ │ 역전 | 검색창 │ │ │ │ Area 선택 | 조회 │ │
│ │ │ ┌─── 테이블 ──┐│ │ │ │ Seed DryRun | Seed 실행 │ │
│ │ │ │ ☑ ID TagNam ││ │ │ │ [log] │ │
│ │ │ │ ☑ 1 ti-6101 ││ │ │ │ [view: subarea table] │ │
│ │ │ │ ☑ 2 pi-6102 ││ │ │ └──────────────────────────┘ │
│ │ │ └─────────────┘│ │ │ │
│ │ │ [취소][적용][추가]│ │ │ │
│ │ └────────────────┘ │ │ │
│ └────────────────────────┘ │ │
├─────────────────────────────┴────────────────────────────────┤
│ [card: 포인트 목록 (전체 width)] │
│ ┌──────────────────────────────────────────────────────────┐│
│ │ ID | TagName | LiveValue | Timestamp | 이력/삭제││
│ │ 1 | TI-6101.PV | 25.3 | 06-08 12:34 | ☐이력 ✕ ││
│ │ 2 | FICQ-6113.PV | 152.7 | 06-08 12:34 | ☐이력 ✕ ││
│ └──────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────┘
4.1 그룹 카드 (pb-group-card)
총 5개의 그룹 카드가 존재하며, 각 카드는 동일한 구조:
| 요소 | CSS 셀렉터 | 설명 |
|---|---|---|
| 헤더 | .pb-group-header > .card-sub-cap |
그룹명 (컨트롤러 포인트 #1, 아날로그 모니터링 포인트 #2, ...) |
| 태그 패턴 | input[data-group="{key}"][data-field="tagPatterns"] |
쉼표 구분 LIKE 패턴. placeholder에 예시 표시 |
| 속성 체크박스 | .pb-attr-checkboxes > label > input[type=checkbox] |
pv/op/sp/md 4개. data-group, data-field="attributes" |
| 커스텀 속성 | .pb-custom-attr-inputs > input[data-field="customAttrs"] |
추가 속성 텍스트 입력 2개 (선택) |
| 데이터타입 선택 | select[data-field="dataType"] |
Double / i=7594 / Boolean / String / Int16 / Int32 / UInt16 / UInt32 / Float / DateTime + "(전체)" |
data 속성으로 그룹 연결:
data-group:controller1/analogmon1/digital1/digital2/customdata-field: 식별
4.2 미리보기 영역 (pb-preview)
- 초기 상태:
.hidden클래스로 숨김 - "미리보기" 버튼 클릭 시 표시
- 헤더: "미리보기 결과 (N개)" + 액션 버튼들 (전체 선택 / 전체 해제 / 역전 / 선택: X/Y)
- 검색창: 태그명/NodeId/Name 실시간 필터링 (oninput)
- 테이블: ☑ 선택체크박스 | # | TagName | NodeType | DataType | Group
- 선택되지 않은 행:
opacity: 0.5 - Group 뱃지:
.group-badge(파란색 배경)
- 선택되지 않은 행:
- 하단 버튼: [취소] [✓ 선택된 포인트 적용하기] [+ 기존 데이터에 추가하기]
4.3 포인트 목록 (하단, 전체 폭)
- 빈 상태: "포인트가 없습니다. 위에서 테이블을 작성하세요." (회색)
- 테이블: ID | TagName (uppercase, bold) | LiveValue (fmtVal+parseEnumPv 적용) | Timestamp (fmtTs 적용) | 이력체크박스 / 삭제
- LiveValue가 null이면
—표시 - 삭제 버튼: 붉은색
✕, confirm dialog 표시- "이력" 체크박스 체크 시: ⚠️ 이력(history_table)까지 영구 삭제 메시지
- 미체크 시: 이력 보존 안내
- 삭제 완료 후 alert: "삭제 완료: TI-6101 (#1) (메타데이터 정리됨 · 이력 1,440행 삭제)" 등의 상세 정보
4.4 실시간 구독 제어
4개 필드 입력: server IP / port / client host / 계정 / 비밀번호 (password type)
- 2×2 CSS grid (비밀번호는
grid-column: 1/-1전체 폭) - 버튼: ▶ 구독 시작 / ■ 구독 중지 / 상태 확인
- 구독 상태 표시: logbox에 running 여부 + 구독 포인트 개수
4.5 Sub-Area 관리
- Area 선택 드롭다운: P6 / P9 / P10 / P1 / P2
- 버튼: 📋 조회 / 🧪 Seed DryRun / ⚙️ Seed 실행
- 조회 결과: 테이블 (BaseTag | Description | Sub-Area select)
- Sub-Area:
<select>에 area별 옵션(예: P6-1, P6-2, P6-1,P6-2(공용)) + 현재값 표시 - 변경 시 onchange → PUT
/api/tags/sub-area
- Sub-Area:
5. 데이터 흐름
5.1 미리보기 → 적용 플로우
[사용자] 그룹 카드에 패턴/속성 입력
↓
pbPreview() → POST /api/pointbuilder/preview
↓
{ items: [{nodeId, tagName, name, dataType, group}] }
↓
pbPreviewData 배열 생성 (각 item에 selected:true, idx 추가)
↓
pbRenderPreview() → 체크박스 있는 테이블 렌더링
↓
[사용자] 체크박스 조작 / 검색 필터 / 선택/해제
↓
pbApplySelected() / pbAppendSelected()
↓
POST /api/pointbuilder/apply (또는 /append)
body: { selectedNodeIds: [...] }
↓
DB TRUNCATE + INSERT (apply) / 중복제외 INSERT (append)
↓
pbRefresh() → 포인트 목록 재조회
↓
포인트 목록 갱신 + 실시간 상태 갱신
5.2 그룹 데이터 수집 (pbCollectGroupData)
function pbCollectGroupData(groupKey) {
const tagPatterns = document.querySelector(`input[data-group="${groupKey}"][data-field="tagPatterns"]`)
.value.split(',').map(s => s.trim()).filter(Boolean);
const checkedAttrs = Array.from(
document.querySelectorAll(`input[data-group="${groupKey}"][data-field="attributes"]:checked`)
).map(cb => cb.value);
const customInputs = document.querySelectorAll(`input[data-group="${groupKey}"][data-field="customAttrs"]`);
customInputs.forEach(inp => {
if (inp.value.trim()) checkedAttrs.push(inp.value.trim());
});
const dataType = document.querySelector(`select[data-group="${groupKey}"][data-field="dataType"]`)?.value || null;
return { tagPatterns, attributes: checkedAttrs, dataType };
}
5.3 삭제 플로우
[사용자] "이력" 체크박스 선택 여부 → confirm 대화상자
↓
pbDelete(id, tagName) → DELETE /api/pointbuilder/{id}?purgeHistory={bool}
↓
{ success, baseTag, metadataPurged, historyRowsDeleted }
↓
alert("삭제 완료: TI-6101.PV (메타데이터 정리됨 · 이력 1,440행 삭제)")
↓
pbRefresh() → 목록 갱신
6. UI 상태 관리
6.1 전역 상태 (core.js)
| 함수 | 용도 |
|---|---|
setGlobal(type, msg) |
하단 상태바 업데이트. type: 'busy'/'ok'/'err' |
esc(str) |
HTML 이스케이프 |
api(method, url, body?) |
fetch wrapper + JSON 파싱 |
fmtVal(val) |
숫자 포맷팅 |
fmtTs(iso) |
UTC ISO → KST locale 문자열 변환 |
parseEnumPv(val) |
enum 값 파싱 |
log(elId, entries) |
logbox에 메시지 추가. entries: `[{c: 'ok' |
setGlobal('busy', '포인트 빌드 중') |
빌드/적용 중 busy 표시 |
setGlobal('ok', '구독 중') |
성공 표시 |
6.2 지역 상태 (pb.js)
const PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom'];
let pbPreviewData = []; // [{ nodeId, tagName, name, dataType, group, selected, idx }]
7. CSS 구조
7.1 pb.css 전용 스타일
.pb-group-card /* 그룹 카드 배경/테두리 */
.pb-group-header /* 그룹 헤더 flex */
.pb-pattern-input /* 패턴 입력창 full width */
.pb-attr-checkboxes /* 체크박스 가로 배치 */
.pb-custom-attr-inputs /* 커스텀 속성 입력 flex */
.pb-datatype-select /* 데이터타입 선택 (max-width 260px) */
.pb-preview /* 미리보기 영역 */
.pb-preview-header /* 미리보기 헤더 */
.pb-preview-actions /* 액션 버튼 flex */
.pb-preview table th:first-child, td:first-child /* 체크박스 컬럼 36px */
.pb-preview .group-badge /* 그룹명 뱃지 */
7.2 공유 CSS 클래스 (style.css)
| 클래스 | 용도 |
|---|---|
.card |
카드 컨테이너 |
.card-cap |
섹션 제목 |
.card-sub-cap |
그룹 서브제목 |
.fg |
폼 그룹 (label + input) |
.inp |
입력 필드 |
.btn-a |
주요 버튼 (파랑) |
.btn-b |
보조 버튼 (테두리) |
.btn-sm |
작은 버튼 |
.btn-row |
버튼 행 flex |
.cols-2 |
2열 CSS grid |
.tbl-wrap |
테이블 래퍼 (overflow-x: auto) |
.logbox |
로그 출력 영역 |
.hidden |
display: none !important |
.mut |
회색 텍스트 |
.mono |
고정폭 폰트 |
8. DB 테이블 구조 (참조)
8.1 node_map_master (소스)
컬럼: Id, Level, NodeClass, Name, NodeId, DataType, DisplayName, HasChildren
PointBuilder 쿼리 조건: Level = 3 (Variable), NodeId LIKE, Name IN, DataType =
8.2 realtime_table (대상)
컬럼: Id, TagName, NodeId, LiveValue, Timestamp
TagName = node_map_master.NodeId 에서 ns=1;s= prefix 제거 후 . 포함 전체 (예: ti-6101.pv)
8.3 tag_metadata
컬럼: Id, BaseTag, Attribute, Value, LoadedAt
EAV 패턴: attribute = desc / area / sub_area / state0descriptor ~ state7descriptor
9. 중요 구현 규칙
9.1 JSON camelCase 필수
PropertyNamingPolicy = null이므로 C# DTO의 PascalCase가 그대로 JSON 키가 된다.
프론트는 모든 응답을 camelCase로 가정하므로 Controller의 Ok()는 반드시 익명 객체에 camelCase 키를 명시:
// ✅ 올바름
return Ok(new { success = true, count = x, items = ... });
// ❌ 금지 (JS가 undefined 받음)
return Ok(new { Success = true, Count = x });
return Ok(myDto); // typed DTO 그대로 반환 금지
9.2 미리보기 데이터의 selected 상태
프론트에서만 관리: pbPreviewData[i].selected (초기값 true). 서버에 저장하지 않음.
9.3 삭제 시 "이력" 체크박스 의미
- 기본값:
false(이력 보존,purgeHistory=false) - 체크 시:
purgeHistory=true→DELETE FROM history_table WHERE tagname = ?(복구 불가) confirm()대화상자에 미리 경고문 표시
9.4 메타데이터 자동 로드 실패 허용
Build/Apply/Append 후 메타데이터 로드는 실패해도 Warning 로그만 남기고 사용자에게는 빌드 성공으로 표시.
프론트 응답의 metaCount 필드로 정보 제공.
9.5 수동 추가 시 OPC UA 검증
수동 추가 → AddMonitoredItemAsync()에서 OPC UA 서버가 NodeId를 거부하면:
- DB에 먼저 INSERT
- OPC UA 검증 실패 시
DeleteRealtimePointAsync()로 롤백 - 프론트에 실패 메시지 반환
9.6 Sub-Area 토큰 매칭
공용 설비는 "P6-1,P6-2" 형식으로 저장. 매칭은 항상:
'P6-1' = ANY(string_to_array(value, ','))
직접 비교 (value = 'P6-1') 금지 — 공용 태그 누락 방지.
10. 전체 함수 목록 (pb.js)
| 함수 | 설명 |
|---|---|
pbCollectGroupData(groupKey) |
그룹 카드 데이터 수집 → {tagPatterns, attributes, dataType} |
pbBuild() |
POST /build (전체 교체) |
pbPreview() |
POST /preview + 미리보기 렌더링 |
pbRenderPreview(data) |
미리보기 테이블 렌더링 |
pbPreviewToggleItem(idx) |
개별 체크박스 토글 |
pbPreviewToggleAll(checked) |
전체 선택/해제 |
pbPreviewSelectAll() |
모두 선택 |
pbPreviewDeselectAll() |
모두 해제 |
pbPreviewInvert() |
역전 |
pbGetFilteredPreview() |
검색어로 필터링 |
pbPreviewFilter() |
oninput 핸들러 |
pbUpdatePreviewCount() |
선택 개수 업데이트 |
pbCancelPreview() |
미리보기 닫기 + 데이터 초기화 |
pbApplySelected() |
POST /apply (선택만 적용) |
pbAppendSelected() |
POST /append (선택만 추가) |
pbRefresh() |
GET /points → 목록 갱신 |
pbRender(points) |
포인트 목록 테이블 렌더링 |
pbAddManual() |
POST /add (수동 추가) |
pbDelete(id, tagName) |
DELETE (이력 체크 포함) |
rtStart() |
POST /realtime/start |
rtStop() |
POST /realtime/stop |
rtStatus() |
GET /realtime/status |
metaReload() |
POST /tags/metadata/reload |
metaView() |
GET /tags/metadata |
subAreaLoad() |
GET /tags/sub-area |
subAreaUpdate(baseTag, subArea) |
PUT /tags/sub-area |
subAreaSeed(dryRun) |
POST /tags/sub-area/seed |
subAreaLabel(code) |
sub_area 코드 → 라벨 변환 |
11. HTML/JS/CSS 파일 목록
| 파일 | 역할 |
|---|---|
src/Web/wwwroot/index.html |
shell — nav item #06 "포인트빌더", pane #pane-pb, script 로드 |
src/Web/wwwroot/panes/pb.html |
pane HTML (311 lines) |
src/Web/wwwroot/js/pb.js |
pane JS (503 lines) |
src/Web/wwwroot/css/pb.css |
pane CSS (106 lines) |
src/Web/Controllers/ExperionControllers.cs |
ExperionPointBuilderController (190 lines) |
src/Core/Application/DTOs/ExperionDtos.cs |
PointBuilderGroupDto, PointBuilderBuildDto, PointBuilderPreviewItem, PointBuilderPreviewResult, PointBuilderApplyDto, PointBuilderAddDto |
src/Core/Application/DTOs/SubAreaDtos.cs |
PointDeleteResult |
src/Infrastructure/Database/ExperionDbContext.cs |
BuildRealtimeTableAsync, PreviewRealtimeBuildAsync, ApplySelectedPointsAsync, AppendPointsAsync, GetRealtimePointsAsync, AddRealtimePointAsync, DeleteRealtimePointAsync |
src/Core/Application/Interfaces/IExperionServices.cs |
IExperionDbService (lines 82-92) |
src/Web/wwwroot/js/core.js |
공유 유틸: api, esc, setGlobal, fmtVal, fmtTs, parseEnumPv, log |
src/Web/wwwroot/css/style.css |
디자인 시스템: 카드, 폼, 버튼, 테이블, 로그박스, CSS 커스텀 속성 |