주말 사이에 패키지 충전기 펌웨어가 업데이트됐다. 월요일 아침, 라인 개요 화면의 태그 마흔 개쯤이 품질 Bad에 Bad_NodeIdUnknown을 물고 있었고 같은 포인트의 트렌드 펜은 평평하게 끊겼다. PLC는 멀쩡했다. OPC UA 서버도 살아 있고 browse도 됐다. 바뀐 건 하나였다. 그 벤더 서버는 numeric NodeId를 빌드 순서대로 부여하는데, 업데이트가 모델 상단에 변수 두 개를 추가하면서 그 아래 전부가 2씩 밀렸다.
HMI에는 ns=4;i=6021 같은 값이 저장돼 있었다. 그 숫자가 더 이상 예전 노드를 가리키지 않았다.
특이한 사고가 아니다. import 시점에 어떤 식별자를 저장할지 잘못 고른 결과일 뿐이고, 대부분 그 결정은 태그 브라우저 마법사 안에서 4초 만에 얼떨결에 내려진다.
이름 네 개, 그리고 IEC 62541-3이 실제로 보장하는 것
OPC UA 규격 Part 3(IEC 62541-3, Address Space Model)은 모든 Node에 헷갈리기 쉬운 속성 네 개를 정의한다.
| 속성 | 타입 | 규격이 보장하는 범위 |
|---|---|---|
| NodeId | NodeId (numeric / string / GUID / opaque) | 한 서버 안에서 유일. 재시작이나 rebuild 후 유지된다는 말은 없다. |
| BrowseName | QualifiedName (nsIndex + name) | 지역화되지 않음. 같은 부모 아래 형제끼리 유일한 것은 관례이고 강제는 아니다. |
| DisplayName | LocalizedText | 보장 없음. 라벨이다. locale이 붙고, 바뀌라고 있는 값이다. |
| Description | LocalizedText | 보장 없음. 문서용 문구. |
NodeId 행을 한 번 더 읽는 게 좋다. 여기서 대부분이 틀어진다. 규격은 유일하다고 했지 안정적이라고 하지 않았다. 서버 재시작이나 프로젝트 rebuild를 넘어 유지되는지는 서버 구현의 성질이고, 벤더마다 완전히 다르다. PLC 심볼명에서 string NodeId를 만드는 서버와 SQLite row id로 노드에 번호를 매기는 게이트웨이는 전혀 다른 물건이다.
DisplayName은 키가 될 수 없다
이건 단호하게 말하겠다. 되돌리는 비용이 제일 크기 때문이다. DisplayName은 LocalizedText다. 클라이언트가 ko-KR locale을 요청하고 서버에 한국어 번역이 있으면 같은 노드에서 다른 문자열이 돌아온다. 여기에 정체성을 걸면, import한 엔지니어의 클라이언트가 어떤 locale을 요청했느냐에 정체성이 좌우된다.
그러니 히스토리언 포인트 식별, 알람 소스 식별, MES 설비 매핑, HMI 태그 import 매칭에 DisplayName을 쓰지 않는다. 화면과 리포트에 쓴다. 원래 그러라고 있는 값이다.
설계 검토 때 쓰는 테스트는 하나다. 서버에서 "Pump 1 Run"을 "P-101 Running"으로 바꾸고 다시 로드한다. 기술 매핑이 깨지거나 히스토리언이 새 포인트를 만들면 설계가 잘못된 것이고, 2년치 트렌드가 반으로 쪼개진 다음이 아니라 지금 고쳐야 한다.
NodeId 안정성은 기대하지 말고 증명한다
NodeId를 저장하기로 했다면 — 그럴 만한 이유는 충분하다. 가장 빠른 참조이고, Read/Write/Subscribe가 실제로 받는 유일한 형태다 — 하루 반나절을 들여 정말 유지되는지 확인한다. 잡아내는 빈도 순으로 네 가지다.
- Rebuild 후 diff. PLC나 게이트웨이 프로젝트에 더미 변수 하나를 추가하고 rebuild한 뒤 주소 공간을 다시 export해서 이전 export와 비교한다. 앞의 충전기 사고를 잡아낼 수 있었던 검사가 이거다. 무관한 NodeId가 밀렸다면 numeric NodeId는 후보에서 제외한다.
- 서버 재시작. 메모리 기반 서버 중에는 시작할 때마다 번호를 다시 매기는 것도 있다.
- 두 번째 런타임에 배포. 같은 프로젝트를 예비 게이트웨이에 import한다. 두 장비의 NodeId가 다르면 검증한 프로젝트를 개발에서 운영으로 올릴 때 태그를 다시 import해야 한다는 뜻이다.
- 벤더에게 직접 확인. NodeId가 계약된 인터페이스인지 내부 구현값인지 문서로 받는다. "지금까지 바뀐 적 없습니다"는 답이 아니다.
대략적인 경향은 이렇다. 심볼명에서 파생된 string NodeId는 rebuild를 넘어 살아남는다. 빌드 순서로 부여되는 numeric NodeId는 대개 못 살아남는다. GUID는 서버가 노드 테이블을 영속화하느냐에 전적으로 달려 있어서 반반이다.
namespace index 말고 URI를 저장한다
싸게 저지르는 실수이고, 오래된 프로젝트에서도 여전히 보인다.
ns=3;i=1207은 그 자체로는 아무 의미가 없다. 3은 서버 NamespaceArray의 인덱스다. Server 객체에 있는 문자열 배열이고 NodeId는 i=2255다. 이 배열은 서버가 기동할 때 만들어지고, 벤더가 namespace를 추가하거나 companion spec 모델을 활성화하거나, 단순히 개발 서버와 운영 서버의 구성 순서가 달랐다는 이유만으로 순서가 바뀐다.
태그 옆에 URI(http://vendor.com/PackagedLine/)를 같이 저장한다. 접속 시 i=2255를 읽어 URI를 찾고, 그때 나온 인덱스를 쓴다. 쓸 만한 클라이언트는 알아서 해주지만 확인은 해야 한다. 싸구려 드라이버 중에 인덱스를 그대로 저장했다가 조용히 다른 namespace의 노드를 읽는 물건이 적지 않다.
이 글에서 하나만 가져간다면 이걸 가져가는 게 좋다. 시트에 컬럼 하나 추가하는 비용으로 "값이 틀렸는데 틀린 티가 안 나는" 사고 유형 전체를 막는다.
Browse path: rebuild에는 강하고 구조 변경에는 약하다
NodeId 저장의 대안은 경로를 저장하는 것이다. Area1/Line2/MotorA/Speed를 저장해 두고 접속 시 TranslateBrowsePathsToNodeIds(Part 4, Services)로 푼다. 트레이드오프는 깔끔하다. 경로는 NodeId 재생성에 강하고, NodeId는 계층 구조 변경에 강하다. 우리 서버가 어느 쪽을 더 자주 하는지로 고른다.
경로는 이럴 때 깨진다.
- 프로젝트 정리 중에 설비가 다른 폴더로 이동한다.
- 서로 다른 부모 아래 같은 BrowseName이 있는데 import가 부모 정보를 버린다.
- 벤더가 flat tag 모델에서 object-variable 모델로 넘어간다. 모든 경로에 레벨이 하나 늘어난다.
- 클라이언트가 시작 시에만 경로를 풀고 경로별
Bad_NoMatch를 삼킨다.
마지막 항목이 진짜 위험하다. TranslateBrowsePathsToNodeIds는 경로마다 결과를 돌려주기 때문에 부분 실패가 정상 동작이고 그냥 넘기기 쉽다. "연결됨"만 찍고 태그 아홉 개를 못 푼 채 놔두는 클라이언트가 시끄럽게 실패하는 클라이언트보다 나쁘다. 인수 전에 일부러 실패시켜 본다. 서버에서 노드 하나 이름을 바꾸고 HMI가 실제로 알려주는지 확인한다.
사이에 별칭 계층을 둔다
화면, 알람, 스크립트는 현장 태그명 — Line01_P101_RunFb — 을 참조하고, OPC UA 매핑은 프로젝트에서 정확히 한 곳에만 있어야 한다. 과한 설계가 아니다. 펌웨어 업데이트 한 번의 비용이 import 시트 하나냐, NodeId를 하드코딩한 모든 화면·알람·스크립트를 뒤지는 작업이냐의 차이다.
아래 중 하나라도 터지면 본전을 뽑는데, 최소 하나는 항상 터진다.
- 벤더가 업데이트로 모델을 바꾼다.
- 소스 매핑은 옮기면서 히스토리언 포인트 정체성은 유지해야 한다.
- 이중 언어 HMI에서 하나의 기술 신호에 운전자 표시 문구만 다르게 필요하다.
- 개발 서버와 운영 서버의 namespace index가 다르게 잡힌다.
별칭은 지루하게 유지한다. OPC UA 주소를 별칭 이름에 밀어 넣지 않는다. ns4_i6021_Speed 같은 이름을 붙이면 취약함을 grep하기 더 어려운 곳으로 옮겼을 뿐이다.
import 시트
마법사가 주는 것보다 컬럼이 많아야 하고, 마지막 항목이 보기보다 중요하다.
SCADA 내부 태그명 · 설비/구역 · 데이터 타입과 단위 · endpoint URL · namespace URI · NodeId 타입과 값(쓰는 경우) · browse path(쓰는 경우) · leaf BrowseName · DisplayName · read/write 방향 · 알람 또는 히스토리언 사용 여부 · 마지막 검증일.
검증일이 있어야 다음 엔지니어가 이 시트를 문서로 볼지 유물로 볼지 판단할 수 있다.
보자마자 알아봐야 할 고장 두 가지
브라우저에서는 값이 맞는데 HMI에서는 비어 있다. 브라우저는 현재 주소 공간을 즉석에서 풀고 있고, 런타임은 import 당시 저장한 NodeId를 쓰고 있다. 지금 브라우저에 보이는 것 말고 드라이버 태그에 실제로 저장된 값을 확인한다. 서로 다른 데이터이고, 브라우저는 자기 딴엔 맞는 값을 보여주면서 사람을 속인다.
표시 이름 정리 후 히스토리언 포인트가 중복된다. 누가 DisplayName을 정리했고, 히스토리언은 라벨로 정체성을 잡고 있었고, 결과적으로 Pump 1 Run은 화요일에 끝나고 P-101 Running이 수요일에 시작한다. 나중에 두 계열을 병합하는 건 아무도 예산을 잡아두지 않는 수작업이다. 라벨 변경은 안정적인 포인트 키에 대한 메타데이터 수정이어야 한다.
인수 서명 전에
설비 타입별로 샘플 태그를 하나씩 골라, 서버 재시작 후에도 매핑이 그대로인지, DisplayName을 고쳐도 기술적으로 아무것도 안 바뀌는지, 저장된 참조가 시트와 일치하는지, 클라이언트의 namespace URI가 기대한 모델로 해석되는지 확인한다.
그리고 다들 건너뛰는 테스트를 한다. 서버에서 노드 하나 이름을 바꾸고 HMI가 그걸 보고하는지 본다. 실패하는 걸 한 번도 못 본 매핑은 실패 양상을 모르는 매핑이다.