# 진단 보고서 — 신호태그 .PV 누락으로 인한 history_table 분절 **진단일**: 2026-06-08 **발생일**: 2026-06-03 (commit 7409fab 컬럼명칭 통일) 이후 신규 데이터부터 **영향**: `history_table` 1억 7,900만 행 중 5,180만 행 (19GB) 이력 분절 **등급**: 🔴 HIGH — 데이터 무결성 결함 --- ## STEP 1 — 맥락 파악 **질문: 이 문제는 무엇을 하는 시스템의 문제인가?** HC900 프로세스 컨트롤러 → Modbus TCP → C++ 게이트웨이 → gRPC → C# 크롤러 → PostgreSQL ``` HC900 ──Modbus TCP──▶ C++ Gateway ──gRPC──▶ C# Hc900Crawler ──▶ PostgreSQL (hc900.history_table) ``` 세 가지 Python 스크립트가 태그명 생성의 근원: | 스크립트 | 역할 | 파일 | |---|---|---| | `build_register_map.py` | HC Designer CSV → register-map.json | `scripts/build_register_map.py` | | `load_map_master.py` | register-map.json → `hc900_map_master` 테이블 | `scripts/load_map_master.py` | | `Hc900RealtimeService.cs` | 게이트웨이 폴링 → `realtime_table` → `history_table` | `src/Infrastructure/Hc900/Hc900RealtimeService.cs` | > 이 단계를 건너뛰면 "의도적 설계"를 "버그"로 오인한다. --- ## STEP 2 — 구조 탐색 **의존 파일 및 데이터 흐름:** ``` docs/SignalTags.csv ← HC Designer export (원본) docs/SummaryFucntionBlockReport.csv ← 루프 정의 (원본) docs/Variables.csv ← 변수 정의 (원본) │ ▼ build_register_map.py docs/register-map.json ← 게이트웨이 레지스터 맵 │ ▼ load_map_master.py PostgreSQL hc900_map_master ← DB 매핑 테이블 │ ▼ Hc900RealtimeService (C#) PostgreSQL realtime_table → history_table ``` **핵심 발견:** `build_register_map.py`는 루프와 신호태그를 **서로 다른 로직**으로 처리한다. --- ## STEP 3 — 코드 읽기 ### `build_register_map.py` — 루프 처리부 (line 153-168) ```python # line 159: 루프는 Experion point name을 재구성 tag_name = f"{loop['tag']}.{name}" # 결과: "FICQ-6101.PV", "FICQ-6101.SP", "FICQ-6101.OP" ← Experion ItemName과 일치 ✓ ``` `LOOP_PARAM_OFFSETS`에서 `.PV`, `.SP`, `.OP` 등을 붙여 Experion 시스템의 point name 형식을 재구성한다. ### `build_register_map.py` — 신호태그 처리부 (line 171-180) ```python # line 171-180: 신호태그는 CSV raw tag를 그대로 사용 for sig in signals: registers.append({ "tag": sig["tag"], ← 문제: CSV row[2] 그대로, .PV suffix 없음 "addr": sig["addr"], ... }) # 결과: "TI-6111B" ← Experion의 "TI-6111B.PV"와 불일치 ✗ ``` **근거:** `parse_signal_or_variable_csv()` (line 102-140)에서 `tag = row[2].strip()` — HC Designer CSV의 3번째 컬럼을 그대로 가져온다. HC Designer export에는 `.PV` suffix가 붙어있지 않다. ### `load_map_master.py` — DB 로딩 (line 59-65) ```python # line 64: tagname == hc900_tag == e["tag"] cur.execute( "INSERT INTO hc900_map_master (tagname, hc900_tag, ...) VALUES (%s, %s, ...)", (e["tag"], e["tag"], ...)) ``` register-map의 `tag` 필드를 `tagname`과 `hc900_tag`에 **동일하게** 저장. register-map tag가 `.PV` 붙으면 자동으로 따라옴. 수정 불필요. ### `Hc900RealtimeService.cs` — 데이터 기록 (line 203, 227-231) ```csharp // line 203: 게이트웨이 응답을 tagLookup으로 매칭 if (tagLookup.TryGetValue(tv.TagName, out var tagnames)) { foreach (var tagname in tagnames) { rows.Add((tagname, livevalue, ts)); // ← tagname이 DB에 기록됨 } } // line 227-231: realtime_table INSERT INSERT INTO realtime_table (controller_id, tagname, ..., livevalue, timestamp) VALUES ($1, $2, ..., $3, $4) ON CONFLICT (controller_id, tagname) DO UPDATE ... ``` 전구간 tag name 변형 없음. register-map tag가 그대로 `realtime_table` → `history_table`에 기록된다. --- ## STEP 4 — 호출 계층 지도 ``` HC Designer CSV (SignalTags.csv) │ row[2] = "TI-6111B" ← .PV 없음 ▼ build_register_map.py:131 tag = row[2].strip() │ line 159 (루프): f"{loop['tag']}.{name}" → "FICQ-6101.PV" ✓ │ line 171 (신호): sig["tag"] → "TI-6111B" ✗ ▼ register-map.json: "tag": "TI-6111B" ▼ load_map_master.py:64 tagname == hc900_tag == "TI-6111B" ▼ C++ gateway (gateway.cpp:80, 169, 189) ← tag field 그대로 전달, 변형 없음 ▼ C# Hc900RealtimeService (line 203, 227) ← tagname 그대로 realtime_table INSERT ▼ history_table (SnapshotToHistoryAsync) ← realtime_table에서 복사 ``` **결론:** tag name 결정은 `build_register_map.py` line 171에서 한 번에 발생. 이후 전구간 무변형 전달. --- ## STEP 5 — 패턴 매칭 (체크리스트 순회) ### 🔴 런타임 즉시 실패 | 체크 | 항목 | 판단 | |------|------|------| | [x] | **데이터 불일치** | Experion dump 원본(`TI-6111B.PV`) vs 우리 추출(`TI-6111B`) | | [ ] | 미정의 변수·함수 참조 | 해당 없음 | | [ ] | 잘못된 타입 | 해당 없음 | ### 🟠 데이터 무결성 | 체크 | 항목 | 판단 | |------|------|------| | [x] | **이력 분절** | 같은 물리 신호가 두 이름으로 `history_table`에 분산 저장 | | [x] | **과거 데이터 권위** | Experion dump(`.PV` 포함)가 현장 권위 원본 | | [ ] | Race Condition | 해당 없음 | ### 🟡 코드 구조 | 체크 | 항목 | 판단 | |------|------|------| | [x] | **처리 로직 불일치** | 루프는 Experion point name 재구성, 신호태그는 raw 사용 | | [ ] | 설정 하드코딩 | 해당 없음 | --- ## STEP 6 — 교차 검증 STEP 5에서 발견한 각 항목에 대해 4개 질문 모두 확인: ### 항목 1: 신호태그 .PV 누락 (HIGH) | 질문 | 확인 | 결과 | |------|------|------| | Q1. 이미 수정된 문제인가? | `build_register_map.py` 현재 상태 grep | **아니오** — line 171 여전히 `sig["tag"]` | | Q2. 다른 레이어에서 처리되고 있는가? | C++ gateway, C# RealtimeService grep | **아니오** — 전구간 무변형 전달 | | Q3. 의도적 설계인가? | 문서·주석 확인 | **아니오** — 루프는 `.PV` 붙이는데 신호는 안 붙이는 이유 없음 | | Q4. 실제 장애 시나리오가 있는가? | `history_table`에서 `TI-6111B.PV` vs `TI-6111B` 분산 | **예** — 5,180만 행 분절, 시계열 조회 시 끊김 | **→ Q1~Q4 통과. 보고서 포함.** ### 항목 2: 이력 분절 (HIGH) | 질문 | 확인 | 결과 | |------|------|------| | Q1. 이미 수정된 문제인가? | `history_table` 행 수 확인 | **아니오** — 5,180만 행 여전히 옛 이름으로 존재 | | Q2. 다른 레이어에서 처리되고 있는가? | rename 훅, 데이터 이관 로직 grep | **아니오** — tagname 변경 시 이관 코드 없음 | | Q3. 의도적 설계인가? | 아키텍처 문서 확인 | **아니오** — 단일 신호가 두 이름으로 저장되는 것은 의도 아님 | | Q4. 실제 장애 시나리오가 있는가? | 시계열 조회 시 두 이름 모두 검색 필요 | **예** — 운영상 실수 유발 | **→ Q1~Q4 통과. 보고서 포함.** --- ## STEP 7 — 심각도 분류 | 등급 | 항목 | 기준 | |------|------|------| | 🔴 HIGH | 신호태그 .PV 누락 | 데이터 무결성 결함. Experion dump 원본과 불일치. 재현 가능 | | 🔴 HIGH | history_table 이력 분절 | 5,180만 행 분산. 시계열 조회 시 끊김. 데이터 손실과 동등 | --- ## STEP 8 — 진단 결과 및 수정 계획 ### [1]. 신호태그 .PV 누락 (HIGH) **문제**: `build_register_map.py`가 신호태그(CSV row[2])를 Experion point name 규칙(`TAGNAME.PV`) 없이 그대로 register-map에 기록. 루프는 `.PV` 등을 붙이는데 신호태그만 누락. **근거**: `scripts/build_register_map.py:171` — `"tag": sig["tag"]` (CSV raw tag, `.PV` suffix 없음) **영향**: Experion dump 원본(`TI-6111B.PV`)과 불일치 → `history_table`에 같은 물리 신호가 두 이름으로 분산 저장. 5,180만 행 분절. **수정**: `build_register_map.py` line 171 부근에서 신호태그에 `.PV` suffix 추가. ```python # 수정 전 (line 171-180): for sig in signals: registers.append({ "tag": sig["tag"], # "TI-6111B" ... }) # 수정 후: for sig in signals: # Experion point naming convention: TI-6111B → TI-6111B.PV experion_tag = sig["tag"] + ".PV" registers.append({ "tag": experion_tag, # "TI-6111B.PV" ... }) ``` **변수태그 확인 필요**: `Variables.csv`의 tag name이 Experion에서 어떻게 매핑되는지 확인. 변수는 R/W 태그로 HC900 내부 변수이므로 `.PV` 규칙이 적용되는지 별도 검증 필요. --- ### [2]. history_table 이력 분절 (HIGH) **문제**: tagname 변경 시 과거 데이터 이관 훅이 없음. 6/3 이후 신규 데이터가 옛 이름(`.PV` 없음)으로 쌓이면서 같은 물리 신호가 두 이름으로 분절. **근거**: `Hc900RealtimeService.cs` — `realtime_table` → `history_table` 복사 시 tagname 변환 로직 없음. `Hc900DbContext.cs` SnapshotToHistoryAsync도 tagname 그대로 복사. **영향**: 시계열 조회 시 두 이름 모두 검색 필요. 운영상 실수 유발. **수정**: 단계별 진행 필요 (아래 STEP 9~11 참조). --- ## STEP 9 — 수정 실행 계획 (완료 서명) ### 단계 1: `build_register_map.py` 수정 - [ ] 신호태그 처리부(line 171)에 `.PV` suffix 추가 - [ ] 변수태그 처리부(line 183)도 Experion point name 규칙 적용 여부 확인 - [ ] 수정 후 register-map 재생성 테스트 **수정자**: ____________________ **작성일**: ____/____/____ **검토자**: ____________________ **검토일**: ____/____/____ --- ### 단계 2: register-map 재생성 및 DB 반영 - [ ] 수정된 `build_register_map.py`로 register-map 재생성 - [ ] `load_map_master.py`로 `hc900_map_master` 갱신 - [ ] `hc900_map_master`에서 신호태그 tagname에 `.PV`가 붙었는지 확인 ```sql -- 확인 쿼리 SELECT tagname, hc900_tag FROM hc900_map_master WHERE tagname ~ '^[TFDLI]P?[A-Z]?-\d{4,5}$' AND tagname NOT LIKE '%.%' LIMIT 20; -- 결과가 없어야 정상 (모든 신호태그에 .PV 붙음) ``` **실행자**: ____________________ **실행일**: ____/____/____ **검토자**: ____________________ **검토일**: ____/____/____ --- ### 단계 3: history_table 과거 데이터 정합화 - [ ] `history_table`에서 옛 이름(`.PV` 없음) 행 수 확인 ```sql -- 옛 이름 잔존 행 수 확인 SELECT COUNT(*) FROM hc900.history_table WHERE tagname ~ '^[TFDLI]P?[A-Z]?-\d{4,5}$' AND tagname NOT LIKE '%.%' AND tagname NOT IN ( -- 변수태그 등 .PV 없이 정상인 태그 제외 SELECT tagname FROM hc900.map_master WHERE ... ); ``` - [ ] 옛 이름 → 새 이름(.PV 붙임) UPDATE 실행 - [ ] UPDATE 후 행 수 확인 (분절 없이 단일 이름으로 통합) ```sql -- UPDATE 예시 (본격 실행 전 반드시 트랜잭션으로 테스트) BEGIN; UPDATE hc900.history_table SET tagname = tagname || '.PV' WHERE tagname = 'TI-6111B'; -- 확인: SELECT COUNT(*) WHERE tagname = 'TI-6111B.PV' COMMIT; ``` **실행자**: ____________________ **실행일**: ____/____/____ **검토자**: ____________________ **검토일**: ____/____/____ --- ### 단계 4: 재발 방지 설계 - [ ] `hc900_map_master.tagname` 변경 시 `history_table` 이관을 강제하는 절차 문서화 - [ ] 또는: 장기 저장 식별자를 tagname이 아닌 안정 키(Modbus addr / hc900_tag)로 변경 검토 - [ ] display name은 read 시점에 `map_master`로 해석하는 아키텍처로 전환 검토 **설계자**: ____________________ **작성일**: ____/____/____ **검토자**: ____________________ **검토일**: ____/____/____ --- ## 부록: 데이터 흐름 전체 도식 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ HC Designer CSV Export (현장 dump) │ │ SignalTags.csv: row[2] = "TI-6111B" ← .PV 없음 │ │ SummaryFucntionBlockReport.csv: row[2] = "FICQ-6101" │ └──────────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ build_register_map.py │ │ │ │ 루프 (line 159): │ │ tag_name = f"{loop['tag']}.{name}" │ │ → "FICQ-6101.PV" ✓ Experion ItemName과 일치 │ │ │ │ 신호태그 (line 171) ← 문제: │ │ tag = sig["tag"] ← CSV row[2] 그대로 │ │ → "TI-6111B" ✗ Experion의 "TI-6111B.PV"와 불일치 │ │ │ │ 변수 (line 183): │ │ tag = var["tag"] ← CSV row[2] 그대로 │ │ → "VAR-xxxx" (Experion 매핑 확인 필요) │ └──────────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ register-map.json │ │ {"tag": "TI-6111B"} ← .PV 없음 │ └──────────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ load_map_master.py (line 64) │ │ tagname == hc900_tag == e["tag"] │ │ → hc900_map_master: tagname="TI-6111B", hc900_tag="TI-6111B" │ │ (register-map tag를 그대로 복사. register-map 수정하면 자동 따라옴) │ └──────────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ C++ Gateway (gateway.cpp) │ │ line 80: e.tag = item["tag"] ← register-map tag 그대로 저장 │ │ line 169: fresh[entry.tag] = cv ← cache key로 사용 │ │ line 189: tv->set_tag_name(name) ← gRPC 응답에 그대로 포함 │ │ (변형 없음, register-map tag가 100% tag name이 됨) │ └──────────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ C# Hc900RealtimeService │ │ line 203: tagLookup.TryGetValue(tv.TagName, ...) ← 매칭 │ │ line 227: INSERT INTO realtime_table (tagname, ...) │ │ → tagname = map_master의 tagname = register-map의 tag │ │ (변형 없음) │ └──────────────────────────┬──────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ PostgreSQL hc900 │ │ │ │ realtime_table: tagname="TI-6111B" ← 신규 데이터 │ │ history_table: tagname="TI-6111B.PV" ← Experion dump 원본 (5,180만 행) │ │ : tagname="TI-6111B" ← 신규 데이터 (분절) │ │ │ │ → 같은 물리 신호가 두 이름으로 분산 │ └─────────────────────────────────────────────────────────────────────┘ ``` ## 부록: Experion Point Name 규칙 | 태그 종류 | HC Designer CSV tag | Experion point name | register-map tag | 상태 | |---|---|---|---|---| | 루프 (PID) | `FICQ-6101` | `FICQ-6101.PV` | `FICQ-6101.PV` | ✓ 일치 | | 신호태그 | `TI-6111B` | `TI-6111B.PV` | `TI-6111B` | ✗ 불일치 | | 신호태그 | `FI-6102` | `FI-6102.PV` | `FI-6102` | ✗ 불일치 | | 변수 | `VAR-xxxx` | 확인 필요 | `VAR-xxxx` | ? 미확인 | **Experion 규칙**: 모든 신호태그(Tag Type = TI/FI/LI/PI/DI/AI/AO 등)는 `.PV` suffix를 붙여 point name을 생성한다.