
이런 경험 있으신가요?

OPC-UA 서버에 연결해서 노드를 읽으려고 이렇게 했습니다.
node = client.get_node("ns=4;s=PLC.DM[0]")
value = await node.read_value()
자체 제작한 테스트 서버에서는 잘 됐는데, 실제 PLC OPC-UA 서버에 연결하니까 이런 오류가 납니다.
asyncua.ua.uatypes.BadNodeIdUnknown
"ns=4;s=PLC.DM[0]이 없다"는 건데, 분명히 OPC-UA 클라이언트(UaExpert 같은 툴)로 보면 노드가 있습니다.
왜 없다고 할까요?
또 다른 경우도 있습니다.
노드는 찾았는데 값을 쓰려고 하면 이런 오류가 납니다.
asyncua.ua.uaerrors.BadTypeMismatch
PLCLink Phase 4에서 OPC-UA 프로토콜을 추가하면서 두 문제를 모두 만났습니다.
용어 먼저 짚고 넘어갈게요

OPC-UA란
산업 현장에서 여러 브랜드의 PLC와 MES 시스템을 연결하는 표준 통신 프로토콜입니다.
각 제조사마다 다른 독점 프로토콜 대신 OPC-UA를 쓰면 브랜드 상관없이 통신할 수 있어요.
다국어 통역사처럼, OPC-UA 서버가 있으면 클라이언트는 어떤 PLC든 같은 방식으로 데이터를 읽을 수 있습니다.
노드 (Node)란
OPC-UA에서 데이터를 담는 단위입니다.
PLC의 DM0 레지스터 하나가 노드 하나에 대응합니다.
노드들이 트리 구조로 연결되어 있어요.
파일 시스템의 폴더와 파일처럼, 노드에 경로로 접근할 수 있습니다.
NodeId란
각 노드를 고유하게 식별하는 주소입니다.
ns=4;i=3 같은 숫자 형식이나 ns=4;s=PLC.DM[0] 같은 문자열 형식이 있어요.
문제는 이 NodeId가 서버마다, 심지어 같은 서버의 재시작마다 달라질 수 있다는 겁니다.
browse path란
NodeId 대신 트리 구조의 이름을 따라가는 방법입니다.
파일 시스템에서 C:\Users\Gooing\Documents\파일.txt처럼 경로로 찾는 것과 같아요.
NodeId가 바뀌어도 이름(경로)이 같으면 찾을 수 있습니다.
VariantType이란
OPC-UA에서 값의 데이터 타입입니다.
정수인지, 실수인지, 불리언인지를 명시합니다.
쓰기를 할 때 이 타입을 맞춰야 하는데, 틀리면 BadTypeMismatch 오류가 납니다.
왜 NodeId 직접 지정이 문제가 되나

asyncua로 서버를 직접 만들어서 테스트하면 노드가 이렇게 만들어집니다.
# asyncua 서버에서 노드 생성
node = await server.nodes.objects.add_variable(
ua.NodeId(3, 2), # ns=2, numeric ID=3
"DM[0]",
ua.Variant(0, ua.VariantType.Int16)
)
이때 노드의 NodeId는 ns=2;i=3처럼 숫자 형식이 됩니다.
서버를 다시 만들면 ID가 달라져요.
자동 생성된 NodeId로 접근하면:
# 이건 내가 만든 테스트 서버에서만 동작
node = client.get_node("ns=2;i=3")
실제 PLC OPC-UA 서버에서는 완전히 다른 NodeId를 씁니다.
Siemens S7-1500이라면 ns=3;s="DB1"."DM[0]" 같은 형식일 수도 있고, 구조가 전혀 다를 수 있어요.
문자열 NodeId 템플릿(ns=4;s=PLC.DM[0])을 미리 만들어서 쓰는 방법도 있지만, 이것도 실제 서버의 구조와 맞아야만 합니다.
서버마다 네임스페이스 번호(ns)와 노드 이름 규칙이 다르기 때문에 범용적으로 쓸 수 없어요.
우편함 비유로 설명하면 이렇습니다.
NodeId는 "302호 우편함"처럼 번호로 찾는 방식이에요.
그런데 건물마다 우편함 번호 체계가 다릅니다.
A 건물에서 302호가 나의 우편함이었다고 B 건물에서 302호도 나의 것이 아닌 것처럼, NodeId는 서버마다 다릅니다.
해결 : browse path를 먼저 시도한다
browse path는 이름으로 찾는 방식입니다.
"Objects 폴더 안의 PLC 폴더 안의 DM 폴더 안의 DM[0]"처럼 경로를 따라갑니다.
NodeId가 달라도 이름이 같으면 찾을 수 있어요.
async def get_node_by_browse_path(client, ns, device, address):
root = client.get_root_node()
try:
# browse path 우선 시도
node = await root.get_child([
"0:Objects", # 최상위 Objects 폴더
f"{ns}:PLC", # PLC 그룹 노드
f"{ns}:{device}", # 디바이스 (DM, CIO 등)
f"{ns}:{device}[{address}]", # 개별 노드
])
return node
except Exception:
# browse path 실패 시 NodeId 직접 접근으로 폴백
node_id = f"ns={ns};s=PLC.{device}[{address}]"
return client.get_node(node_id)
PLCLink에서는 browse path를 먼저 시도하고, 실패하면 NodeId 직접 접근으로 폴백합니다.
이렇게 하면 browse path가 통하는 서버(대부분의 경우)에서는 안정적으로 동작하고, 안 되는 경우에도 NodeId 폴백이 있습니다.
OPC-UA 서버 구조를 먼저 파악해야 한다

browse path가 통하려면 서버의 트리 구조를 알아야 합니다.
실제 PLC OPC-UA 서버에 처음 연결할 때는 UaExpert 같은 OPC-UA 클라이언트 툴로 트리 구조를 먼저 확인하는 것을 권장합니다.
서버 트리 예시 (가상):
Objects
└─ PLC (ns=2)
├─ DM (ns=2)
│ ├─ DM[0] → ns=2;i=1001
│ ├─ DM[1] → ns=2;i=1002
│ └─ ...
└─ CIO (ns=2)
├─ CIO[0] → ns=2;i=2001
└─ ...
browse path의 각 단계 이름(PLC, DM, DM[0])이 실제 서버 트리의 BrowseName과 일치해야 합니다.
서버마다 이 이름이 다를 수 있어요.
PLCLink에서 asyncua로 자체 OPC-UA 서버를 만들 때는 browse path가 일치하도록 노드 이름을 명시적으로 설정했습니다.
# 서버 노드 생성 시 BrowseName 명시
dm_folder = await plc_node.add_folder(
ua.NodeId(f"DM", ns), # NodeId
ua.QualifiedName("DM", ns) # BrowseName = browse path에 사용되는 이름
)
두 번째 문제 : 쓰기 시 BadTypeMismatch

노드를 찾는 데 성공했는데 값을 쓰려고 하면 이런 오류가 납니다.
asyncua.ua.uaerrors.BadTypeMismatch
OPC-UA 노드는 자신이 받을 수 있는 데이터 타입이 고정되어 있습니다.
노드가 Int16으로 만들어졌는데 Python int(기본값이 Int64)를 보내면 타입이 맞지 않아서 거부합니다.
해결 방법은 쓰기 전에 현재 노드의 값을 먼저 읽어서 VariantType을 확인하는 것입니다.
async def write_node(node, value):
# 현재 값을 읽어서 타입 확인
existing = await node.read_data_value()
vtype = existing.Value.VariantType
# 같은 타입으로 변환해서 씀
typed_value = type(existing.Value.Value)(value) # 기존 타입으로 캐스팅
await node.write_value(
ua.DataValue(ua.Variant(typed_value, vtype))
)
existing.Value.VariantType으로 현재 타입을 확인하고, 그 타입으로 변환해서 쓰는 방식입니다.
조금 더 견고하게 만들려면:
async def write_node_safe(node, value):
try:
existing = await node.read_data_value()
vtype = existing.Value.VariantType
existing_val = existing.Value.Value
# 타입 캐스팅 (예외 처리 포함)
if isinstance(existing_val, bool):
typed_value = bool(value)
elif isinstance(existing_val, int):
typed_value = int(value)
elif isinstance(existing_val, float):
typed_value = float(value)
else:
typed_value = value
await node.write_value(
ua.DataValue(ua.Variant(typed_value, vtype))
)
except Exception as e:
raise ValueError(f"OPC-UA 쓰기 실패: {e}")
왜 먼저 읽어야 하나

"굳이 먼저 읽어야 하나?"라는 생각이 들 수 있습니다.
그냥 타입을 Int16으로 고정하면 안 될까요?
안 됩니다.
같은 OPC-UA 서버라도 노드마다 타입이 다를 수 있어요.
DM0은 Int16이고, DM1은 UInt32이고, DM2는 Float일 수도 있습니다.
PLCLink가 범용적으로 어떤 서버든 연결해야 하기 때문에 타입을 하드코딩할 수 없어요.
미리 읽어서 타입을 확인하는 것은 약간의 지연이 생기지만, 타입 불일치 오류 없이 안정적으로 쓰기가 됩니다.
쓰기 작업은 읽기에 비해 자주 일어나지 않기 때문에 이 정도 오버헤드는 감수할 만 합니다.
정리
| 문제 | 원인 | 해결 |
|---|---|---|
| BadNodeIdUnknown | NodeId가 서버마다 달라서 | browse path로 이름 기반 탐색 |
| browse path도 안 됨 | 서버 트리 구조가 다름 | NodeId 직접 접근으로 폴백 |
| BadTypeMismatch | 노드 타입과 쓰려는 값 타입 불일치 | 쓰기 전에 기존 값 읽어서 VariantType 확인 |
마치며
OPC-UA는 표준 프로토콜이라 "한 번 만들면 어디서든 된다"고 생각하기 쉽습니다.
실제로는 서버마다 노드 구조, NodeId 생성 방식, 네임스페이스 번호가 달라요.
browse path 방식이 이 문제를 완전히 해결하지는 않습니다.
서버마다 BrowseName 규칙이 다르면 browse path도 맞지 않을 수 있어요.
완전한 해결은 연결할 서버의 트리 구조를 먼저 파악하고, 그에 맞게 경로를 설정하는 것입니다.
PLCLink에서는 browse path 우선 + NodeId 폴백 + VariantType 보존이라는 세 가지 방어 코드로 OPC-UA 서버와의 호환성을 최대한 높였습니다.
'(개인Project)_개발 > PLC-PC 연결' 카테고리의 다른 글
| [Program][보족] PyInstaller EXE가 콘솔 없이 에러 메시지도 없이 꺼진다 : 초기화 로그로 디버깅 (0) | 2026.06.11 |
|---|---|
| [Program][Phase5] "Python 설치해야 해요?" 라는 질문이 없어지는 날 : PLCLink 데스크탑 앱 제작기 (1) | 2026.06.10 |
| [Program][보족] Python으로 PLC 프로토콜을 직접 짜야 할 때 : FINS 스펙 읽는 방법 (0) | 2026.06.07 |
| [Program][보족] pymodbus 버전별 API 변경 정리 : 3.6 이하에서 3.13으로 마이그레이션 (0) | 2026.06.06 |
| [Program][Phase4] 프로토콜 팩토리 패턴으로 PLC 통신 코드를 어떻게 정리했나 (0) | 2026.06.05 |