์นดํ…Œ๊ณ ๋ฆฌ ์—†์Œ

Python ๊ธฐ๋ฐ˜ ์ดˆ๋‹น 10๋งŒ ๊ฑด ์„ผ์„œ ๋ฐ์ดํ„ฐ ์‹ค์‹œ๊ฐ„ ์‹œ๊ฐํ™”: UI ์“ฐ๋ ˆ๋“œ ๋ณ‘๋ชฉ ํ•ด๊ฒฐ๊ณผ Ring Buffer ๋ฐ GPU ๊ฐ€์† ์ตœ์ ํ™”

๊ฒŒ์ž„๊ต์ˆ˜ 2026. 10. 19. 01:01
๋ฐ˜์‘ํ˜•

๐ŸŒ English Abstract

This article presents a comprehensive engineering guide to real-time high-speed data visualization in Python, handling up to 100,000 samples per second (100 kS/s). It covers architectural solutions for UI thread blocking using multi-threaded PySerial data ingestion, high-performance memory management with pre-allocated NumPy Ring Buffers, and PyOpenGL-accelerated PyQtGraph rendering. Readers will gain actionable troubleshooting insights into eliminating signal backpressure, optimizing downsampling algorithms, and resolving OS-level serial buffer overflows.

์˜๋ฃŒ๊ธฐ๊ธฐ, ์ž„๋ฒ ๋””๋“œ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์Šคํ…œ, ์‚ฐ์—…์šฉ ์ง„๋™ ๋ถ„์„ ๋“ฑ ์ตœ์‹  ํ•˜๋“œ์›จ์–ด ์ œ์–ด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋Š” ์ดˆ๋‹น ์ˆ˜๋งŒ์—์„œ ์ˆ˜์‹ญ๋งŒ ๊ฑด์— ๋‹ฌํ•˜๋Š” ๊ณ ์† ๋ฐ์ดํ„ฐ ํŒŒ์ดํ”„๋ผ์ธ ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์ˆ˜์ ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ํŒŒ์ด์ฌ(Python) ํ™˜๊ฒฝ์—์„œ PySerial๋กœ ์ดˆ๋‹น 10๋งŒ ๊ฑด(100 kS/s)์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์‹ ํ•˜๋ฉด์„œ PyQtGraph๋กœ ์‹ค์‹œ๊ฐ„ ๊ทธ๋ž˜ํ”„๋ฅผ ๊ทธ๋ฆด ๋•Œ, ๋Œ€๋‹ค์ˆ˜์˜ ์—”์ง€๋‹ˆ์–ด๋Š” UI ํ™”๋ฉด ๋ฉˆ์ถค(UI Thread Freeze), ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜(Memory Leak), ๊ทธ๋ฆฌ๊ณ  ํ”„๋ ˆ์ž„ ๋“œ๋ž(Frame Drop) ํ˜„์ƒ์„ ๊ฒช๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๋ณธ ํฌ์ŠคํŒ…์—์„œ๋Š” ํŒŒ์ด์ฌ์˜ ํ•œ๊ณ„๋กœ ์ง€๋ชฉ๋˜๋Š” GIL(Global Interpreter Lock)๊ณผ Qt ์ด๋ฒคํŠธ ๋ฃจํ”„(Event Loop)์˜ ๋ณ‘๋ชฉ์„ ๊ทน๋ณตํ•˜๊ณ , ์ดˆ๋‹น 10๋งŒ ๊ฑด์˜ ๋ Œ๋”๋ง์„ 60 FPS ์ˆ˜์ค€์œผ๋กœ ๋งค๋„๋Ÿฝ๊ฒŒ ์‹œ๊ฐํ™”ํ•˜๊ธฐ ์œ„ํ•œ ๋ฐฑ์—”๋“œ ํŒŒ์ดํ”„๋ผ์ธ ์„ค๊ณ„ ๋ฐ ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… ๋ฐฉ์•ˆ์„ ์‹ฌ๋„ ์žˆ๊ฒŒ ๋‹ค๋ฃน๋‹ˆ๋‹ค.



1. UI ์“ฐ๋ ˆ๋“œ ๋ธ”๋กœํ‚น์˜ ์›์ธ๊ณผ ๋ฐ์ดํ„ฐ ์ˆ˜์‹  ํŒŒ์ดํ”„๋ผ์ธ ๋ถ„๋ฆฌ

PyQt๋‚˜ PySide์™€ ๊ฐ™์€ GUI ํ”„๋ ˆ์ž„์›Œํฌ๋Š” ๋‹จ์ผ ๋ฉ”์ธ ์“ฐ๋ ˆ๋“œ(Main UI Thread)์—์„œ ์ด๋ฒคํŠธ ๋ฃจํ”„๋ฅผ ์ˆœํ™˜ํ•˜๋ฉฐ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์ฒ˜๋ฆฌ์™€ ํ™”๋ฉด ๊ทธ๋ฆฌ๊ธฐ(Paint Event)๋ฅผ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. PySerial์˜ read() ํ•จ์ˆ˜๋ฅผ ๋ฉ”์ธ ์“ฐ๋ ˆ๋“œ์—์„œ ๋™๊ธฐ ๋ฐฉ์‹(Synchronous)์œผ๋กœ ํ˜ธ์ถœํ•˜๊ฑฐ๋‚˜, ๋ฐ์ดํ„ฐ ์ˆ˜์‹  ์‹œ๋งˆ๋‹ค ์ง์ ‘ UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์‹ฌ๊ฐํ•œ ๋ณ‘๋ชฉ์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

  • ์ด๋ฒคํŠธ ๋ฃจํ”„ ์ง€์—ฐ(Event Loop Latency): ์ˆ˜์‹  ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ์ž‘์—…์ด UI repaint ์‹œ๊ฐ„๋ณด๋‹ค ์˜ค๋ž˜ ๊ฑธ๋ ค ํ™”๋ฉด์ด ๊ตณ๋Š” ํ˜„์ƒ์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.
  • ์‹œ๊ทธ๋„ ๋ฐฑํ”„๋ ˆ์…”(Signal Backpressure): ์ดˆ๋‹น 10๋งŒ ๋ฒˆ์˜ Qt Signal์„ ๋ฐœํ–‰ํ•˜๋ฉด ์‹œ๊ทธ๋„ ํ(Signal Queue)๊ฐ€ ํฌํ™”๋˜์–ด ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์ด ํญ์ฆํ•˜๊ณ  ๋ฐ˜์‘์„ฑ์ด ๋งˆ๋น„๋ฉ๋‹ˆ๋‹ค.

ํ”„๋กœ๋“€์„œ-์ปจ์Šˆ๋จธ(Producer-Consumer) ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด

์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๋ ค๋ฉด ๋ฐ์ดํ„ฐ ์ˆ˜์‹ (Producer)๊ณผ ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™”(Consumer)์˜ ์—ญํ• ์„ ์—„๊ฒฉํžˆ ๋ถ„๋ฆฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฐ์ดํ„ฐ ์ˆ˜์‹ ์€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ Worker ์“ฐ๋ ˆ๋“œ(QThread)์—์„œ ์ „๋‹ดํ•˜๊ณ , ํš๋“ํ•œ ๋ฐ์ดํ„ฐ๋Š” ์ฒญํฌ(Chunk) ๋‹จ์œ„๋กœ ๋ฌถ์–ด ์ฃผ๊ธฐ์ ์œผ๋กœ ๋ฉ”์ธ ์“ฐ๋ ˆ๋“œ์— ์ „๋‹ฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

from PySide6.QtCore import QThread, Signal
import serial
import numpy as np

class SerialReaderThread(QThread):
    # 10๋งŒ ๊ฑด์„ ๋งค๋ฒˆ ๋ณด๋‚ผ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ ์ฒญํฌ ๋‹จ์œ„(์˜ˆ: 1,000๊ฐœ ๋ฐ์ดํ„ฐ)๋กœ ๋ฌถ์–ด์„œ ๋ฐœ์†ก
    data_raw_received = Signal(np.ndarray)

    def __init__(self, port, baudrate, chunk_size=1000):
        super().__init__()
        self.port = port
        self.baudrate = baudrate
        self.chunk_size = chunk_size
        self.is_running = True

    def run(self):
        ser = serial.Serial(self.port, self.baudrate, timeout=0.1)
        buffer = bytearray()
        
        while self.is_running:
            if ser.in_waiting > 0:
                # ์‹œ๋ฆฌ์–ผ ๋ฒ„ํผ์—์„œ ๊ฐ€์šฉ ๋ฐ์ดํ„ฐ๋ฅผ ๋Œ€๋Ÿ‰ ์ฝ๊ธฐ
                raw_bytes = ser.read(ser.in_waiting)
                buffer.extend(raw_bytes)
                
                # ์ •ํ•ด์ง„ ํŒจํ‚ท ๊ทœ๊ฒฉ์— ๋”ฐ๋ผ ํŒŒ์‹ฑ ํ›„ ๋„˜ํŒŒ์ด ๋ฐฐ์—ด ๋ณ€ํ™˜ (์˜ˆ์‹œ: 2๋ฐ”์ดํŠธ int16)
                while len(buffer) >= self.chunk_size * 2:
                    chunk_data = np.frombuffer(buffer[:self.chunk_size * 2], dtype=np.int16)
                    del buffer[:self.chunk_size * 2]
                    
                    # Qt Signal ๋ฐœ์†ก (์ดˆ๋‹น 100ํšŒ ๋‚ด์™ธ๋กœ ์ œํ•œ๋จ)
                    self.data_raw_received.emit(chunk_data)
        
        ser.close()

์œ„ ๋ฐฉ์‹์˜ ํ•ต์‹ฌ์€ ์‹œ๊ทธ๋„ ๋ฐœ์ƒ ๋นˆ๋„๋ฅผ ์ดˆ๋‹น 10๋งŒ ๋ฒˆ์—์„œ ์ดˆ๋‹น 100๋ฒˆ(1,000๊ฐœ ๋‹จ์œ„ ์ฒญํฌ) ์ˆ˜์ค€์œผ๋กœ ๋Œ€ํญ ๋‚ฎ์ถ”๋Š” ์‹œ๊ทธ๋„ ์Šค๋กœํ‹€๋ง(Signal Throttling)์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ๋ฉ”์ธ UI ์“ฐ๋ ˆ๋“œ์˜ ์ด๋ฒคํŠธ ๋ฃจํ”„ ๊ณผ๋ถ€ํ•˜๋ฅผ ์›์ฒœ ์ฐจ๋‹จํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

2. ๋ฉ”๋ชจ๋ฆฌ ์žฌํ• ๋‹น ๋ณ‘๋ชฉ ํ•ด๊ฒฐ: NumPy ๊ณ ์† Ring Buffer ๊ตฌํ˜„

ํŒŒ์ด์ฌ์˜ ๊ธฐ๋ณธ list.append()๋‚˜ np.append() ๋ฐฉ์‹์€ ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ ์‹œ ์น˜๋ช…์ ์ธ ์„ฑ๋Šฅ ์ €ํ•˜๋ฅผ ์œ ๋ฐœํ•ฉ๋‹ˆ๋‹ค. np.append()๋Š” ์‹คํ–‰๋  ๋•Œ๋งˆ๋‹ค ๋‚ด๋ถ€์ ์œผ๋กœ ์ƒˆ๋กœ์šด ๋ฉ”๋ชจ๋ฆฌ ๊ณต๊ฐ„์„ ํ• ๋‹นํ•˜๊ณ  ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฅผ ๋ณต์‚ฌ(O(N))ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ดˆ๋‹น 10๋งŒ ๊ฑด์˜ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ˆ„์ ๋˜๋Š” ํ™˜๊ฒฝ์—์„œ๋Š” ๋ถˆ๊ณผ ์ˆ˜ ์ดˆ ๋งŒ์— CPU ์‚ฌ์šฉ๋ฅ ์ด 100%์— ๋„๋‹ฌํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

๋”ฐ๋ผ์„œ ๋ฉ”๋ชจ๋ฆฌ ํ• ๋‹น์„ ๋ฏธ๋ฆฌ ์™„๋ฃŒํ•ด ๋‘๊ณ  ์ธ๋ฑ์Šค๋งŒ ์ˆœํ™˜ํ•˜๋Š” ์›ํ˜• ๋ฒ„ํผ(Ring Buffer / Circular Buffer) ๊ตฌ์กฐ๋ฅผ ๊ณ ์„ฑ๋Šฅ NumPy ๊ธฐ๋ฐ˜์œผ๋กœ ์ง์ ‘ ๊ตฌ์ถ•ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๊ณ ์„ฑ๋Šฅ NumPy Ring Buffer ์„ค๊ณ„

class FastNumPyRingBuffer:
    def __init__(self, capacity, dtype=np.float32):
        self.capacity = capacity
        self.data = np.zeros(capacity, dtype=dtype)
        self.index = 0
        self.is_full = False

    def append_many(self, samples: np.ndarray):
        n = len(samples)
        if n == 0:
            return

        if n >= self.capacity:
            # ์ž…๋ ฅ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฒ„ํผ ํฌ๊ธฐ๋ณด๋‹ค ํฌ๋ฉด ๋งˆ์ง€๋ง‰ capacity ๋งŒํผ๋งŒ ์ €์žฅ
            self.data[:] = samples[-self.capacity:]
            self.index = 0
            self.is_full = True
            return

        end_idx = self.index + n
        if end_idx <= self.capacity:
            # ๊ฒฝ๊ณ„๋ฅผ ๋„˜์ง€ ์•Š๋Š” ์—ฐ์† ๋ฉ”๋ชจ๋ฆฌ ๋ณต์‚ฌ
            self.data[self.index:end_idx] = samples
            self.index = end_idx
        else:
            # ๋ฒ„ํผ ๊ฒฝ๊ณ„๋ฅผ ๋„˜์–ด์„ค ๊ฒฝ์šฐ ์Šฌ๋ผ์ด์Šค๋กœ ๋‚˜๋ˆ„์–ด ๋ณต์‚ฌ (Wrap-around)
            overflow = end_idx - self.capacity
            self.data[self.index:self.capacity] = samples[:n - overflow]
            self.data[0:overflow] = samples[n - overflow:]
            self.index = overflow
            self.is_full = True

    def get_ordered_data(self) -> np.ndarray:
        """์‹œ๊ฐํ™”๋ฅผ ์œ„ํ•ด ์‹œ๊ฐ„ ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ๋œ ์„ ํ˜• ๋ฐฐ์—ด ๋ฐ˜ํ™˜"""
        if not self.is_full:
            return self.data[:self.index]
        return np.concatenate((self.data[self.index:], self.data[:self.index]))

๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์ „ ํ• ๋‹น(Pre-allocation) ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜๋ฉด, ์ˆ˜์‹  ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ ์—ฐ์‚ฐ ์†๋„๊ฐ€ $O(1)$๋กœ ์ผ์ •ํ•˜๊ฒŒ ์œ ์ง€๋˜๋ฏ€๋กœ ๊ฐ€๋น„์ง€ ์ปฌ๋ ‰ํ„ฐ(Garbage Collector)์˜ ๋ถ€๋‹ด์„ ํš๊ธฐ์ ์œผ๋กœ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.



3. PyQtGraph GPU ๊ฐ€์† ๋ฐ ๋‹ค์šด์ƒ˜ํ”Œ๋ง ์ตœ์ ํ™”

ํŒŒ์ด์ฌ ์‹œ๊ฐํ™” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ค‘ Matplotlib์€ ์‹ค์‹œ๊ฐ„ ์‹œ๊ฐํ™”์— ์ ํ•ฉํ•˜์ง€ ์•Š์œผ๋ฉฐ, **PyQtGraph**๊ฐ€ ๊ฐ€์žฅ ์ตœ์ ํ™”๋œ ๋Œ€์•ˆ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ PyQtGraph ์—ญ์‹œ ์•„๋ฌด๋Ÿฐ ์„ค์ • ์—†์ด ์ดˆ๋‹น 10๋งŒ ๊ฐœ์˜ ํฌ์ธํŠธ(Points)๋ฅผ ๋Œ€ํ™”ํ˜• ๋ ˆ๋ฒจ์—์„œ ๋ Œ๋”๋งํ•˜๋ฉด CPU ์†Œํ”„ํŠธ์›จ์–ด ๋ Œ๋”๋Ÿฌ์˜ ํ•œ๊ณ„๋กœ ํ”„๋ ˆ์ž„ ๋ ˆ์ดํŠธ๊ฐ€ ๋–จ์–ด์ง„๋‹ค.

100 kS/s ์‹œ๊ฐํ™” ์„ฑ๋Šฅ์„ ์ตœ์ƒ์œผ๋กœ ๋Œ์–ด์˜ฌ๋ฆฌ๊ธฐ ์œ„ํ•ด์„œ๋Š” **OpenGL GPU ๊ฐ€์†**๊ณผ **๋‹ค์šด์ƒ˜ํ”Œ๋ง(Downsampling)** ์ „๋žต์„ ๊ฒฐํ•ฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

1) PyOpenGL ๋ Œ๋”๋ง ํ™œ์„ฑํ™”

PyQtGraph ๋‚ด๋ถ€ ๋ Œ๋”๋ง ํŒŒ์ดํ”„๋ผ์ธ์„ Qt์˜ QPainter(CPU) ๊ธฐ๋ฐ˜์—์„œ OpenGL(GPU) ๊ธฐ๋ฐ˜์œผ๋กœ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

import pyqtgraph as pg

# PyQtGraph ์ „์—ญ ์˜ต์…˜ ์„ค์ •: OpenGL GPU ๊ฐ€์† ์‚ฌ์šฉ
pg.setConfigOptions(useOpenGL=True, antialias=False)

์ฃผ์˜ ์‚ฌํ•ญ: ๊ณ ์† ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™” ์‹œ antialias=True(์•ˆํ‹ฐ์•จ๋ฆฌ์–ด์‹ฑ) ์˜ต์…˜์„ ์ผœ๋ฉด GPU์˜ ๋ž˜์Šคํ„ฐํ™”(Rasterization) ์—ฐ์‚ฐ๋Ÿ‰์ด ๊ธ‰์ฆํ•˜๋ฏ€๋กœ ๋ฐ˜๋“œ์‹œ False๋กœ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

2) ์ง€๋Šฅํ˜• ๋‹ค์šด์ƒ˜ํ”Œ๋ง (Peak Downsampling)

๋ชจ๋‹ˆํ„ฐ์˜ ํ•ด์ƒ๋„๋Š” ๋ณดํ†ต FHD(1920x1080)์—์„œ 4K ์ˆ˜์ค€์ž…๋‹ˆ๋‹ค. ์ฆ‰, ๊ฐ€๋กœ 1,920ํ”ฝ์…€ ํ™”๋ฉด์— 10๋งŒ ๊ฐœ์˜ ํฌ์ธํŠธ๋ฅผ ์ „๋ถ€ ๊ทธ๋ฆฌ๋Š” ๊ฒƒ์€ ๋ฌผ๋ฆฌ์ ์œผ๋กœ ๋ฌด์˜๋ฏธํ•˜๋ฉฐ GPU ์ž์›์˜ ๋‚ญ๋น„์ž…๋‹ˆ๋‹ค. PyQtGraph์˜ ๋‹ค์šด์ƒ˜ํ”Œ๋ง ๊ธฐ๋Šฅ์„ ์ ์šฉํ•˜๋ฉด ํ”ฝ์…€ ์˜์—ญ ๋‚ด์˜ ๋Œ€ํ‘œ๊ฐ’๋งŒ ๊ณจ๋ผ ๊ทธ๋ ค ์—ฐ์‚ฐ๋Ÿ‰์„ 90% ์ด์ƒ ์ ˆ๊ฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

plot_item = pg.PlotWidget()
curve = plot_item.plot(pen='g')

# Peak Downsampling ํ™œ์„ฑํ™”
curve.setDownsampling(ds=True, auto=True, method='peak')
curve.setClipToView(True)  # ํ™”๋ฉด์— ๋ณด์ด๋Š” ์˜์—ญ ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ๋Š” ๋ Œ๋”๋ง์—์„œ ์ œ์™ธ

ํŠนํžˆ method='peak' ์•Œ๊ณ ๋ฆฌ์ฆ˜์€ ๊ฐ ํ”ฝ์…€ ๊ตฌ๊ฐ„ ๋‚ด์˜ ์ตœ์†Ÿ๊ฐ’(Min)๊ณผ ์ตœ๋Œ“๊ฐ’(Max)์„ ์ฐพ์•„ ๋ณด์กดํ•˜๋ฏ€๋กœ, ์„ผ์„œ ์‹ ํ˜ธ์˜ **์ž„ํŽ„์Šค ๋…ธ์ด์ฆˆ๋‚˜ ํ”ผํฌ(Peak) ๊ธ€๋ฆฌ์น˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์ƒ๋žต๋˜์ง€ ์•Š๊ณ  ์ •ํ™•ํ•˜๊ฒŒ ํ‘œํ˜„**๋œ๋‹ค๋Š” ์ด์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค.

4. ํ˜„์—… ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… ๋ฐ ์—ฃ์ง€ ์ผ€์ด์Šค (Troubleshooting)

์‹œ์Šคํ…œ์„ ์‹ค์ œ ์šด์šฉ ํ™˜๊ฒฝ์— ๋ฐฐํฌํ•  ๋•Œ ๋ถ€๋”ชํžˆ๋Š” ๋Œ€ํ‘œ์ ์ธ ๋ฌธ์ œ๋“ค๊ณผ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.

ํ•˜๋“œ์›จ์–ด ๋ฐ OS ๋ ˆ๋ฒจ ๋ฒ„ํผ ์˜ค๋ฒ„ํ”Œ๋กœ์šฐ (PySerial Latency)

Windows OS ํ™˜๊ฒฝ์—์„œ FTDI ๊ณ„์—ด USB-to-Serial ์นฉ์…‹์„ ์‚ฌ์šฉํ•  ๋•Œ, ๊ธฐ๋ณธ ๋“œ๋ผ์ด๋ฒ„ ์„ค์ •์˜ ์ง€์—ฐ ์‹œ๊ฐ„ ํƒ€์ด๋จธ(Latency Timer)๊ฐ€ 16ms๋กœ ์„ค์ •๋˜์–ด ์žˆ์–ด ๋ฐ์ดํ„ฐ๊ฐ€ ๋“œ๋ฌธ๋“œ๋ฌธ ๋ญ‰์ณ์„œ ๋“ค์–ด์˜ค๋Š” ํ˜„์ƒ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  • ํ•ด๊ฒฐ์ฑ…: ์žฅ์น˜ ๊ด€๋ฆฌ์ž(Device Manager) > ํฌํŠธ(COM & LPT) > ์†์„ฑ > ๊ณ ๊ธ‰ ์„ค์ •์—์„œ Latency Timer ๊ฐ’์„ 1ms๋กœ ์ตœ์ ํ™”ํ•ฉ๋‹ˆ๋‹ค.

Qt Timer ๊ธฐ๋ฐ˜ GUI Render Loop ๊ตฌํ˜„

์‹œ๊ทธ๋„ ์ˆ˜์‹  ์‹œ๋งˆ๋‹ค curve.setData()๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋Œ€์‹ , ๋…๋ฆฝ๋œ QTimer๋ฅผ ๊ตฌ๋™ํ•˜์—ฌ **60 FPS(์•ฝ 16.6ms ์ฃผ๊ธฐ)**๋กœ ํ”Œ๋กฏ์„ ๊ฐฑ์‹ ํ•˜๋„๋ก ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ˆ˜์‹  ์†๋„์™€ ํ™”๋ฉด ํ”Œ๋กฏ ์†๋„๊ฐ€ ์™„์ „ํžˆ ๋””์ปคํ”Œ๋ง(Decoupling)๋˜์–ด ์™„๋ฒฝํ•œ ๋ถ€๋“œ๋Ÿฌ์›€์„ ์–ป์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

# UI ํด๋ž˜์Šค ๋‚ด๋ถ€ ์˜ˆ์‹œ
self.render_timer = QTimer()
self.render_timer.setInterval(16) # ~60 FPS
self.render_timer.timeout.connect(self.update_plot)
self.render_timer.start()

def update_plot(self):
    # Ring Buffer์—์„œ ํ˜„์žฌ ์Šค๋ƒ…์ƒท ์ถ”์ถœ ํ›„ ๋ Œ๋”๋ง
    plot_data = self.ring_buffer.get_ordered_data()
    if len(plot_data) > 0:
        self.curve.setData(plot_data)

PyOpenGL ๋“œ๋ผ์ด๋ฒ„ ํ˜ธํ™˜์„ฑ ์ด์Šˆ

์ผ๋ถ€ ๋‚ด์žฅ ๊ทธ๋ž˜ํ”ฝ ์นด๋“œ(Intel Integrated Graphics) ํ™˜๊ฒฝ์—์„œ๋Š” PyOpenGL ์‚ฌ์šฉ ์‹œ ๋“œ๋ผ์ด๋ฒ„ ์ถฉ๋Œ๋กœ ํ”„๋กœ๊ทธ๋žจ์ด ํŠ•๊ธฐ๊ฑฐ๋‚˜ ๊ฒ€์€ ํ™”๋ฉด์ด ์ถœ๋ ฅ๋˜๋Š” ์—ฃ์ง€ ์ผ€์ด์Šค๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.

  • ํ•ด๊ฒฐ์ฑ…: ํ”„๋กœ๊ทธ๋žจ ์‹œ์ž‘ ์‹œ OpenGL ์ดˆ๊ธฐํ™”๋ฅผ ์‹œ๋„ํ•˜๋˜, ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ useOpenGL=False๋กœ ๋กค๋ฐฑํ•˜๊ณ  ๋‹ค์šด์ƒ˜ํ”Œ๋ง ํŒฉํ„ฐ๋ฅผ ๋†’์ด๋Š” Polymorphic Fallback ๋กœ์ง์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.


5. ๊ฒฐ๋ก  ๋ฐ ์ตœ์ข… ์‹œ์Šคํ…œ ๊ตฌ์กฐ ์š”์•ฝ

์ดˆ๋‹น 10๋งŒ ๊ฑด ์ด์ƒ์˜ ์ดˆ๊ณ ์† ์„ผ์„œ ๋ฐ์ดํ„ฐ๋ฅผ ํŒŒ์ด์ฌ ํ™˜๊ฒฝ์—์„œ UI ๋ ‰ ์—†์ด ์‹ค์‹œ๊ฐ„ ์‹œ๊ฐํ™”ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‹ค์Œ ํ•ต์‹ฌ ์š”์†Œ๋ฅผ ๋ช…ํ™•ํ•˜๊ฒŒ ๊ฒฐํ•ฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  1. QThread ๋ถ„๋ฆฌ: PySerial ๋ฐ์ดํ„ฐ๋ฅผ ๋ฉ”์ธ UI ์“ฐ๋ ˆ๋“œ๊ฐ€ ์•„๋‹Œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์“ฐ๋ ˆ๋“œ์—์„œ ๋ธ”๋กœํ‚น ์—†์ด ์ˆ˜์‹ ํ•ฉ๋‹ˆ๋‹ค.
  2. Chunk ๋‹จ์œ„ ํ†ต์‹ : Qt Signal ๋ฐœ์†ก ๋นˆ๋„๋ฅผ ์ดˆ๋‹น 60~100ํšŒ ์ˆ˜์ค€์œผ๋กœ ์ œ์–ดํ•˜์—ฌ ์ด๋ฒคํŠธ ๋ฃจํ”„ ๋ณ‘๋ชฉ์„ ์˜ˆ๋ฐฉํ•ฉ๋‹ˆ๋‹ค.
  3. NumPy Pre-allocated Ring Buffer: ์ง€์†์ ์ธ ๋™์  ๋ฉ”๋ชจ๋ฆฌ ํ• ๋‹น ๋ฐ ๊ฐ€๋น„์ง€ ์ปฌ๋ ‰์…˜์„ ํšŒํ”ผํ•˜๊ณ  ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ํšจ์œจ์„ ์ตœ์ ํ™”ํ•ฉ๋‹ˆ๋‹ค.
  4. PyQtGraph OpenGL + Peak Downsampling: GPU ๊ฐ€์†๊ณผ ๊ฐ€์‹œ์  ํ”ผํฌ ๋ณด์กด ๋‹ค์šด์ƒ˜ํ”Œ๋ง์„ ํ†ตํ•ด GPU ๋ Œ๋”๋ง ๋ถ€ํ•˜๋ฅผ ์ค„์ž…๋‹ˆ๋‹ค.
  5. Timer ๊ธฐ๋ฐ˜ Decoupled Rendering: ์ˆ˜์‹  ํŒŒ์ดํ”„๋ผ์ธ๊ณผ UI ๋ Œ๋”๋ง ์ฃผ๊ธฐ๋ฅผ ๋ถ„๋ฆฌํ•˜์—ฌ ๊ณ ์ • 60 FPS ์Šค๋ฌด์Šค ๋ Œ๋”๋ง์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค.

์œ„์˜ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ ํŒจํ„ด์„ ๊ณ ์† ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘(DAQ) ํ”„๋กœ์ ํŠธ์— ์ ์šฉํ•˜๋ฉด, ํŒŒ์ด์ฌ ๊ธฐ๋ฐ˜์œผ๋กœ๋„ C/C++ ๋ชป์ง€์•Š์€ ์šฐ์ˆ˜ํ•œ ์„ฑ๋Šฅ๊ณผ ๋†’์€ ๋ฐ˜์‘์„ฑ์˜ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์Šคํ…œ์„ ์•ˆ์ •์ ์œผ๋กœ ๊ตฌ์ถ•ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ฐ˜์‘ํ˜•