CMS
레거시 MFC 검사 앱을 PySide6로 현대화한 외주.
CMS는?
18650·21700 배터리 셀의 내부저항(ACIR)과 개방전압(OCV)을 자동으로 측정·판정하는 생산라인용 산업 HMI다. 미쓰비시 PLC(지그)가 셀을 눌러 계측기로 측정하면, 기준값 대비 OK/NG를 판정하는 검사 시퀀스를 한 트레이 256채널로 돌린다. PySide6 데스크톱 앱.
이건 내 포트폴리오에서 유일한 유료 용역 프로덕션이다. 개인 프로젝트 대부분이 미출시인 것과 달리, 미쓰비시 PLC·계측기와 실제로 통신하며 현장 설비 앞에서 반복 검증을 거친 산업 소프트웨어다. 다만 양산 라인에 올라간 단계는 아니다. 트레이 실장비 입고가 11월 중순이라, 지금까지 확인한 것은 현장에 설치해 설비와 붙이고 돌려 본 데까지다.
추가 개발분(DCIR)은 고객사가 한 차례 직접 써 보고 보고서를 보내 왔고, 그 지적을 고친 뒤 직접 방문해 실설비 앞에서 끝까지 돌려 봤다. 채널이 넘어가고, 네 채널 모두 전압이 떨어지고, 저항이 값으로 나왔다. 두 달 넘게 열려 있던 가장 큰 미완이 그날 닫혔다. 대신 하루 동안 고칠 것 서른 건이 새로 나왔고, 그걸 고친 버전은 아직 설비 앞에 다시 서지 않았다. 그리고 판정에 쓸 기준값을 아직 못 받았다. 재는 것은 되고 OK/NG를 매기는 것은 아직인 상태다.
국내 배터리 제조사에 납품한 클라이언트 용역이라, 회사명·계약·판정 규격·설비 주소 등은 전부 마스킹했다. 내부 화면과 실 스펙은 비공개.
왜 만들었나
이전 벤더가 남긴 낡은 MFC(C++) 검사 시스템을 대체·현대화하는 용역이었다. C++이라 직접 이어 쓰기 어렵고 벤더에 종속돼 있어서, PySide6로 전면 재작성했다. 레거시는 "무엇을 검사하고 어떻게 판정하는가"의 설계 도면으로만 참조하고, 통신·안전감시·상태머신·Mock 같은 골격은 새로 설계했다.
개발은 AI 보조(바이브코딩)로 진행했다. 1인이 이 규모(1차 완료 시점 약 15,000줄, 2차 DCIR까지 더해 현재 약 22,000줄)를 현장 설치까지 끌고 가려면 그게 현실적인 방식이었다.
전체 구조


실장비를 다루는 소프트웨어
이 프로젝트의 무게는 화면이 아니라 실제 PLC·계측기·비상정지와 안전하게 대화하는 부분에 있다.
PLC 통신 + 32/64비트 브릿지
미쓰비시 PLC와 통신해 M/D 비트를 읽고 쓰며 검사 시퀀스를 제어한다. 안전 신호, 하트비트, 채널 릴레이 스위칭까지 여기서 오간다. 통신은 미쓰비시 MX Component COM 인터페이스를 win32com으로 잡고, 별도 QThread에서 명령 큐로 처리한다.
까다로운 지점: MX Component가 32비트 전용이라 64비트 PySide6 앱에서 바로 못 부른다. 그래서 64비트를 감지하면 32비트 파이썬 서브프로세스를 띄워 로컬 TCP로 JSON 명령을 주고받는 브릿지를 만들어 우회했다. 하트비트로 연결 끊김을 감지하고, 안전 신호는 500ms로 폴링한다.
채널 그리드 HMI (16×16, 마스킹)
안전 시스템, EMG 래치
비상정지(EMG)와 도어 열림을 500ms 폴링으로 감시하다가, 발생하면 즉시 사이클을 중단하고 ESC로도 안 닫히는 모달을 띄운다. 해제는 PLC에 에러리셋 신호를 배치로 쓰고 ACK 비트를 기다리는 핸드셰이크로, 3초 안에 응답이 없으면 강제 클리어한다.
가장 값진 교훈이 여기서 나왔다. 물리 EMG 버튼이 눌린 상태면 PLC가 안전 비트를 1로 유지해서, 소프트웨어 Reset으로는 절대 안 풀린다(폴링이 계속 재발화). "코드를 아무리 고쳐도 안 풀리면, 버튼을 물리적으로 복구했는지부터 물어라"는 규칙은 여기서 몸으로 배운 것이다.
테스트 사이클 FSM
한 트레이 256채널을 지그 DOWN 한 번 → 채널을 순차로 릴레이 전환하며 측정·판정 → 지그 UP 한 번으로 돈다. 레거시의 전역 플래그 조합을 11-state Enum FSM으로 승격했다. 전이는 PLC 비트 콜백·사용자 버튼·타이머로 일어난다.
디버깅에서 배운 것: FSM이 개별 쓰기 완료 신호의 엣지에 걸려 있어서, 배치 쓰기를 그냥 하면 트리거가 안 됐다. 배치를 주소별 개별 신호로 분해해야 체인이 돈다(Mock도 같은 규약을 재현). 그리고 256채널 뒤에 버리는 **더미 채널(257)**을 하나 더 돌려 PLC 레이턴시로 마지막 채널이 누락되는 걸 막았다.
측정 · 판정 · 캘리브레이션
Hioki 계측기로 ACIR과 OCV를 RS-232C로 측정한다(원시 저항 값을 mΩ로 환산, N회 평균). 판정은 채널별 오프셋을 적용한 뒤, 미장착(오버/언더레인지) 검출 → 범위 판정 → NG 심각도 순. 원시값은 무결하게 기록하고 오프셋은 판정 단계에서만 쓴다. 캘리브레이션 탭에서 채널별 오프셋을 잡는다.
실제 판정 기준값·오프셋은 클라이언트 고유 규격이라 비공개. 코드 기본값은 범용 Li-ion 새니티 범위(예: ACIR 10
50mΩ, OCV 3.04.2V)일 뿐이다.
Mock 엔진, 무장비 개발
실장비(PLC·계측기·스캐너)가 없어도 로직·FSM·UI를 검증할 수 있게, 실엔진과 완전히 같은 신호 인터페이스를 가진 Mock 엔진 3종을 만들었다(드롭인 교체). 타이머로 응답을 모사하고, 배치 쓰기도 주소별 개별 신호로 재현하며, 비상정지 같은 장애를 주입하는 API까지 뒀다. 덕분에 현장 장비 없이 개발·검증이 돌아갔다.
2차 개발, DCIR 측정 추가 (진행 중)
1차 기능이 자리를 잡고 나서 발주처가 다시 의뢰한 추가 개발이다. 1차가 ACIR·OCV였다면 2차는 DCIR(직류 내부저항)을 잰다. 이름이 비슷해서 같은 일로 보이지만 성격이 다르다.
| 측정 | 무엇을 보나 | 어떻게 얻나 | 단계 |
|---|---|---|---|
| OCV | 개방전압 | 무부하 상태에서 전압을 읽는다 | 1차 (개발 완료) |
| ACIR | 교류 내부저항 | 계측기가 교류 신호를 걸어 직접 잰다 | 1차 (개발 완료) |
| DCIR | 직류 내부저항 | 셀을 정전류로 방전시켜 전압 강하로 산출한다 | 2차 (진행 중) |
앞의 둘은 계측기에 물어보면 값이 나온다. DCIR은 셀을 실제로 방전시켜야 값이 생긴다. R = (V1 − V2) / I, V1은 부하를 걸기 전 전압, V2는 방전이 끝나는 시점의 전압이다. 그래서 신규 장비가 들어온다. 정전류를 뽑아내는 전자로드(Chroma 63000 계열)와, 전압을 정밀하게 읽을 6.5자리 DMM(Keysight 34465A)이다. PLC와 채널 경로는 1차 그대로 재사용한다.
최종 구성은 PLC 1대 + 계측기 2대 + 전자로드 1대의 하이브리드다. 계측기가 둘인 이유가 있다. DMM은 DC 전압계라 ACIR(교류 내부저항)을 못 잰다. 그래서 1차의 계측기를 빼고 갈아끼우는 게 아니라, 측정 종류에 따라 계측기가 갈린다. ACIR·OCV는 기존 계측기가, DCIR의 V1·V2는 새 DMM이 맡는다. 장비를 하나로 통일하는 대신 각자 잘하는 걸 시키는 쪽을 택했다.
| 역할 | 장비 | 담당 |
|---|---|---|
| 채널 선택 | PLC (미쓰비시) | 1차 그대로 |
| ACIR · OCV | 계측기 (Hioki, RS-232C) | 1차 그대로 |
| DCIR 전압 (V1·V2) | DMM (Keysight 34465A, LAN 또는 USB) | 2차 신규 |
| 정전류 방전 | 전자로드 (Chroma 63000 계열, USB) | 2차 신규 |
절대 규칙이 하나 있다. 1차 코드는 건드리지 않는다. 현장에서 확인을 마친 기준선이라, 고치는 대신 얹는다. 통신 검증도 본 앱이 아니라 고립된 미니 스크립트로 먼저 했다. PLC 자동화·MUX·FSM을 전부 빼고 전자로드 통신과 타이밍만 남겨서, 변수를 하나씩만 확인했다.
통신 테스트는 통과했고, 0.3V가 남았다
현장 통신 테스트에서 목표는 전부 통과했다. USB(USBTMC)로 자동 인식, SCPI로 모드·전류·부하 ON/OFF 제어, 측정값 읽기, 셀에 2A 실부하까지. 장비 전면 패널이 Remote·CC·I Set 2.000A로 바뀌는 걸로 양방향 통신을 눈으로 확인했다. 명령과 응답은 전부 원시 문자열로 로깅했다.
문제는 다른 데서 나왔다. 전자로드가 읽는 전압이 계측기보다 0.3V 넘게 낮았다. 어느 쪽이 틀렸는지부터 정해야 해서 제3의 측정기로 교차 확인했고, 계측기가 맞았다.
원인은 고장이 아니라 구조다. 전자로드는 자기 단자에서 전압을 읽는데, 전류가 흐르면 셀과 로드 사이의 배선·접점에서 전압이 깎인다(전류 × 선저항). 그래서 로드가 보는 값은 실제 셀 전압에서 배선 손실을 뺀 값이다. 계측기는 셀 단자 근처에서 4단자(켈빈)로 재기 때문에 그 손실이 빠진다. 전류가 클수록 차이는 커진다. DCIR 목표 전류가 20A대인 걸 생각하면, 2A에서 0.3V는 넘어갈 숫자가 아니었다.
전자로드 한 대가 전류도 걸고 전압도 읽으면 배선도 코드도 단순해진다. 실측이 그 편의를 기각했다. DCIR은 전압 차이로 저항을 만드는 계산이라, 전압에 섞인 배선 손실이 그대로 저항 오차가 된다.
그래서 전압 취득은 1차 때 만들어 둔 경로를 그대로 쓴다. PC가 PLC로 채널을 물리고, 4단자로 붙은 계측기가 V1·V2를 읽고, 전자로드는 전류만 담당한다. 역할을 쪼갠 1차 설계가 옳았다는 걸 실측으로 확인한 셈이고, 덕분에 재사용할 것이 하나 더 늘었다.
계측기는 6.5자리 DMM(Keysight 34465A)으로 간다. 전자로드와 똑같이 USB(USBTMC)라 드라이버 설치 없이 잡히고, 둘 다 같은 방식으로 붙으니 응답 문자열로 누가 로드고 누가 DMM인지 가른다. 전압은 MEAS:VOLT:DC? 한 방이면 자동 범위로 나온다. 이 "한 방이면 나온다"가 나중에 요구 하나를 막는다. 뒤에 다시 나온다.
그래서 채널 하나의 시퀀스는 이렇게 된다. PC가 PLC에 채널을 지정하고 → 계측기로 V1을 읽고 → 전자로드에 정전류 방전을 걸고 → PC가 타이머를 들고 있다가 정해둔 간격마다 전압과 전류를 받아 적고 → 방전이 끝나는 시점의 전압을 V2로 잡아 R을 계산해 그리드에 올리고 → 부하를 끊고 다음 채널로 넘어간다. 타이머를 장비가 아니라 PC가 쥐고 있는 게 핵심이다.
계산에 쓰는 전류도 나중에 한 번 바뀌었다. 처음엔 마지막에 읽은 전류 한 값을 썼는데, 발주처가 방전하는 동안 실제로 흐른 전류의 평균을 원했다. 정전류라고 해도 실전류는 설정값 언저리에서 흔들리고(10A면 ±5mA 수준), 그 차이가 저항값의 소수 자리를 바꾼다. 이미 주기마다 전류를 받아 적고 있었으니 평균을 내는 것 자체는 몇 줄이었다. 기록을 넉넉히 남겨 두면 나중에 요구가 바뀌어도 다시 재러 가지 않아도 된다는 게, 이 프로젝트에서 계속 확인되는 것이다.
측정값을 "한 번만 잡을지, 계속 받아 적을지"는 두 번 뒤집힌 항목이다. 처음엔 방전 끝에 한 번 읽었고, 다음엔 그 시점에서 여러 번 읽어 평균 내는 쪽으로 갔다가, 최종적으로는 방전하는 동안 정해둔 간격마다 계속 기록하는 시계열로 정해졌다. R에 쓰는 V2는 그중 마지막 값이다. 발주처가 원한 건 저항 한 숫자가 아니라 전압이 어떻게 떨어지는지 그 자체였다. 규격을 물어보지 않고 "합리적으로" 정해 뒀으면 세 번 다 틀렸을 자리다.
계획에서 코드로
통신 검증이 끝나고, 미니 스크립트에 있던 드라이버를 본 앱으로 승격했다. 측정 엔진, DCIR 탭, 캘리브레이션 탭, 판정, 결과 저장까지 약 2,300줄이 새로 붙었다.
여기서도 규칙은 하나였다. 1차 파일은 건드리지 않는다. 판정 로직도 1차 것을 고치는 대신 DCIR 전용 함수를 따로 뒀고, 결과 CSV도 1차 파일과 분리했다. 얹기만 하고 빼지 않는다.
엔진은 1차 계측기 엔진과 같은 구조를 그대로 복제했다. 별도 스레드에서 돌고 신호로만 화면과 이야기한다. 다른 점 하나는 안전 처리다. 부하를 거는 장비라서, 어느 경로로 빠져나가든 마지막에 반드시 부하를 끊도록 finally에 넣었다. 예외가 나도, 창을 닫아도, 연결을 끊어도 부하는 먼저 내려간다.
전자로드와 DMM이 둘 다 USB로 붙는 게 오히려 문제였다. 포트만 봐서는 누가 누군지 모른다. 그래서 장비에 이름을 물어보고 응답 문자열로 가른다. 이왕 물어본 김에 화면에도 제네릭한 "DMM"이 아니라 실제 모델명을 띄우게 했다. 현장에서 "지금 뭐가 붙어 있냐"를 눈으로 확인할 수 있는 게 낫다.
현장마다 OCV만 쓰는 곳, DCIR만 쓰는 곳, 둘 다 쓰는 곳이 갈린다. 앱 하나로 셋을 다 덮으려면 어딘가에서 갈라야 하는데, 코드에서 갈면 현장 수만큼 분기가 늘어난다.
설정 파일의 플래그 하나로 사이드바와 탭 노출을 가르는 쪽으로 갔다. 인스톨러가 설치 마법사에서 고른 구성을 그 플래그에 기록하고, 앱은 켜질 때 읽어서 필요한 탭만 만든다. 플래그가 없으면 전부 노출로 떨어지게 해서, 이미 나가 있는 배포분은 아무것도 달라지지 않는다.
덕분에 납품물은 계속 setup 파일 하나다. 설치할 때 체크박스만 다르게 고른다.
그 외에 재사용이 한 번 더 통했다. 1차의 채널 그리드를 DCIR에도 그대로 쓰되, 채널 수가 달라도 정사각에 가깝게 열을 자동으로 잡도록만 손봤다. 현장마다 트레이를 읽는 방향이 달라서, 채널을 가로로 채울지 세로로 채울지도 설정에서 고르게 했다. Mock도 그대로 따라왔다. 장비 없이 띄우면 DCIR도 가짜 장비에 붙어서 흐름을 끝까지 돌려볼 수 있다.
실장비 앞에서, R이 허수로 나왔다
장비를 실제로 물리고 첫 DCIR을 재던 날, 저항이 말이 안 되는 값으로 나왔다. 코드를 열기 전에 원시 로그부터 봤다. 계측기만 셀에 붙였을 때는 계측기가 3.27V를 읽는데 전류가 0이었고, 전자로드만 붙였을 때는 5A가 흐르는데 계측기 전압이 0이었다. 소프트웨어 문제가 아니었다. 전류를 흘리는 장비와 전압을 재는 장비를 셀에 동시에 물리지 못한 상태였던 것이다.
DCIR은 전류를 흘리는 그 순간의 전압 강하를 재는 측정이라, 둘 중 하나만 붙어 있으면 애초에 성립하지 않는다. 셀의 +/− 각 단자에 로드의 전류 리드와 계측기의 전압 리드를 함께 무는 4단자 배선이 필요했다. 앞에서 0.3V 차이를 보고 "전압은 계측기로 읽는다"고 정해 둔 것의 물리적 대가를 여기서 치른 셈이다.
통신 쪽은 전부 통과했다. 두 장비 모두 USB로 인식됐고, 모드·전류·부하 제어와 값 읽기가 다 정상이었고, 셀에 5A가 실제로 흘렀다. 남은 건 배선 하나였다. 코드를 고치러 가지 않고 로그로 물리 원인을 특정한 것이, 1차에서 EMG 래치로 배운 것과 정확히 같은 절차였다.
자동으로 도는 DCIR
1차의 검사 탭이 Auto와 Manual로 갈려 있듯, DCIR에도 자동 순회 탭을 붙였다. 설정한 채널 범위를 릴레이로 하나씩 옮겨 가며 측정하고, 결과를 그리드와 카운터에 쌓고, 끝나면 CSV로 떨군다. 상태머신은 1차 것을 재사용하지 않고 따로 뒀다. 1차는 지그를 눌렀다 떼는 것까지 포함하는 사이클이지만, 이쪽은 지그를 사람이 잡고 릴레이로 채널만 옮기는 훨씬 짧은 루프라서다.
여기서 하나 조심할 것이 있었다. 측정 엔진을 자동 탭과 수동 탭이 같이 쓰는데, 측정이 별도 스레드에서 비동기로 끝나기 때문에 결과 신호가 양쪽에 다 도착한다. 자기가 시킨 측정만 처리하도록 대기 표시를 두고 걸렀다. 1차에서 신호 체인 때문에 한참 헤맸던 경험이 그대로 쓰였다.
릴레이를 실제로 어떻게 물리는지가 그때는 확정 전이었다. 그래서 채널을 바꾸는 동작을 함수 하나에 몰아 뒀다. 확정되지 않은 것을 코드 여기저기에 흩뿌리지 않는 게 그 단계에서 할 수 있는 최선이었다.
얼마 뒤 발주처에서 방식이 내려왔고, 1차에서 쓰던 펄스 방식이 아니라 채널 번호를 PLC의 한 주소에 써 넣으면 넘어가는 명령 방식이었다. 짐작으로 채워 뒀던 것과 다른 답이 온 것이다. 고친 곳은 그 함수 한 줄이었다. 순회 로직도, 탭도, 측정 엔진도 그대로였다.
그리고 현장에서 그 답이 틀렸다.
장비를 물리고 자동 순회를 돌렸더니 첫 채널만 전류가 흐르고 나머지는 흐르지 않았다. 주소에 쓴 값은 성공으로 돌아오고, 되읽어도 그 값이 그대로 들어 있는데, 릴레이가 안 물었다. 소프트웨어가 보는 세계에서는 전부 정상인데 물리적으로는 아무 일도 일어나지 않은 것이다. 그래서 1차가 실제로 쓰고 있던 방식으로 되돌렸다. 채널 번호를 기록하고, 잠깐 기다렸다가, 신호를 올렸다 내리는 핸드셰이크다. 수년째 그 라인에서 돌던 코드가 답이었던 셈이다.
이번엔 한 줄이 아니었다. 펄스는 올림과 내림이 시간으로 갈라지니 함수 세 개가 됐다. 그래도 순회 로직과 탭과 측정 엔진은 이번에도 손대지 않았다. 모르는 것을 한곳에 가둬 두는 것의 값은, 답이 한 번에 맞는 데 있는 게 아니라 답이 두 번 바뀌어도 흔들리는 범위가 그대로인 데 있었다. 그리고 쓴 값과 되읽은 값을 로그로 띄우게 해 뒀다. 다음에 또 안 물면, 명령이 안 들어간 건지 들어갔는데 안 물린 건지를 화면에서 바로 가르기 위해서다.
그리고 고객사가 직접 써 본 날, 그 되돌린 방식도 안 돌았다.
보고는 한 줄이었다. 측정할 때 릴레이 전환 소리가 나지 않아 측정값이 정상인지 확인이 필요하다는 것. 릴레이가 안 물리면 그 뒤에 붙은 요구 네 건이 통째로 검증 불가가 된다. 측정이 돌지 않으면 타이밍도 로우데이터도 수집할 방법이 없다.
이번에는 현장에 가기 전에 코드와 과거 실측 기록을 먼저 대조했다. 원인이 둘 나왔고, 둘 다 내가 심은 것이었다. 근거까지 붙였다. 그리고 둘 다 틀렸다. 어쩌다 틀렸는지가 이 프로젝트에서 가장 비싸게 배운 것이라, 틀린 분석을 지우지 않고 그대로 두고 그 뒤에 적는다.
첫째, PLC를 동작 가능한 상태로 올리지 않고 있었다. 1차 검사 사이클은 릴레이를 돌리기 전에 스무 개 주소를 한 번에 써서 "PC가 살아 있고 자동 모드"라는 것을 PLC에 알린다. DCIR 순회는 채널 번호와 펄스, 네 줄만 보내고 있었다. 그러면 안 된다는 것은 3월 실측 기록에 이미 적혀 있었다. 이 세 비트 없이 명령을 보내면 PLC가 무시한다고. 래더는 명령을 받은 게 아니라 버린 것이었고, 고객사가 들은 침묵과 정확히 맞는다.
둘째, 릴레이를 주소로 착각했다. 실제 동작은 펄스 한 번에 한 칸 전진이고, 채널 번호를 쓰는 주소는 표시용이다. 이것도 4월 실측 기록에 있다. "채널 번호를 쓰고 한 번 펄스"로 만들어 둔 구현은 1번부터 순서대로 전부 돌 때만 우연히 맞는다. 임의 구간부터 시작하면 릴레이는 그 채널이 아니라 첫 칸에 서 있다. 바로 전에 넣은 "시작 지점" 기능이 애초에 성립하지 않았다는 뜻이다. 1차가 이 함정을 안 밟은 이유는 설계가 좋아서가 아니라 늘 1번부터 전체를 돌기 때문이었다.
고친 방식은 이렇다. 순회를 시작할 때 PLC 상태를 올리고 끝나면 내린다. 릴레이 위치는 앱이 0부터 세면서 목표 채널까지 필요한 횟수만큼 펄스한다. 덤으로 같은 채널을 두 번 불러도 더 전진하지 않는다. 예전에 1차에서 일시정지 후 재개하면 릴레이가 앞서가던 사고가, 이 구조에서는 구조적으로 안 난다.
아픈 쪽은 원인 자체가 아니다. 두 원인의 답이 둘 다 과거 실측 기록에 적혀 있었다. 릴레이 전환 함수 하나만 보고 옮겼고, 그 함수가 기대고 있던 선행 상태는 다른 함수에 있어서 따라오지 않았다. "관련 파일을 전부 읽고 기능을 완전히 추출한 뒤 구현한다"는 규칙은 예전에 PLC 기능 누락으로 한 번 배워서 적어 둔 것인데, 같은 자리를 또 밟았다. 그래서 이번엔 점검 항목을 네 줄로 적어 뒀다. 그 함수를 부르기 전에 세팅돼 있어야 하는 상태가 있는가. 주소가 절대값인가 펄스인가. 읽기 전용에 쓰고 있지는 않은가. 상승 엣지로 먹는 신호인데 직전 값이 남아 엣지가 죽지는 않는가.
이 항목은 여기까지 네 번 바뀌었다. 지금 버전도 가짜 PLC로 쓰기 순서를 전부 받아 적어 대조한 것까지이고, 진짜 판정은 현장에서 소리가 나는지다. 소리가 안 날 경우를 대비해 지그 신호를 함께 보내는 스위치를 설정에 빼 뒀고, 어떤 방식에서 소리가 나는지 한 번에 가릴 점검 기능도 화면에 붙였다. 고객사가 설정 파일을 손으로 고치게 할 수는 없어서다.
그래서 현장에 갔다
점검 기능을 돌릴 필요는 없었다. 관리자 화면에서 채널 번호를 그 주소에 직접 써 보니 딸깍 소리가 났다. 두 번째 방식, 발주처가 처음에 알려준 그 방식이다.
그 방식을 되돌려 넣고 자동 순회를 돌렸다. 네 채널이 차례로 넘어가고, 채널마다 전압이 떨어지고, 저항이 값으로 나왔다. 네 번을 다시 돌려도 같았다. 계측기가 읽은 전압과 전자로드가 읽은 전압의 차이까지 보여서, 앞에서 4단자 배선을 고집한 이유도 숫자로 확인됐다.
즉 처음부터 답은 맞았고, 내가 깨뜨렸다.
앞의 "첫 채널만 전류가 흘렀다"는 그 날, 전환 명령은 정상으로 들어가고 있었다. 전류가 안 흐른 건 다른 문제였다(그게 무엇이었는지는 지금도 모른다. 같은 설비에서 지금은 네 채널 모두 흐른다). 나는 그 증상을 보고 근거 없이 "전환 방식이 틀렸다"고 단정하고 코드를 바꿨다. 그 코드가 그대로 커밋돼 나갔고, 고객사가 들은 침묵이 그것이었다. 그리고 그 뒤에 쓴 "원인 두 건" 분석은 내가 만든 고장을 설명하려고 쌓은 것이었다.
더 아픈 쪽은 그 분석이 그럴듯했던 이유다. 두 원인 모두 과거 실측 기록에 근거가 있었다. 기록은 실재했고 인용도 정확했다. 틀린 건 하나였다. 그 기록이 다른 설비의 것이었다. 이 용역에는 설비가 둘 있다. 1차 OCV·ACIR을 재는 트레이 설비와, 2차 DCIR을 재는 릴레이 설비다. 둘은 PLC 래더가 다르다. 한쪽에서 검증된 주소와 핸드셰이크를 다른 쪽에 옮기면, 근거가 있는데도 틀린다. 릴레이가 다섯 번 바뀐 것의 대부분이 이 한 가지에서 나왔다.
그래서 규칙 하나가 바뀌었다. "확인 전에 단정하지 않는다"는 원래 가지고 있었다. 모자랐다. "근거가 있다"와 "근거가 이 대상의 것이다"는 다른 검사다. 근거를 붙였기 때문에 오히려 더 쉽게 단정이 됐다.
틀린 분석 위에 세운 것들은 걷어냈다. 설정 스위치 두 개, 릴레이 점검 화면, 상대 전진 계산까지 전부 지웠다. 10-07에 고객사로 보낸 원인 설명도 "잘못된 분석이었습니다"로 정정해 다시 보냈다. 틀린 설명을 남겨 두면 다음 담당자가 그걸 근거로 또 판단한다. 내부 문서 세 개에도 머리말에 정정·폐기 표시를 달아 뒀다. 기록으로는 남기고, 근거로는 못 쓰게.
다만 하나는 안 지웠다. PLC를 동작 가능 상태로 올리는 선행 전송이다. 현장에서 돌아간 조합이 "그 선행 상태 + 채널 주소 지정"이라, 선행 상태가 필요했는지 아닌지는 아직 안 갈렸다. 없어도 되는데 무해한 것일 수도 있다. "원인이었다"는 거짓이고 "필요 없다"는 미확인이다. 둘을 구분해서 적어 두는 것 말고는 지금 할 게 없다.
고칠 자리는 다시 함수 하나로 돌아왔다. 한 개에서 세 개로, 세 개에 호출 지점 두 곳까지 늘었다가, 되돌아오니 다시 한 줄이다. 다섯 번 바뀌는 동안 측정 엔진과 탭과 순회 로직은 한 번도 안 바뀌었다. 격리해 둔 값은 그대로 받았다. 그런데 이번에 알게 된 게 하나 더 있다. 격리는 틀린 방향으로 바꾸는 비용도 같이 낮춘다. 고칠 자리가 한 줄이었기 때문에 근거 없이도 쉽게 바뀌었다. 값싼 변경일수록 왜 바꾸는지를 물어야 한다.
채널을 넘기는 신호가 PLC로 나가니, 자동 순회의 전제도 PLC 연결이 됐다. 전자로드와 DMM 옆에 PLC 연결 표시등을 하나 더 붙였다. 현장에서 순회가 안 돌 때 "장비가 안 붙었나, 채널이 안 넘어가나"를 화면에서 먼저 가를 수 있어야 해서다. 같은 이유로 현장 절차서도 따로 썼다. 순서대로 따라 하는 법, 안 될 때 원시 로그 세 줄로 원인을 가르는 법, 그리고 자동 순회가 끝내 안 맞으면 수동 측정으로 채널을 하나씩 도는 플랜 B까지.
현장 PC에 올리기
여기까지는 전부 내 맥에서 돌던 이야기다. 실제로는 현장 윈도우 PC에 올려야 하고, 거기서 걸린 것들은 코드 설계와는 다른 종류였다.
윈도우에서 USB 계측기는 시스템에 VISA 라이브러리가 깔려 있어야 보인다. 파이썬 쪽 순수 구현만으로는 목록에 아예 안 뜬다. 반대로 맥에서는 그 시스템 VISA가 없다. 그래서 설치된 VISA를 먼저 열어 보고, 실제로 장치 목록을 받아오는 것까지 확인한 뒤, 안 되면 파이썬 구현으로 내려가게 했다. "있으면 쓴다"가 아니라 "열어서 동작까지 확인하고 쓴다"로 만든 건, 깔려 있는데 동작하지 않는 경우가 실제로 있어서다.
PLC 브릿지는 더 사소한 이유로 안 붙었다. 32비트 파이썬을 찾는 코드에 특정 계정명이 박힌 경로가 후보로 들어 있었다. 계정명이 다른 PC에서는 당연히 못 찾는다. 지금은 현재 로그인한 계정의 경로를 먼저 뒤진다. 다른 컴퓨터에서 돌 코드에 특정 사용자의 홈 디렉터리를 적어 두면 안 된다는, 배울 필요도 없었어야 할 것을 배웠다.
그렇게 해서 현장 PC에 로드와 계측기와 PLC 세 대가 전부 붙었다. 연결 표시등 세 개가 초록으로 켜지고 PLC 통신 테스트까지 통과한 날이, 2차가 처음으로 "장비 세 대와 동시에 손을 잡은" 날이다.
스물네 건을 받았다
발주처와 회의를 하고 요구사항 스물네 건을 받았다. 정리하면서 하나하나 소스 코드를 열어 근거를 붙였다. 이미 되어 있는 것, 뼈대는 있고 배선만 하면 되는 것, 새로 만들 것, 현장에서 재 봐야 아는 것, 발주처가 방법을 정해 줘야 하는 것으로 갈랐다. 못 하겠다고 답한 건 없었다.
그중 한 건은 코드를 확인하는 것만으로 끝났다. "방전이 끝날 때 전압값을 받은 뒤에 전류를 끊어 달라, 먼저 끊으면 그 값이 오염된다"는 요구였는데, 열어 보니 마지막 전압 읽기가 값을 돌려받은 다음에 부하를 내리고 있었다. 바꿀 게 없었다. 회의에서 "됩니다"라고 답하지 않고 파일을 열어 확인한 뒤 근거를 적어 회신한 게 이 항목의 전부이고, 스물네 건 중 가장 싸게 끝난 건이기도 하다.
나머지는 대체로 이런 것들이었다. 화면과 결과 파일의 단위와 소수 자릿수를 고르게 하고(반올림이 아니라 버림이다, 검사값을 올려 주면 안 된다), 측정 설정 입력을 밀리 단위로 통일하고, 채널 수가 적어 칸이 커지면 글씨도 같이 커지게 하고, 그리드 칸에 뭘 띄울지 고르게 하고(저항은 무조건 띄운다), 전류를 끊고 최소 100밀리초 뒤에 채널을 넘기고, 비상정지가 걸리면 DCIR 순회도 같이 서게 했다.
단위 작업은 안쪽을 건드리지 않는 쪽으로 갔다. 코드가 쓰는 단위는 그대로 두고 보여줄 때와 파일에 쓸 때만 변환하는 층을 따로 뒀다. 밀리 단위 입력도 화면에서만 밀리로 받고 경계에서 되돌린다. 그래서 설정도, 엔진도, 계산도 이 요구를 모른다. 표시를 바꿔 달라는 요구에 계산이 흔들리면 그건 다른 버그를 만드는 일이다.
전류 레인지 항목은 아예 화면에서 없앴다. 낮은 레인지가 큰 전류에서 이상 동작하는 걸 발주처가 확인해서, 자동으로 고정하고 선택지를 치웠다. 앞서 측정 종류 선택을 없앤 것과 같은 판단이다. 고를 수 있게 두면 언젠가 잘못 고른다. 쓰는 사람이 정할 일이 아닌 건 화면에 두지 않는 게 낫다.
남은 네 건은 그다음에 붙였다. 안전 조건은 전류 상한과 하한, 전압 하한을 넣어 두면 방전하는 동안 샘플마다 검사해서, 벗어나는 순간 부하를 끊고 그 채널을 NG로 떨어뜨리고 다음 채널로 넘어간다. 0으로 두면 쓰지 않는다. 셀을 실제로 방전시키는 측정이라 "이상하면 즉시 멈춘다"는 장치가 어디엔가 있어야 했다.
결과 파일은 두 종류가 됐다. 판정값만 담은 결과 파일과, 방전하는 동안 받아 적은 전압·전류를 시간순으로 길게 늘어놓은 원시 파일이다. 원시 파일은 자동 생성을 켜고 끌 수 있고, 원할 때 CSV나 엑셀로 뽑는 버튼을 따로 뒀다. 저장 위치도 고를 수 있게 했다. 현장 PC의 폴더 구조는 내가 정할 일이 아니다. 같이 데이터 탭에 DCIR 결과를 조회하는 자리를 만들고, 흩어져 있던 DCIR 로그를 기존 통합 로그에 합류시켰다. 요구가 "로그는 하나로"였다.
마지막은 측정 설정을 기억하는 것이다. 전류와 방전시간, 저장 간격, 안전 조건은 한 번 정하면 계속 같은 값을 쓰는데, 팝업을 열 때마다 처음부터 입력하고 있었다. 적용할 때 기억해 두고 다음에 그 값으로 열리게 했다. 단 시작 지점만은 항상 0으로 둔다. 그건 "이어서 하기"용이라 기억하면 위험하다. 그리고 같은 값을 설정 탭에서도 고칠 수 있던 걸 걷어냈다. 한 값을 두 화면에서 고치게 두면 어느 쪽이 진짜인지 아무도 모른다.
고객사가 직접 써 봤다
거기까지 만들어 보냈고, 고객사가 실제로 돌려 봤다. 돌아온 목록의 맨 위가 릴레이였고(위에서 쓴 그 건이다), 그 아래에 화면과 저장 쪽 세 건이 더 있었다.
안전 조건 입력칸이 아래 그리드에 가려지고 스크롤도 안 됐다. 만들어 넣은 기능을 쓸 수가 없었다는 뜻이다. 원인은 팝업이 요구하는 최소 높이가 창의 최소 높이보다 커서, Qt가 자식들을 겹쳐 놓고 있던 것이었다. 창을 더 키우는 길은 막혀 있었다. 예전에 그 방향으로 가서 작은 노트북에서 팝업이 화면보다 커진 적이 있다. 그래서 상단 폼 자체를 스크롤되는 영역에 넣어 요구하는 최소 높이를 3분의 1 아래로 내렸고, 창 크기는 하드코딩이 아니라 화면 가용 영역에서 깎아 정하게 했다.
채널 수가 일흔 개를 넘으면 글자가 잘렸다. 바로 전에 "칸이 커지면 글씨도 커지게" 만든 그 코드다. 범인은 글씨 크기 하한이 아니라 공간 분배였다. 칸 안에 세 줄이 들어가는데 세로 공간을 줄 수와 무관하게 균등히 나눠서, 세 줄짜리 라벨이 한 줄 몫만 받고 있었다. 필요한 높이 비율로 나누도록 고치고, 그래도 안 들어갈 때만 더 작게 내려가게 했다. 그리고 글자 크기를 계수로 계산하던 것을 실제 폰트 측정값에서 받도록 바꿨다. 포인트에서 픽셀로 가는 환산은 폰트와 화면 배율에 따라 달라서 선형이 아니다. 계수를 박아 두면 한쪽 플랫폼에서 잘린다.
세 번째는 결과 자동 저장이 설정 불가로 보인다는 것이었다. 열어 보니 수동 탭에는 있고 자동 순회 탭에만 배선이 빠져 있었다. 기능이 없는 게 아니라 쓰는 화면에서 닿지 않았다. 켜고 끄는 항목을 만들고 원시 파일 쪽과 나란히 놓았다.
그리고 이 세 건을 보다가 공통 배경 하나를 알게 됐다. 구동 PC는 윈도우고 내 검증은 맥에서 한다. Qt는 OS마다 기본 스타일이 달라서 위젯 여백과 컨트롤 높이, 스크롤바 폭이 다르고, 윈도우는 화면 배율을 125~150%로 쓰는 경우가 흔하다. 맥에서 픽셀을 맞춰도 윈도우에서 어긋난다. 그래서 앱 전체를 Qt가 직접 그리는 스타일로 고정하고, 맥과 윈도우에서 다르게 쓰던 기본 글자 크기도 하나로 통일했다.
그 과정에서 더 아픈 걸 알았다. 내가 화면을 확인하던 오프스크린 환경의 기본 스타일이 원래 그 고정 스타일이었다. 즉 그때까지 내가 보던 화면과 고객사가 보던 화면은 처음부터 다른 스타일이었다. 이번 변경으로 검증 환경과 구동 환경이 처음으로 같아졌다. 합격 기준도 "맥에서 잘 보인다"에서 **"글자가 더 크거나 배율이 높아도 안 잘린다"**로 바꿨다.
다만 여기서 선을 그어야 한다. 조건은 오프스크린에서 재현했지만 윈도우 실기에서는 아직 못 돌려 봤다. 화면 배율은 오프스크린으로 완전히 재현되지 않는다. 그리고 남은 한계도 하나 적어 둔다. 채널이 가장 많고 표시 항목을 전부 켜고 화면이 가장 작은 조합은 글자를 최소까지 줄여도 1픽셀이 모자란다. 표시 항목을 하나 줄이면 들어간다. 더 작게 내리는 선택지도 있었지만 읽을 수 없는 숫자는 띄워도 의미가 없다.
하루에 서른 건
릴레이가 풀리고 측정이 돌기 시작하자, 그다음부터가 쏟아졌다. 그날 현장에서 적은 발견이 서른네 건이다. 체크리스트 여든아홉 줄 중 통과가 스물일곱, 추가 작업이 서른일곱, 담당자 확인이 열셋.
릴레이에 막혀 있던 동안 아무도 그 뒤를 못 보고 있었던 것이다. 하나가 막으면 그 뒤의 전부가 "구현은 됐지만 검증은 안 된" 상태로 쌓이고, 막힌 게 풀리는 순간 한꺼번에 나온다. 그래서 그날은 고치는 날이 아니라 적는 날로 쓰기로 했다. 화면을 보고 바로 손대기 시작하면 설비를 쓸 수 있는 시간을 코드 고치는 데 다 쓴다. 측정을 막는 것만 그 자리에서 교체하고 나머지는 번호를 붙여 적고 돌아왔다.
적어 온 서른네 건 중 네 묶음이 특히 그럴듯한 교훈이었다.
첫째, 방전 5초가 실제로는 7초였다. 저장 간격을 100밀리초로 주면 2초 방전이 9초가 됐다. 고객사가 10월 1일에 "방전 2초인데 체감상 더 오래 걸린다"고 말한 게 이거였다. 원인은 간단했다. 간격만큼 쉬고 나서 장비에 전압을 물어보는 구조라, 질의에 걸리는 시간이 샘플마다 쌓였다.
문제는 왜 그게 지금까지 안 보였는지다. 원시 파일에 적던 시각이 실제 시각이 아니라 예정 시각이었다. 1초 간격이면 1초, 2초, 3초라고 적는다. 실제로는 1.4초, 2.8초, 4.2초에 읽었는데도. 계측 결과를 예정값으로 적어 두면, 계측이 자기 결함을 가린다. 로그만 보고 있는 동안 아무 문제가 없었다.
고친 방식은 "간격만큼 쉰다"가 아니라 **"처음 시각 기준으로 k번째 샘플은 k×간격 지점에 찍는다"**로 바꾼 것이다. 질의가 간격을 못 따라가면 한 번 경고하고, 끝 시점을 밀어낼 중간 샘플은 건너뛴다. 총 방전 시간이 설정값을 넘지 않는 쪽을 우선한다. 저항에 쓰는 끝 시점 전압은 절대 건너뛰지 않는다. 그리고 파일에 적는 시각을 실제 시각으로 바꿨다.
둘째, 계측기 설정 한 줄이 요구 하나를 막고 있었다. 100밀리초마다 저장해 달라는 요구가 있었는데, 전압 한 번 읽는 데 347밀리초가 걸렸다. 애초에 불가능한 요구였다. 원인은 앞에서 "한 방이면 나온다"고 좋아했던 그 명령이었다. 그 명령은 부를 때마다 측정 설정을 처음부터 다시 잡는다. 편한 대신 느렸다.
현장에서 측정 분해능을 단계별로 바꿔 가며 읽기 시간과 노이즈를 재 봤다. 분해능을 낮추면 빨라지고 노이즈가 올라가는 교환인데, 한 단계에서 열 배 빠르면서 노이즈도 더 낮았다. 자동 범위를 매번 다시 잡는 비용이 그만큼 컸던 것이다. 셀 전압은 범위가 뻔하니 고정해도 된다. 그래서 연결할 때 한 번만 설정하고 이후에는 읽기 명령만 보내는 쪽으로 바꿨다. 36밀리초. 발주처가 그 분해능으로 확정했다.
이걸 설정 항목으로 빼지 않고 코드에 박았다. 쓰는 사람이 정할 일이 아니고, 고를 수 있게 두면 언젠가 잘못 고른다.
셋째, 셀이 없는 채널이 저항값을 저장하고 있었다. 빈 채널에 전류가 거의 안 흐르니, 전압 차를 거의 0인 전류로 나눠서 백만 밀리옴이 나왔다. 판정 상한이 딱 백만이라 그 위로 아슬아슬하게 올라가 OPEN으로도 안 걸렸다. 저항 범위로 빈 채널을 가리려 한 게 틀렸다. 전류로 가려야 했다. 방전 중 실제 흐른 전류의 평균이 설정값의 10퍼센트에도 못 미치면 저항을 내지 않고 OPEN으로 낸다. 1차에서 미장착 검출을 오버레인지로 하던 것과 같은 자리인데, DCIR은 전류를 걸어 보는 측정이라 더 직접적인 증거가 손에 있었다.
넷째, 자동 탭과 수동 탭이 결과를 따로 들고 있었다. 이게 발견 다섯 건의 공통 원인이었다. 자동으로 네 채널을 돌린 뒤 수동으로 한 채널만 다시 재면 자동 결과에 안 합쳐지고 별 파일로 갈렸다. 결과 파일의 순번 열이 순번이 아니라 채널 종류 수를 세고 있었다. 한쪽에서 초기화해도 반대쪽 그리드가 안 지워졌다. 수동 탭은 채널 전환 명령을 아예 안 보내고 있어서, 릴레이가 붙어 있던 다른 셀을 재고 기록만 고른 채널로 남겼다. 이건 그냥 틀린 값이다.
증상이 다섯 갈래로 보였지만 원인은 하나였다. 두 화면이 같은 것을 각자 들고 있었다. 두 탭이 함께 쓰는 세션 객체를 하나 만들어 그리로 모았다. 채널당 최신 한 건을 유지하고, 순번은 저장할 때마다 하나씩 늘고, 어느 쪽에서 초기화해도 양쪽이 지워진다. 증상이 여러 갈래로 보일 때 원인이 하나인 쪽을 먼저 의심하는 게 대체로 싸다.
그리고 실행이 드디어 한 번에 된다. 앞에서 "브릿지를 먼저 띄우고 앱을 켜는 두 단계"라고 적어 둔 그 건인데, 원인이 반대였다. 자동 기동이 실패한 게 아니라 너무 잘 되고 있었다. 앱이 떠 있는 브릿지를 확인하지 않고 매번 자기 브릿지를 또 띄웠고, 포트를 공유하는 옵션이 켜져 있어서 윈도우에서는 두 브릿지가 같은 포트를 같이 잡을 수 있었다. 그래서 손으로 띄운 멀쩡한 브릿지가 있어도 앱은 자기가 띄운, 통신 라이브러리를 못 여는 쪽에 붙었다. "수동으로 띄우면 정상"이라는 관찰이 오래 오진을 붙들고 있었다. 정상으로 보인 쪽은 실제로 안 쓰이고 있었다.
지금은 먼저 포트에 신호를 보내 보고, 응답하는 브릿지가 있으면 그걸 쓰고 없을 때만 띄운다. 포트 공유 옵션은 지웠고, 띄운 브릿지의 출력은 삼키지 말고 파일에 남긴다. 좀비 브릿지는 일부러 죽이지 않는다. 죽이면 통신 라이브러리 쪽 잠금이 남아서 더 나빠진다. 실행 배치 파일도 다시 썼다. 남은 프로세스를 정리하고, 32비트 브릿지를 별 창으로 띄우고, 2초 기다리고, 64비트 앱을 켠다. 현장 PC의 기본 파이썬이 3.4라서 어디서도 그냥 python을 부르지 않는다. 전부 전체 경로다.
그날 하나는 만든 것을 되돌렸다. 10월 1일 보고서에 캘리브레이션 항목이 한 줄 있었다. 화살표 하나와 짧은 메모. 나는 그걸 "교류 저항과 개방전압의 보정을 따로 하라"로 읽고, 보정값을 두 종류로 쪼개서 따로 저장하고 판정에도 반영하게 만들었다. 1차 판정 코드를 건드리는 일이라 수정 전 판정 함수를 얼려 두고 십만 건 넘게 대조해 가며 넣었다.
현장에서 물어보니 탭 이름을 바꿔 달라는 뜻이었다. 캘리브레이션 탭이 하나뿐인데 DCIR 캘리브레이션이 새로 생겨서, 어느 게 어느 건지 구분이 안 됐던 것이다. 그래서 분리한 기능을 전부 걷어내고 이름만 바꿨다. 되돌린 뒤 얼려 둔 판정 함수와 다시 전수 대조해서, 1차 판정이 손대기 전과 완전히 같다는 걸 확인하고 끝냈다.
웃긴 건, 이 건에서 제대로 한 게 되돌릴 수 있게 만들어 둔 것뿐이라는 점이다. 요구를 잘못 읽은 건 못 막았다. 한 줄짜리 메모를 받았을 때 "이렇게 이해했습니다"를 한 번 되묻는 비용이, 판정 코드를 건드리고 십만 건을 대조하고 다시 되돌리는 비용보다 훨씬 쌌다. 애매한 한 줄은 구현하기 전에 물어봐야 한다. 다만 1차 코드를 건드릴 때 기준선을 얼려 두는 습관이 없었다면, 되돌린 뒤에 "진짜 원래대로인가"를 증명할 방법이 없었다. 그 대조 스크립트는 저장소에 남겨 뒀다.
이 다섯 묶음과 실행 수정은 전부 가짜 장비에서만 확인했다. 고친 것만 모은 재검증 목록 마흔여덟 줄을 따로 썼고, 그걸 들고 다시 설비 앞에 서는 게 다음 일이다.
실장비로 저항을 잰 건 이제 됐다. 채널 전환, 네 채널 측정, 4단자 배선, 전압값을 받은 뒤 전류를 끊는 순서, 인가 명령과 전압 수신에 걸리는 시간까지 설비 앞에서 확인했다. 두 달 넘게 밀려 있던 "구현은 됐고 검증만 못 한" 묶음이 풀렸다.
남은 건 성질이 바뀌었다. "돌아가는가"가 아니라 **"고친 것이 그대로인가"**다. 그날 나온 서른네 건의 수정은 전부 가짜 장비에서만 확인했고, 재검증 목록 마흔여덟 줄은 아직 안 돌렸다. 100밀리초 주기 저장이 분해능을 바꾼 버전에서 실제로 되는지, 방전 시간이 설정값과 맞는지, 실행이 한 번에 켜지는지가 거기 들어 있다.
발주처 답을 기다리는 것도 있다. 가장 큰 건 판정 기준값이다. 셀 저항의 상한과 하한을 아직 못 받았다. 재는 것은 되는데 OK/NG를 매기는 기준이 없다. DCIR 캘리브레이션 방법도 여전히 미정이다. 측정 반복성은 셀을 쉬게 한 뒤 다시 재야 판단할 수 있고, 한 채널에서 본 회차별 편차는 셀 체결이나 프로브 접촉 쪽을 먼저 봐야 한다.
그대로 남은 것들: 화면은 윈도우 실기와 화면 배율에서 아직 못 돌려 봤다. 실행은 배치 파일 더블클릭 한 번으로 모였지만 설치 파일(exe)로 묶는 건 여전히 없다. 방전 전류와 시간 같은 값은 현장 확정 대기다. 트레이 실장비가 들어오면 1차와 2차를 전부 다시 테스트한다.
기술 스택
- UI/앱: Python · PySide6 · 자체 제작 산업 위젯(QPainter · QSS)
- PLC: 미쓰비시 MX Component COM(pywin32) + 32/64비트 TCP 브릿지
- 계측/스캐너: Hioki 계측기 · 바코드 스캐너 (RS-232C, pyserial)
- 2차(DCIR): 전자로드 Chroma 63000 계열 · DMM Keysight 34465A (USB · USBTMC · SCPI, pyvisa)
- 구조: 11-state FSM · QThread + Signal/Slot 명령 큐 · dataclass 도메인 모델 · Mock 엔진
- 환경: Windows + 32비트 Python + MX Component · zip + 배치 파일 배포(브릿지와 앱을 순서대로 띄우는 한 번 실행). PyInstaller + Inno Setup 인스톨러 스크립트는 작성돼 있고 설치 파일로 묶는 단계는 미완
- 화면 일관성: 위젯 스타일을 Qt가 직접 그리는 쪽으로 고정하고 기본 글자 크기를 OS 간에 통일(구동은 윈도우, 개발·검증은 맥)
- 계측 성능: DMM 측정 설정을 연결할 때 한 번만 잡고 이후엔 읽기 명령만 보낸다(전압 읽기 347밀리초 → 36밀리초. 100밀리초 주기 저장의 전제)
왜 이 스택
- PySide6: 낡은 MFC(C++) 검사 시스템을 대체하는 용역이었다. C++은 이어 쓰기 어렵고 벤더에 종속돼 있어 Python으로 전면 재작성했다. 1인이 이 규모(1차 기준 약 15,000줄)를 현장 납품까지 끌고 가려면 Python의 생산성이 필요했고, 산업 HMI 위젯은 QPainter·QSS로 자체 제작했다.
- MX Component (PLC 통신): 미쓰비시 PLC의 표준 통신 인터페이스라, 지그·계측기를 제어하려면 이걸 통해야 한다.
- 32/64비트 브릿지: MX Component가 32비트 전용이라 64비트 PySide6 앱에서 바로 못 부른다. 그래서 32비트 파이썬 서브프로세스를 띄워 로컬 TCP로 우회했다. 우아하진 않지만 벤더 라이브러리 제약을 실무에서 넘는 방법이었다.
- pyserial: 계측기·스캐너가 RS-232C 시리얼이라 pyserial로 통신했다.
Trouble Shooting
이 프로젝트의 트러블은 대부분 실장비 앞에서 로그로 잡은 것들이다.
- 특정 채널부터 오버레인지가 떴다. Start 시 PLC 상태 초기화가 부족해 상태가 오염됐다. 앱 재시작으로도 복구가 안 되고 레거시 클리어로만 풀려서, Start 전에 관련 주소를 일괄 0으로 밀고 에러리셋을 거는 것으로 해결했다.
- 비상정지가 안 잡혔다. 추측 수정 대신 디버그 덤프로 "실제로 변하는 주소"를 현장에서 확정해 감시 대상에 반영했다.
- 에러리셋이 안 풀렸다. ACK 핸드셰이크와 3초 강제 타임아웃으로 방어했다.
- Qt UI가 자꾸 번복됐다. HTML 목업을 먼저 만들어 "이 수치 그대로 Qt에 옮긴다"를 규칙으로 못 박아 재발을 막았다.
- 레거시 이식 누락도 있었다. 신규 코드에 빠진 필드를 레거시와 대조해 찾아 채웠다.
- 2차 탭을 얹었더니 언어 토글이 그 탭에만 안 먹었다. 1차에서 만든 전역 재번역 배선이 새 탭까지 자동으로 따라오지 않아서, 화면을 추가할 때 재번역 함수도 같이 등록하도록 고쳤다.
- 시작 팝업 하나에 1차 폼과 2차 DCIR 설정을 같이 담았더니 내용이 창보다 커져서 입력칸이 겹쳤다. 창을 키우고 상단 폼 높이를 고정해 아래 Cell ID 그리드를 살렸는데, 이번엔 작은 노트북 화면에서 팝업이 화면보다 커져 위쪽이 잘렸다. 결국 고정을 걷어내고 최소 높이만 준 뒤 남는 세로는 그리드가 흡수하게 했다. 그러고도 한 번 더 눌렸다. 상단의 파일 목록이 자기 최소 크기로 위쪽을 부풀리고 있어서 아래 그리드가 얇게 깔렸다. 목록이 세로 공간을 요구하지 않도록 정책을 바꿔서, 위는 폼 높이만큼만 쓰고 남는 세로는 전부 그리드로 가게 했다. 한 화면에 두 시대의 폼을 합치면 화면 크기 가정이 충돌한다. 고정 값으로 막으면 반대편에서 터지고, 결국 "누가 남는 공간을 먹는가"를 정해야 끝난다.
- 앱을 켤 때마다 "저장 완료" 팝업이 떴다. 저장해 둔 언어를 복원하는 코드가 콤보 값을 바꾸면서 사용자가 직접 고른 것과 똑같은 핸들러를 태웠고, 그 핸들러가 설정 저장까지 하고 있었다. 복원할 때는 신호를 막고 언어만 적용하도록 갈랐다. 설정 복원이 조작용 핸들러를 그대로 타면 부팅 때 부작용이 나온다.
- DCIR 첫 실측에서 저항이 허수로 나왔다. 원인은 코드가 아니라 배선이었고, 원시 로그에서 "계측기만 붙으면 전류 0, 로드만 붙으면 전압 0"이 그대로 보였다. 전류와 전압을 셀에 동시에 물리는 4단자 배선이 필요했다.
- 자동 순회에서 첫 채널만 전류가 흘렀다. 주소 쓰기도 되읽기도 성공하는데 전류가 안 흘렀다. 나는 발주처가 알려준 전환 방식이 틀렸다고 단정하고 1차가 쓰던 핸드셰이크로 바꿨다. 그게 오진이었다. 전환 명령은 정상으로 들어가고 있었고, 바꾼 그 코드가 다음 버전에서 릴레이를 아예 멈췄다. 전류가 왜 안 흘렀는지는 지금도 모른다. 쓰기와 되읽기가 성공하는 신호를 보고 그 신호 탓을 한 것이 틀린 자리다.
- 측정 설정 팝업에서 키보드 입력이 안 먹었다. 범인은 팝업이 아니라 바코드 스캐너용 전역 키 필터였다. 입력칸 외의 위젯에 들어오는 키를 전부 삼키고 있어서, 숫자 입력칸에 타이핑이 안 됐다. 입력을 가로채는 전역 필터는 나중에 붙는 위젯을 모른다.
- 그다음엔 릴레이 전환 소리가 아예 안 났다. 원인을 둘 찾았고, 둘 다 과거 실측 기록에 근거가 있었고, 둘 다 틀렸다. 실제 원인은 바로 앞 항목, 내가 근거 없이 전환 방식을 바꾼 것이었다. 기록은 실재했고 인용도 정확했는데 그 기록이 다른 설비의 것이었다. 이 용역에는 설비가 둘이고 PLC 래더가 다르다. "근거가 있다"와 "근거가 이 대상의 것이다"는 다른 검사다. 근거를 붙였기 때문에 오히려 쉽게 단정했다. 고객사로 보낸 원인 설명도 정정해 다시 보냈다.
- 방전 5초가 실제로는 7초였다. 간격만큼 쉬고 나서 전압을 물어보는 구조라 질의 시간이 샘플마다 쌓였는데, 원시 파일에 적던 시각이 실제 시각이 아니라 예정 시각이어서 데이터에 전혀 안 보였다. 시작 시각 기준 데드라인에 찍도록 바꾸고 실제 시각을 적게 했다. 계측 결과를 예정값으로 적어 두면 계측이 자기 결함을 가린다.
- 100밀리초마다 저장해 달라는 요구가 계측기 설정 한 줄에 막혀 있었다. 쓰던 명령이 부를 때마다 측정 설정을 다시 잡아서 전압 한 번 읽는 데 347밀리초였다. 연결할 때 한 번만 설정하고 이후엔 읽기만 보내도록 바꿔 36밀리초가 됐다. "한 방이면 나온다"는 편의에 값이 붙어 있었다.
- 셀이 없는 빈 채널이 저항값을 저장했다. 전압 차를 거의 0인 전류로 나눠서 판정 상한을 아슬아슬하게 넘겼다. 저항 범위로 빈 채널을 가리려 한 게 틀렸고, 전류로 가려야 했다. 실제 흐른 전류가 설정값에 한참 못 미치면 저항을 내지 않는다.
- 자동 탭과 수동 탭이 결과를 따로 들고 있었다. 증상이 다섯 갈래로 보였는데(합쳐지지 않는 저장, 순번이 순번이 아닌 것, 한쪽만 지워지는 초기화, 다른 셀을 재는 수동 측정 등) 원인은 하나였다. 두 화면이 공유 세션 하나를 쓰도록 모았다. 증상이 여러 갈래로 보일 때 원인이 하나인 쪽을 먼저 의심하는 게 대체로 싸다.
- 현장에서 PLC가 안 붙는데 손으로 띄운 브릿지 창은 멀쩡했다. 자동 기동이 실패한 게 아니라 너무 잘 되고 있었다. 앱이 떠 있는 브릿지를 안 보고 매번 자기 것을 또 띄웠고, 포트 공유 옵션 때문에 둘이 같은 포트를 잡아 앱이 자기가 띄운 불량 쪽에 붙었다. "수동으로 하면 정상"이라는 관찰이 오래 오진을 붙들고 있었다. 정상으로 보인 쪽은 실제로 안 쓰이고 있었다.
- 만들어 넣은 안전 조건 입력칸이 화면에 가려서 쓸 수가 없었다. 팝업이 요구하는 최소 높이가 창 최소보다 커서 Qt가 자식을 겹쳐 놓고 있었다. 상단 폼을 스크롤 영역에 넣어 요구 높이를 낮췄다. "만들었다"와 "쓸 수 있다"는 다르다.
- 채널 수가 늘면 글자가 잘렸다. 글씨 크기 하한이 아니라, 세 줄짜리 라벨에 한 줄 몫의 세로 공간을 주던 분배가 문제였다. 그리고 글자 크기를 계수로 계산하던 것을 실제 폰트 측정값에서 받도록 바꿨다. 포인트에서 픽셀로 가는 환산은 폰트와 배율에 따라 달라 선형이 아니다. 이 "칸 크기에 비례하는 글씨"는 작은 회귀를 하나 더 냈다. 측정 중인 칸은 글씨 갱신을 건너뛰게 돼 있었는데, 선택 강조가 칸을 측정 중 상태로 만들어서 선택된 칸만 글자가 예전 크기에 묶였다. 고정 크기 시절에는 건너뛰어도 티가 안 나던 코드가, 비례로 바꾸는 순간 버그가 된 것이다. 일반 경로와 강조 경로가 같은 계산을 쓰도록 분리해 잡았다.
- 위 두 건의 공통 배경: 구동은 윈도우, 검증은 맥인데 Qt 기본 스타일이 OS마다 다르다. 앱 전체를 Qt가 직접 그리는 스타일로 고정했다. 그러면서 내가 화면을 확인하던 오프스크린 환경의 기본 스타일이 원래 그것이었다는 걸 알았다. 그때까지 내가 본 화면과 고객사가 본 화면은 처음부터 다른 스타일이었다. 검증 환경과 구동 환경이 다르면 그건 검증이 아니다.
미해결 잔여도 있다. 고차 채널의 간헐적 하드웨어 실패(물리 점검 필요) 같은 것들이다. "45/45 통과"는 레거시 대조 자체검증이지 클라이언트 최종 검수가 아니다.
Decision Log
C++ 레거시는 직접 복사할 수 없고 벤더에 종속돼 있었다. "무엇을·어떻게 판정하는가"만 참조하고 PySide6로 새로 썼다. "다 내가 만들었다"가 아니라 "이식 + 직접 설계"가 정확한 표현.
MX Component가 32비트 전용이라, 64비트 앱에서 32비트 파이썬 서브프로세스를 띄워 로컬 TCP로 우회했다. 초기엔 소켓 프로토콜을 고려했지만 최종 배포는 COM.
레거시엔 없던 것. EMG·도어 폴링과 ACK 핸드셰이크, 실시간 비트 모니터링을 새로 얹었다. 실장비를 다루는 소프트웨어의 핵심이 여기다.
무장비 개발·검증을 위해 실엔진과 같은 인터페이스의 Mock을 만들어 드롭인 교체하게 했다. 하드웨어 없이 로직을 돌릴 수 있는 게 1인 개발의 속도를 만들었다.
Qt UI를 머릿속으로 잡으면 계속 번복된다. HTML 목업을 먼저 확정하고 "이 수치 그대로 옮긴다"를 규칙으로 삼았다.
이 용역에는 설비가 둘 있다. 1차 검사 트레이와 2차 DCIR 릴레이다. 겉보기에 같은 PLC를 쓰니 주소와 핸드셰이크도 같을 것 같은데, 래더가 다르다.
릴레이 전환 방식이 다섯 번 바뀐 것의 대부분이 여기서 나왔다. 1차에서 수년째 돌던 방식을 2차에 옮겨 적었고, 과거 실측 기록까지 근거로 붙였다. 기록은 실재했고 인용도 정확했는데 그 기록이 1차 설비의 것이었다. 결과적으로 처음부터 맞던 2차 방식을 "근거 없는 코드"로 취급해 지웠고, 그 뒤 몇 주를 내가 만든 고장을 설명하는 데 썼다.
그래서 점검 항목을 하나 늘렸다. 근거를 붙일 때 "이 근거가 어느 설비의 것인가"를 따로 적는다. 재사용은 기본값이지만, 물리 설비 쪽으로 넘어가는 신호에서는 재사용이 기본값이면 안 된다.
회고, 유일한 실전 프로덕션
개인 프로젝트들이 "혼자 어디까지 만들 수 있나"의 증거라면, CMS는 다르다. 실제 돈을 받고, 실제 설비와 통신하며, 현장에 설치돼 반복 검증을 받은 유일한 프로덕션이다.
낡은 MFC C++를 PySide6로 이식하면서, EMG 안전 래치·신호 체인·PLC 상태 오염 같은 실장비의 함정을 추측이 아니라 로그로 잡아냈다. 이 경험들이 내 디버깅 규칙(하드웨어를 먼저 의심하라, 로그가 가리키는 곳을 봐라)의 실제 출처다. 그리고 AI 보조로 1인이 이 규모를 현장 설치까지 끌고 갔다.
"스펙트럼 넓은 사람"이라는 말의 가장 무거운 증거다. 다만 회사·금액·규격은 전부 가린 채로만 보여줄 수 있는, 그런 종류의 증거이기도 하다.
2차(DCIR 측정 추가)는 통신 검증을 마치고 앱 안에 구현까지 들어갔고, 현장 PC에 설치해 장비 세 대와 붙였고, 고객사가 한 번 직접 써 보고 보고서를 보내 왔고, 그 지적을 고친 뒤 직접 방문해 설비 앞에서 끝까지 돌렸다. 네 채널이 넘어가고 저항이 값으로 나온 날이 이 프로젝트에서 가장 오래 기다린 날이었다. 대신 그날 고칠 것 서른네 건이 새로 나왔고, 그걸 고친 버전은 아직 가짜 장비에서만 돌았다. 그래서 2차에 붙일 수 있는 말은 **"실설비에서 측정까지 됐고, 그날 나온 것을 고쳐 재검증을 기다린다"**까지다. 그리고 판정 기준값을 아직 못 받았으니 "검사기가 완성됐다"도 아니다.
그리고 이 프로젝트에서 가장 자주 확인한 건 따로 있다. 모르는 것을 "합리적으로" 정해 두면 거의 틀린다. 전압을 언제 몇 번 읽을지가 두 번 뒤집혔고, 채널을 어떻게 넘길지는 다섯 번 뒤집혔다. 모르는 것만 위험한 게 아니다. 적어 두고 안 읽은 것도 똑같이 위험하다.
그런데 다섯 번째에서 하나 더 배웠다. 가장 비싼 오진은 근거가 없을 때가 아니라, 근거가 엉뚱한 대상의 것일 때 나왔다. 설비 두 대의 PLC 래더가 다른데 한쪽의 검증된 기록을 다른 쪽에 옮겨 쓰면서, 처음부터 맞던 답을 "근거 없는 코드"로 취급해 지웠다. 그 뒤로 몇 주를 내가 만든 고장을 설명하는 데 썼다. 확인 전에 단정하지 않는다는 규칙만으로는 안 됐고, "이 근거가 이 대상의 것인가"를 따로 물어야 했다.
그래서 지금 하는 일은 확정된 것을 잘 만드는 쪽이 아니라, 확정 안 된 것을 한곳에 모아 두고 기록을 넉넉히 남겨서 답이 왔을 때 싸게 바꾸는 쪽에 가깝다. 한 가지 단서가 붙었다. 싸게 바꿀 수 있다는 건 틀린 방향으로도 싸게 바뀐다는 뜻이다. 고칠 자리가 한 줄이었기 때문에 근거 없이도 쉽게 들어갔다. 값싼 변경일수록 왜 바꾸는지를 물어야 한다.