
Phase 3.5가 끝났을 때 남은 한 가지 아쉬움
Phase 3.5를 마무리했을 때, PLCLink는 캔버스 HMI까지 갖춘 꽤 완성된 도구가 됐습니다.
그런데 현장에 나가면 이런 상황이 생깁니다.
"저 쪽 설비는 Omron인데요."
"이 라인은 Modbus TCP로만 연결돼요."
"우리 공장은 OPC-UA 서버 깔려 있어요."
PLCLink는 Mitsubishi와 Keyence만 연결됐어요.
SLMP 프로토콜만 지원했거든요.
특정 현장에서는 쓸 수 없는 툴이었습니다.
Phase 4의 목표는 하나였어요.
어떤 PLC든, 어떤 프로토콜이든 연결되게.
설계 원칙 : 프로토콜과 메이커를 분리한다

가장 먼저 한 결정이 이겁니다.
기존 코드는 "Keyence면 이렇게, Mitsubishi면 저렇게" 방식으로 메이커가 통신 방식을 결정했어요.
새 프로토콜을 추가하려면 메이커 로직 전체를 손봐야 했습니다.
Phase 4에서 이 둘을 완전히 분리했습니다.
protocol → 어떻게 통신하는가
maker → 주소를 어떻게 표기하는가
예를 들어 이런 조합이 가능합니다.
maker=keyence, protocol=slmp → 기존 방식
maker=keyence, protocol=modbus_tcp → Keyence PLC를 Modbus로 연결
maker=mitsubishi, protocol=fins → 미쓰비시를 FINS로? (이건 안 되지만 구조상 분리됨)
maker=omron, protocol=fins → Omron CJ2M + FINS/TCP
이 구조를 프로토콜 팩토리로 구현했습니다.

def make_client(protocol, host, port, timeout, maker):
if protocol == "slmp":
return PLCClient(host, port, timeout, maker)
elif protocol == "modbus_tcp":
return ModbusClient(host, port, timeout, maker)
elif protocol == "opcua":
return OPCUAClient(host, port, timeout, maker)
elif protocol == "fins":
return FINSClient(host, port, timeout, maker)
새 프로토콜이 생기면 클라이언트 하나만 추가하면 됩니다.
기존 코드를 건드리지 않아요.
프로토콜 4가지 : 각각 어떻게 다른가

SLMP (기존)
Mitsubishi와 Keyence 전용입니다.
바이너리 프레임을 직접 조립해서 소켓으로 보냅니다.
Phase 1부터 써온 방식이에요.
특징: 메이커 고유 프로토콜이라 공개 라이브러리가 없고, 3E Frame 구조를 직접 구현해야 합니다.
Modbus TCP (신규)
산업 현장에서 가장 보편적인 표준 프로토콜입니다.
PLC 브랜드 상관없이 Modbus 옵션이 있으면 쓸 수 있어요.
pymodbus 라이브러리를 사용했습니다.
함수 코드(FC)로 읽기 종류를 구분합니다.
FC01: Coil (출력 비트) 읽기
FC02: Discrete Input (입력 비트) 읽기
FC03: Holding Register (워드) 읽기
FC04: Input Register (읽기 전용 워드) 읽기
처음에는 "비트면 FC02, 워드면 FC03 쓰면 되겠다"고 생각했는데, 이게 SLMP와 다르게 동작하는 부분이 있어서 한 번 막혔습니다.
(자세한 내용은 보족 P에서)
OPC-UA (신규)
스마트 팩토리, MES 연동을 위한 현대적 표준 프로토콜입니다.
세션 기반으로 동작하고, PLC 데이터가 "노드"라는 개념으로 관리됩니다.
asyncua 라이브러리를 사용했습니다.
노드를 찾는 방법이 두 가지 있는데, 실제 서버와 연결하니까 예상대로 안 됐어요.
(자세한 내용은 보족 R에서)
FINS/TCP (신규)
Omron PLC 전용 프로토콜입니다.
SLMP처럼 바이너리 프레임을 직접 조립합니다.
공개 Python 라이브러리가 없어서 Omron 공식 스펙 기반으로 처음부터 구현했어요.
TCP 연결 후 20바이트 핸드쉐이크를 먼저 하고, 그다음에 메모리 읽기 명령을 보냅니다.
구조체 포맷 하나가 틀려서 파싱이 전부 어긋나는 버그가 있었어요.
(자세한 내용은 보족 Q에서)
SetupFlow UX 재설계 : 통신 방식을 먼저 선택하게

프로토콜이 4가지가 됐는데, 기존 SetupFlow는 "메이커 선택"이 첫 화면이었습니다.
그런데 Keyence PLC라고 해도 SLMP로 연결할 건지, Modbus로 연결할 건지를 먼저 정해야 합니다.
"Keyence 선택"이 통신 방식까지 결정해버리면 혼란이 생겨요.
기존 흐름:
메이커 선택 (Keyence/Mitsubishi/Modbus 혼재) → IP/포트 → 진단
변경된 흐름:
통신 방식 선택 (SLMP/Modbus TCP/OPC-UA/FINS) → 장비 선택 → IP/포트 → 진단
사용자가 "어떻게 연결할 것인가"를 먼저 고르면, 그에 맞는 장비 목록이 표시됩니다.
포트 기본값도 자동으로 바뀌어요.
SLMP 선택 → 포트 기본값 5007
Modbus 선택 → 포트 기본값 502
OPC-UA 선택 → 포트 기본값 4840
FINS 선택 → 포트 기본값 9600
이 변경으로 "Modbus TCP 선택 → Omron CJ2M 선택 → 포트 502 자동 입력"처럼 흐름이 자연스러워졌습니다.
비트 읽기 : 프로토콜마다 방식이 달랐다

SLMP에서 비트를 읽을 때는 이런 최적화를 써왔습니다.
비트 신호 16개를 한 번의 요청으로 읽으려면, 워드(16비트) 1개를 읽고 프론트엔드에서 각 비트를 추출하면 됩니다.
요청 1번에 16개 비트를 처리할 수 있어요.
그런데 Modbus는 달랐습니다.
Modbus는 비트(Coil, DI)를 직접 읽는 전용 함수 코드(FC01, FC02)가 있습니다.
워드로 읽어서 분해하는 게 아니라 비트 주소로 바로 요청해야 해요.
이걸 프론트엔드에서 분기 처리했습니다.
// SLMP: 비트를 워드로 읽어서 프론트에서 분해 (요청 1개 = 16비트)
if (pt.type === "bit" && isSlmp) {
return { ...pt, count: Math.ceil(count / 16), type: "word" }
}
// Modbus/FINS/OPC-UA: 비트 그대로 전달 → 백엔드가 FC01/FC02 사용
return { ...pt, count, type: pt.type }
프로토콜을 추가할 때 이런 동작 차이를 파악하지 않으면 Modbus Coil 읽기가 아무 값도 안 오거나 0만 나오는 증상이 생깁니다.
타이밍 차트 : 비트 신호를 계단파로 보면 달라진다

Data List에는 원래 Recharts LineChart로 트렌드 차트가 있었습니다.
숫자 값이 시간에 따라 어떻게 변하는지 보여주는 기능이에요.
그런데 비트 신호(ON/OFF)를 선 그래프로 그리면 이렇게 됩니다.
1
│ /\ /\
│ / \ / \
0 / V \
──────────────
언제 켜지고 언제 꺼졌는지는 보이는데, "이 신호가 얼마나 켜져 있었나"를 직관적으로 파악하기 어렵습니다.
타이밍 차트는 비트 신호를 계단파(stepAfter)로 그립니다.
1
│▓▓▓▓│ │▓▓▓│
│ │ │ │
0 │▓▓▓│ │▓▓
──────────────────
ON 구간이 막대 형태로 보입니다.
여러 신호를 독립 레인으로 쌓으면
"실린더 A가 전진하고, 그다음 실린더 B가 전진하고, 동시에 센서 C가 들어온다"는
타이밍을 한눈에 볼 수 있어요.
// 비트 신호 (계단파)
<Area dataKey={eid} type="stepAfter"
stroke={color} fill={`${color}30`}
strokeWidth={1.5} dot={false} />
// 워드 신호 (선 그래프)
<Line dataKey={eid} type="monotone"
stroke={color} strokeWidth={1.5} dot={false} />
비트 레인은 48px, 워드 레인은 72px 높이로 독립 표시합니다.
DB의 dl_history에서 읽어서 시간 범위 선택이 가능해요.
OK/NG 패턴 분석 : 불량 원인을 데이터로 찾는다

트리거 스냅샷을 OK/NG로 분류해두면, 각 채널 값을 비교해서 "어떤 채널이 OK와 NG 사이에 차이가 있는가"를 계산할 수 있습니다.
엔지니어가 "이 쪽 신호가 불량이랑 관련 있을 것 같다"는 감으로 찾던 것을 데이터로 확인하는 기능이에요.
유의도 계산 방식입니다.
def significance(ok_vals, ng_vals):
delta = abs(ok_평균 - ng_평균)
spread = max(ok_표준편차, ng_표준편차)
base = max(|ok_평균|, |ng_평균|, 1)
# 평균 차이가 표준편차의 2배 이상 + 5% 이상이면 HIGH
if delta > spread * 2 and delta / base > 0.05:
return "HIGH"
elif delta > spread or delta / base > 0.02:
return "MED"
return "LOW"
단, 이 결과는 상관관계입니다.
인과관계가 아니에요.
"이 채널이 HIGH로 나왔다"는 건 OK/NG 사이에 값 차이가 크다는 것이지, 이게 불량의 원인이라는 뜻은 아닙니다.
최소 OK 3개, NG 3개 이상이 있어야 통계적으로 의미가 있고, 최종 판단은 엔지니어가 맥락과 함께 해야 해요.
데이터가 "여기 좀 봐" 하고 가리키는 것이고, 결론은 사람이 내립니다.
데이터 보존 정책 : 무한 누적되면 DB가 버티지 못한다

24시간 내내 폴링하면 데이터가 쌓이는 속도가 생각보다 빠릅니다.
1초 폴링에 채널 10개면 분당 600건, 30일이면 약 2600만 건입니다.
SQLite가 작은 규모에서 잘 동작하지만 이 정도 쌓이면 쿼리 속도가 느려지고 파일 크기도 커집니다.
DataCleaner를 추가했습니다.
서버 시작 30초 후, 그리고 24시간 간격으로 자동으로 오래된 데이터를 삭제합니다.
dl_history → 30일 초과 삭제
trigger_snapshots → 90일 초과 삭제
alarm_history → 180일 초과 삭제 (해제된 것만)
보존 기간은 SystemPage에서 변경 가능합니다.
"30일은 너무 짧다"는 현장이 있으면 늘릴 수 있어요.
버전 표시 자동화 : 배포할 때마다 수동으로 바꾸지 않으려고
헤더에 버전 번호를 하드코딩해두면 배포할 때마다 파일을 열어서 바꿔야 합니다.
한 번 잊으면 구버전으로 운영하게 됩니다.
서버 시작 시 git log에서 마지막 커밋 날짜를 읽어서 버전으로 쓰도록 바꿨어요.
date_str = subprocess.check_output(
["git", "log", "-1", "--format=%cd", "--date=format:%Y-%m-%d"]
).decode().strip()
# → "2026-05-21"
커밋하면 자동으로 버전이 올라갑니다.
git이 없거나 실패하면 오늘 날짜로 폴백해요.
Phase 4를 마무리하며
Phase 4에서 PLCLink는 Mitsubishi/Keyence 전용 툴에서 어떤 PLC든 연결할 수 있는 범용 PLC 모니터링 툴로 바뀌었습니다.
프로토콜 팩토리 구조로 확장성을 확보했고, 타이밍 차트와 OK/NG 분석으로 "데이터를 보는 것"에서 "데이터로 판단하는 것"으로 기능이 한 단계 올라갔어요.
아직 원형 게이지, 슬라이더 위젯, 멀티 상태 램프 등 캔버스 HMI에 추가하려던 것들이 남아있습니다.
이건 Phase 5 이후에 채워나갈 예정이에요.
Phase 5는 이 모든 것을 Python 설치 없이, 더블클릭 한 번으로 쓸 수 있는 EXE 파일로 패키징하는 작업입니다.
코드는 GitHub에서 확인할 수 있습니다.
github.com/Gweongooing/PLCLink
'(개인Project)_개발 > PLC-PC 연결' 카테고리의 다른 글
| [Program][보족] Python으로 PLC 프로토콜을 직접 짜야 할 때 : FINS 스펙 읽는 방법 (0) | 2026.06.07 |
|---|---|
| [Program][보족] pymodbus 버전별 API 변경 정리 : 3.6 이하에서 3.13으로 마이그레이션 (0) | 2026.06.06 |
| [Program][보족] Ctrl+C로 서버를 껐더니 traceback이 쏟아졌다 : FastAPI CancelledError 억제하는 방법 (0) | 2026.06.03 |
| [Program][보족] 자동저장을 만들었는데 다른 저장이 덮어쓴다 : 로컬 상태와 서버 상태를 동기화하는 방법 (0) | 2026.06.02 |
| [Program][보족] 드래그 구현했는데 빠르게 움직이면 위젯을 놓쳐버린다 : 오버레이 패턴으로 해결 (0) | 2026.06.01 |