π English Abstract
This article addresses critical performance bottlenecks encountered when deploying Python's asyncua library for large-scale industrial OPC UA data acquisition systems. We explore architectural strategies to resolve event loop latency caused by high-frequency subscriptions and eliminate Garbage Collection (GC) overhead through memory reuse pipeline patterns. Practical code examples and real-world troubleshooting steps for edge cases such as network reconnections and burst traffic are also provided.
μ€λ§νΈ ν©ν 리 λ° μ°μ μ© μ¬λ¬ΌμΈν°λ·(IIoT, Industrial Internet of Things) κ΅¬μΆ νμ₯μμ OPC UA(Open Platform Communications Unified Architecture)λ νλ‘ν μ½ μνΈμ΄μ©μ±μ 보μ₯νλ ν΅μ¬ νμ€μΌλ‘ μ리 μ‘μμ΅λλ€. κ·Έλ¬λ μλ§ κ°μ μ°μ μ© νκ·Έ(Tag)μ μΌμ λ°μ΄ν°λ₯Ό μ΄λΉ μμ ν μ΄μμ κ³ λΉλλ‘ μμ§ν λ, Pythonμ λΉλκΈ° λΌμ΄λΈλ¬λ¦¬μΈ asyncua νκ²½μμλ μμμΉ λͺ»ν ν΅μ μ§μ°(Latency) λ° λ©λͺ¨λ¦¬ κ°λΉμ§ 컬λ μ (GC, Garbage Collection) κ³ΌλΆν λ¬Έμ κ° λΉλ²ν λ°μν©λλ€.
λ³Έ κΈμμλ λκ·λͺ¨ μ°μ λ°μ΄ν° μμ§ νμ΄νλΌμΈμμ λ°μνλ λΉλκΈ° ꡬλ ν΅μ μ§μ°μ κ·Όλ³Έ μμΈμ λΆμνκ³ , κ°μ²΄ μ¬μ¬μ©(Object Pooling) λ° μ λ‘ μλ‘μΌμ΄μ (Zero-Allocation) κ°λ μ λμ νμ¬ μμ€ν μμ μ±κ³Ό μ²λ¦¬ μλλ₯Ό κ·Ήλνν μ€λ¬΄ μ΅μ ν κ°μ΄λλ₯Ό μ 곡ν©λλ€.
1. λκ·λͺ¨ OPC UA λ°μ΄ν° μμ§ νκ²½μ μ£Όμ λ³λͺ© μμΈ λΆμ
Pythonμ asyncio κΈ°λ°μΌλ‘ μμ±λ asyncua λΌμ΄λΈλ¬λ¦¬λ λΉλκΈ° I/Oλ₯Ό μ§μνλ―λ‘ λμμ± μ²λ¦¬μ λ°μ΄λ μ±λ₯μ 보μ
λλ€. νμ§λ§ μ΄λΉ λ°μ΄ν° λ³κ²½ μ΄λ²€νΈ(DataChangeNotification)κ° μμ²~μλ§ κ±΄ μ΄μ λ°μνλ νκ²½μμλ λ€μκ³Ό κ°μ νλμ¨μ΄ λ° μννΈμ¨μ΄μ λ³λͺ©μ΄ λνλ©λλ€.
1.1 μ΄λ²€νΈ 루ν κ΅μ°© λ° μ§μ° (Event Loop Starvation)
λΉλκΈ° 루ν λ΄μμ OPC UA μλ²λ‘λΆν° μ λ¬λ λ Έλ λ°μ΄ν°(DataValue)λ₯Ό νμ±νκ±°λ JSON μ§λ ¬ν, DB μ μ₯, λ©μμ§ ν(Kafka, RabbitMQ) μ μ‘ λ±μ μμ μ΄ λκΈ°μ (Synchronous)μΌλ‘ μ€νλλ©΄ μ΄λ²€νΈ 루ν(Event Loop)μ μ°¨λ¨(Blocking)μ΄ λ°μν©λλ€. μ΄λ‘ μΈν΄ μμΌ μμ λ²νΌμ λ°μ΄ν° μ²λ¦¬κ° λ¦μ΄μ§λ©° λ€νΈμν¬ μμ μ§μ°μ΄ λμ λ©λλ€.
1.2 λ°μ΄ν° κ°μ²΄ μμ°μ λ°λ₯Έ GC(Garbage Collection) κ³ΌλΆν
λ§€ μ΄λ²€νΈλ§λ€ μμ² κ°μ DataValue λ° Variant κ°μ²΄κ° λμ μΌλ‘ μμ±λκ³ νκ΄΄λ©λλ€. Pythonμ μΈλλ³ κ°λΉμ§ 컬λ ν°(Generational GC)λ μλͺ
λλ 0μΈλ/1μΈλ κ°μ²΄λ₯Ό μ 리ν λλ§λ€ μκ°μ μΌλ‘ μ€ν μ€μΈ μ€λ λλ₯Ό λ©μΆλ **'Stop-the-World'** νμμ μ λ°νλ©°, μ΄λ κ°λ³μ μΈ ν΅μ μ§μ° μ€νμ΄ν¬(Latency Spike)μ ν΅μ¬ μμΈμ΄ λ©λλ€.
1.3 ꡬλ νλΌλ―Έν° λ―Έμ€λ§€μΉ (Publishing & Sampling Interval Mismatch)
OPC UA μλ²μ ν΄λΌμ΄μΈνΈ κ°μ μνλ§ κ°κ²©(Sampling Interval) λ° λ°ν κ°κ²©(Publishing Interval) μ€μ μ΄ μ΅μ νλμ§ μμΌλ©΄ μλ² μΈ‘μ νμ λ°μ΄ν°κ° μμ¬ ν μ€λ²νλ‘μ°(Queue Overflow)κ° λ°μνκ±°λ, λμΌ λ°μ΄ν°μ λΆνμν μ¬μ μ‘μΌλ‘ λ€νΈμν¬ λμν(Bandwidth)μ λλΉνκ² λ©λλ€.
2. κ³ μ±λ₯ μμ§μ μν λ©λͺ¨λ¦¬ μ¬μ¬μ© νμ΄νλΌμΈ λ° μν€ν μ² μ΅μ ν
μ΄λ¬ν μ§μ°κ³Ό μ±λ₯ μ νλ₯Ό ν΄κ²°νκΈ° μν΄μλ λ°μ΄ν° μμ§ λ μ΄μ΄μ λ°μ΄ν° μ²λ¦¬ λ μ΄μ΄λ₯Ό μλ²½ν λΆλ¦¬νκ³ , λ©λͺ¨λ¦¬ ν λΉμ μ΅μννλ νμ΄νλΌμΈ κΈ°λ²μ΄ νμν©λλ€.
2.1 μμ°μ-μλΉμ ν¨ν΄(Producer-Consumer Pattern)μ ν΅ν λΉλκΈ° 격리
OPC UAμ DataChangeHandler μμ λΆλ μ€μ§ λ°μ΄ν°λ₯Ό λλμ λ©λͺ¨λ¦¬ ν(Lock-Free Queue λλ asyncio.Queue)μ λ°μ΄ λ£λ μν (Producer)λ§ μννλ©°, λ¬΄κ±°μ΄ μ§λ ¬ν λ° μΈλΆ μ μ‘ μμ
μ λ³λμ λΉλκΈ° μ컀(Consumer)λ λ©ν°νλ‘μΈμ€(Multiprocessing)μμ μ²λ¦¬νλλ‘ κ΅¬μ‘°νν©λλ€.
2.2 κ°μ²΄ νλ§(Object Pooling) λ° λ°μ΄ν° λ²νΌ μ¬μ¬μ©
μμ λ μκ³μ΄ λ°μ΄ν°λ₯Ό μ μ₯ν λμ λ리(Dictionary)λ λ°μ΄ν° ν΄λμ€(DataClass) κ°μ²΄λ₯Ό λ§€λ² μλ‘ μμ±νμ§ μκ³ , **κ°μ²΄ ν(Object Pool)** λλ μ¬μ ν λΉλ **λ§ λ²νΌ(Ring Buffer)**μ μ¬μ¬μ©ν©λλ€. μ΄λ₯Ό ν΅ν΄ λ©λͺ¨λ¦¬ ν λΉ νμλ₯Ό κ±°μ μ λ‘(Zero)μ κ°κΉκ² μ μ§ν μ μμ΅λλ€.
2.3 λΉ λ₯Έ μ§λ ¬ν μμ§ λμ
νμ΄μ¬ νμ€ λΌμ΄λΈλ¬λ¦¬μΈ json λͺ¨λ λμ C κΈ°λ°μΌλ‘ ꡬνλ orjsonμ΄λ ujson λΌμ΄λΈλ¬λ¦¬λ₯Ό μ μ©νμ¬ νμ± λ° μ§λ ¬ν μμ μκ°μ 3~5λ°° μ΄μ λ¨μΆμν΅λλ€.
3. μ€λ¬΄ μ΅μ ν μ½λ ꡬν μμ
μλ μμ μ½λλ asyncua κΈ°λ°μμ asyncio.Queueμ 미리 ν λΉλ **κ°μ²΄ μ¬μ¬μ© λ²νΌ(Pre-allocated Buffer)**λ₯Ό μ°λνμ¬ μ§μ° μκ°μ μ΅μννλ κ°λ ꡬ쑰λ₯Ό 보μ¬μ€λλ€.
import asyncio
import logging
from typing import List, Dict, Any
from asyncua import Client, Node, ua
import orjson
# λ‘κΉ
μ€μ
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("OPCUA_Collector")
# 1. λ©λͺ¨λ¦¬ μ¬μ¬μ©μ μν κ°μ²΄ ν λ° λ²νΌ ν΄λμ€
class DataPacketPool:
def __init__(self, size: int = 10000):
self.pool: List[Dict[str, Any]] = [
{"node_id": "", "val": None, "ts": None, "status": 0}
for _ in range(size)
]
self.free_indices: List[int] = list(range(size))
def acquire(self, node_id: str, val: Any, ts: str, status: int) -> Dict[str, Any]:
if not self.free_indices:
# νμ΄ κ½ μ°Όμ κ²½μ° μμ λμ ν λΉ (Edge Case λμ)
return {"node_id": node_id, "val": val, "ts": ts, "status": status}
idx = self.free_indices.pop()
obj = self.pool[idx]
obj["node_id"] = node_id
obj["val"] = val
obj["ts"] = ts
obj["status"] = status
obj["_pool_idx"] = idx
return obj
def release(self, obj: Dict[str, Any]):
idx = obj.get("_pool_idx")
if idx is not None and idx < len(self.pool):
self.free_indices.append(idx)
# μ μ κ°μ²΄ ν μμ±
data_pool = DataPacketPool(size=50000)
processing_queue: asyncio.Queue = asyncio.Queue(maxsize=100000)
# 2. κ³ μ±λ₯ ꡬλ
νΈλ€λ¬ ν΄λμ€
class FastSubscriptionHandler:
"""
μ΄λ²€νΈ 루ν μ°¨λ¨μ λ§κΈ° μν΄ μ΅μνμ λμλ§ μννλ Handler
"""
def datachange_notification(self, node: Node, val: Any, data: ua.DataChangeNotification):
try:
node_id_str = node.nodeid.to_string()
source_timestamp = data.monitored_item.Value.SourceTimestamp.isoformat() if data.monitored_item.Value.SourceTimestamp else ""
status_code = data.monitored_item.Value.StatusCode.value
# κ°μ²΄ νμμ λ©λͺ¨λ¦¬ νλ (μ κ· ν λΉ μμ)
packet = data_pool.acquire(node_id_str, val, source_timestamp, status_code)
# λΉλκΈ° νμ μ¦μ μ°¨λ¨ μμ΄ λ°μ΄ν° ν¬μ
try:
processing_queue.put_nowait(packet)
except asyncio.QueueFull:
logger.error("Queue Overflow! Data dropped.")
data_pool.release(packet)
except Exception as e:
logger.error(f"Error in datachange_notification: {e}")
# 3. λΉλκΈ° μ컀 (Consumer) - λ©λͺ¨λ¦¬ μ¬μ¬μ© λ° λΉ λ₯Έ μ§λ ¬ν μ²λ¦¬
async def data_processing_worker(worker_id: int):
logger.info(f"Worker-{worker_id} started.")
while True:
packet = await processing_queue.get()
try:
# orjsonμ μ΄μ©ν κ³ μ μ§λ ¬ν
json_bytes = orjson.dumps(packet)
# (μ€μ νκ²½μμλ Kafka μ μ‘, DB μ μ₯ λ±μ I/O μν)
# await kafka_producer.send_bytes(json_bytes)
except Exception as e:
logger.error(f"Worker-{worker_id} Error: {e}")
finally:
# μ²λ¦¬ μλ£ ν λ©λͺ¨λ¦¬ κ°μ²΄ λ°λ©
data_pool.release(packet)
processing_queue.task_done()
# 4. ν΄λΌμ΄μΈνΈ λ©μΈ 루ν΄
async def main():
target_url = "opc.tcp://127.0.0.1:4840"
client = Client(url=target_url)
# μΈμ
μ΅μ
μ΅μ ν
client.session_timeout = 30000
async with client:
logger.info("Connected to OPC UA Server")
# μ컀 νμ€ν¬ μμ±
workers = [asyncio.create_task(data_processing_worker(i)) for i in range(4)]
handler = FastSubscriptionHandler()
# ꡬλ
νλΌλ―Έν° μ΅μ ν (Publishing Interval: 50ms)
subscription = await client.create_subscription(50, handler)
# μν λͺ¨λν°λ§ λ
Έλ λ±λ‘ (μ€μ νκ²½μ λ§κ² μ‘°μ )
nodes_to_monitor = [client.get_node(f"ns=2;s=Device1.Sensor_{i}") for i in range(100)]
await subscription.subscribe_data_change(nodes_to_monitor)
logger.info("Subscription initialized successfully.")
# λ©μΈ 루ν μ μ§
await asyncio.sleep(3600)
# μ’
λ£ μ²λ¦¬
for worker in workers:
worker.cancel()
if __name__ == "__main__":
asyncio.run(main())
4. νμ μ£μ§ μΌμ΄μ€(Edge Cases) λμ λ° νΈλ¬λΈμν Guide
λκ·λͺ¨ μ°μ νμ₯ νκ²½μμλ λ¨μ ν΅μ μμ§ μΈμλ μμΈμ μΈ λ€νΈμν¬ λ¬Έμ μ κΈκ²©ν λ°μ΄ν° λ³λμ± λ¬Έμ μ μ§λ©΄νκ² λ©λλ€.
4.1 λ€νΈμν¬ μ¬μ°κ²°(KeepAlive) λ° κ΅¬λ μ¬κ΅¬μ± (Auto-Reconnection)
νμ₯μ L2/L3 μ€μμΉ λ¨μ μ΄λ OPC UA μλ² μ¬λΆν μ ν΄λΌμ΄μΈνΈ μΈμ μ΄ μ μ€λ©λλ€. λ¨μ μ¬μ°κ²° 루νλ§ μ¬μ©νλ©΄ ꡬλ (Subscription) μ λ³΄κ° μμ€λμ΄ λ°μ΄ν° μμ§μ΄ μꡬ μ€λ¨λ μ μμ΅λλ€.
- ν΄κ²°μ±
:
asyncuaμclient.connect()μ€ν¨ μ λ°±μ€ν(Exponential Backoff) μκ³ λ¦¬μ¦μ μ μ©νκ³ , μ°κ²° μ¬μ°½μΆ μcreate_subscription()λ°subscribe_data_change()λ₯Ό μ¬νΈμΆνμ¬ κ΅¬λ μνλ₯Ό μ¬λ±λ‘(Re-subscription)νλ νΈλ€λ¬ λ‘μ§μ λ°λμ ν¬ν¨ν΄μΌ ν©λλ€.
4.2 λ²μ€νΈ νΈλν½(Burst Traffic)κ³Ό Deadband μ€μ
곡μ λΌμΈ κ°λ μκ° μλ§ κ°μ λ°μ΄ν° νκ·Έμ κ°μ΄ λμμ λ³κ²½λμ΄ μμ ν΅μ λ§μ΄ νμ£Όν μ μμ΅λλ€.
- ν΄κ²°μ±
: λ―ΈμΈν λ
Έμ΄μ¦λ‘ μΈν λΆνμν μ΄λ²€νΈ λ°©μ§λ₯Ό μν΄ OPC UA μλ² νΉμ ν΄λΌμ΄μΈνΈ ꡬλ
λ 벨μμ μ λ λ°λλ°΄λ(Absolute Deadband) λλ λ°±λΆμ¨ λ°λλ°΄λ(Percent Deadband) νν°λ₯Ό μ μ©ν©λλ€. λ
Έλ λͺ¨λν°λ§ μ
ua.DataChangeFilterνλͺ©μ ꡬμ±νμ¬ μλ―Έ μλ μμΉ λ³νλ§ μμ νλλ‘ μ μ΄ν©λλ€.
4.3 GC νλ λ° PyPy / Cython μ μ© κ³ λ €
νμ΄μ¬μ GC μ€λ λ μ§μ°μ 극볡νκΈ° μν΄ gc.disable()μ μ μΈνκ³ , λ°°μΉ μμ
μλ£ λ¨κ³λ₯Ό κ±°μ³ μ£ΌκΈ°μ μΌλ‘ gc.collect(generation=2)λ₯Ό μλ νΈμΆνλ κΈ°λ²μ μ¬μ©ν μ μμ΅λλ€. λ³΄λ€ κ·Ήλ¨μ μΈ μ±λ₯ νλ³΄κ° νμνλ€λ©΄ JIT μ»΄νμΌλ¬ κΈ°λ°μ PyPy μ€ν νκ²½ λμ
μ΄λ ν΅μ¬ λΉλκΈ° μμ λ μ΄μ΄λ₯Ό CythonμΌλ‘ λΉλνλ λ°©λ²λ κΆμ₯λ©λλ€.
5. κ²°λ‘ λ° μμ½
Pythonμ asyncua κΈ°λ°μΌλ‘ μ΄κ³ μ, λκ·λͺ¨ μ°μ
μ© OPC UA λ°μ΄ν° μμ§ μμ€ν
μ μμ μ μΌλ‘ ꡬλνκΈ° μν΄μλ λΉλκΈ° μ΄λ²€νΈ 루νμ λΉμ°¨λ¨(Non-blocking) μ€κ³μ λ©λͺ¨λ¦¬ κ°μ²΄ μ¬μ¬μ©(Object Pooling)μ΄ νμμ μ
λλ€.
- μ΄λ²€νΈ 루ν λΆλ¦¬: μμ (Handler)κ³Ό μ²λ¦¬(Worker) κ³Όμ μ
asyncio.Queueλ‘ λΆλ¦¬νμ¬ λ€νΈμν¬ μμ μ§μ°μ λ°©μ§ν©λλ€. - λ©λͺ¨λ¦¬ μ¬μ¬μ©: κ°μ²΄ νλ§ κΈ°λ²μ μ μ©ν΄ κ°λΉμ§ 컬λ ν°(GC)μ μν 'Stop-the-World' μ§μ°μ κ·Όλ³Έμ μΌλ‘ λ¨μΆν©λλ€.
- μμΈ μ²λ¦¬ κ°ν: μλ μ¬μ°κ²° λ° κ΅¬λ μ¬κ΅¬μ±, λ°λλ°΄λ(Deadband) νν°λ§μ ν΅ν΄ νμ₯μμ λ°μ κ°λ₯ν λ€μν μ£μ§ μΌμ΄μ€λ₯Ό μμ νκ² λ°©μ΄ν©λλ€.
μ΄λ¬ν μ΅μ ν νμ΄νλΌμΈμ ꡬμΆνλ©΄ λ¨μΌ Python νλ‘μΈμ€ νκ²½μμλ ν΅μ μ§μ°μκ°(Latency)μ μλΈ λ°λ¦¬μ΄(Sub-millisecond) λ¨μλ‘ μ μ§νλ©΄μ μλ§ κ±΄μ μ€λ§νΈ ν©ν 리 μΌμ λ°μ΄ν°λ₯Ό μ μ€ μμ΄ μμ μ μΌλ‘ μμ§ν μ μμ΅λλ€.