
Phase 2가 끝났을 때 솔직한 상태
Phase 2를 마쳤을 때, 기능 목록만 보면 꽤 그럴싸했습니다.
알람 감시가 서버에서 돌고, 브라우저를 닫아도 이력이 쌓이고, 트렌드 차트에서 값의 변화를 볼 수 있고. "있긴 하다"의 단계는 통과했습니다.

그런데 현장에서 실제로 써보면 느낌이 달랐어요.
알람이 났을 때 담당자가 할 수 있는 게 없었습니다.
알람 팝업에는 어떤 조건이 맞았는지 나오는데, 그래서 뭘 어떻게 해야 하는지가 없었거든요.
결국 PC를 내려놓고 서류철을 뒤지거나, 다른 사람에게 전화를 했습니다.
IO Monitor도 보기만 할 수 있었습니다.
출력 신호를 ON/OFF 하려면 별도 툴이 필요했어요.
배선 테스트나 수동 운전 상황에서 결국 PLC 소프트웨어를 켜야 했습니다.
Phase 3의 목표는 이걸 해결하는 것이었습니다.
"있긴 하다"에서 "현장에서 실제로 쓸 수 있다"로.
1축 : IO Monitor, 보기만 하던 화면이 운전 화면이 됐다
출력 접점을 브라우저에서 직접 ON/OFF한다

가장 원했던 기능입니다.
배선 테스트를 할 때를 생각해보면, 엔지니어가 PLC 앞에 서서 GX Works를 켜고 강제 출력 모드로 들어가야 했습니다.
브라우저에서 버튼 한 번으로 Y3920을 ON 해볼 수 있으면, 그 과정이 완전히 달라집니다.
다만 모든 디바이스를 아무나 건드릴 수 있으면 안 됩니다.
잘못 건드리면 래더 로직 자체가 무너질 수 있거든요.
그래서 쓰기 권한을 디바이스 종류와 모드에 따라 나눴습니다.
| 디바이스 | 확인 모드 | 엔지니어 모드 | 이유 |
|---|---|---|---|
| Y (출력 릴레이) | 가능 | 가능 | 운전자가 직접 조작해야 하는 경우 있음 |
| M, R (내부 릴레이) | 불가 | 가능 | 잘못 건드리면 래더 로직 문제 |
| X (물리 입력) | 불가 | 불가 | 외부 신호, 소프트웨어로 변경 불가 |
| D/DM (데이터 레지스터) | 불가 | Data List에서 | 별도 페이지 기능 사용 |
현장에서 알게 된 것 : 래더가 값을 덮어쓴다

M 디바이스 ON/OFF를 눌렀는데 값이 유지가 안 됐습니다.
서버 로그에는 200 OK였어요. 쓰기는 성공했는데 값이 즉시 돌아왔습니다.
처음엔 PLCLink 버그인 줄 알았습니다.
타이밍 문제인가 해서 딜레이를 늘려봐도 마찬가지였어요.
원인은 PLC의 동작 방식이었습니다.
PLCLink가 M100 = 1 씀 (성공)
↓ (1~5ms 후)
PLC 스캔 사이클 실행
↓
래더에 [OUT M100] 코일이 있으면 즉시 덮어씀
↓
PLCLink가 150ms 후 읽으면 다시 0
SLMP 쓰기는 "PLC 메모리에 값 넣기"입니다.
그런데 래더 프로그램은 매 스캔 사이클마다 그 메모리를 다시 계산해서 덮어씁니다.
M에 OUT 코일이 걸려있으면, 쓴 값이 다음 스캔에서 바로 사라집니다.
반면 D 레지스터처럼 래더가 읽기만 하고 쓰지 않는 영역은 값이 유지됩니다.
그래서 현장에서 실제로 쓸 수 있는 구조는 이렇습니다.
D 레지스터에 명령값을 쓰고, 래더가 그걸 읽어서 동작하게 설계하는 것.
이건 PLCLink 버그가 아니라 SLMP의 근본적인 동작 원칙이었습니다.
현장에서 하드웨어를 직접 연결해봐야 보이는 종류의 문제였어요.
라벨 인라인 편집, 격자 열 수, 순서 변경
사소해 보이지만 반복 작업에서 체감 차이가 나는 것들을 정리했습니다.
카드 뷰에서 라벨을 클릭하면 그 자리에서 바로 편집됩니다.
편집 폼으로 이동하지 않아도 됩니다. 목록 뷰도 마찬가지입니다.
8점/16점 격자 전환도 추가했고, ▲▼ 버튼으로 포인트 순서를 조정할 수 있습니다.
순서 변경에서 한 가지 주의할 점이 있었습니다.
PATCH를 포인트마다 반복 호출하면 각 응답이 React state에 in-place로 적용되어 배열 순서가 화면에 반영되지 않는 문제가 있었어요.
모든 PATCH가 끝난 뒤 서버에서 전체 목록을 다시 불러오는 것으로 해결했습니다.
2축 : 알람 시스템, 뜨는 것에서 대응하는 것으로
30초가 지나면 알람이 소리를 낸다

알람 배너가 떠도 담당자가 화면을 보지 않으면 의미가 없습니다.
Phase 3에서 알람 에스컬레이션을 추가했습니다. 활성 알람이 30초 이상 해제되지 않으면 두 가지 일이 일어납니다.
첫째, 알람 배너가 점멸하기 시작합니다.
정적인 빨간 뱃지에서, 누가 봐도 이상하다는 걸 알 수 있는 상태로 바뀝니다.
둘째, 경보음이 재생됩니다.
라이브러리 없이 Web Audio API로 직접 구현했습니다.
880Hz, 660Hz, 880Hz 세 음을 0.22초 간격으로 재생하고, 이후 30초마다 반복합니다.
function playEscalationBeep() {
const ctx = new AudioContext()
const beep = (freq, startT, dur) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.frequency.value = freq
gain.gain.setValueAtTime(0.25, startT)
gain.gain.exponentialRampToValueAtTime(0.001, startT + dur)
osc.start(startT); osc.stop(startT + dur)
}
beep(880, ctx.currentTime, 0.18)
beep(660, ctx.currentTime + 0.22, 0.18)
beep(880, ctx.currentTime + 0.44, 0.25)
}
알람 통계 탭 추가
통계 탭에서 일별 발생 건수 차트와 알람 룰별 빈도 TOP N을 볼 수 있습니다.
이미 Recharts 라이브러리가 Data List 트렌드 차트에서 쓰이고 있었어요.
BarChart 하나 추가하면 됐습니다.
백엔드도 GROUP BY DATE()로 집계하는 SQL 한 줄이 전부였고요.
설치 후 한 달이 지나면 "어느 알람이 가장 자주 뜨나"를 확인할 수 있습니다.
반복되는 알람이 있으면 설비 세팅을 조정하거나, 래더 로직을 점검하는 근거가 됩니다.
알람 매뉴얼, 세 번 만들었다

이게 Phase 3에서 가장 오래 걸린 기능입니다.
목적은 단순했어요. 알람 팝업에서 조치 방법을 바로 볼 수 있게 하는 것.
"D1000 이상" 알람이 뜨면 담당자가 서류를 뒤질 필요 없이, 팝업 안에서 바로 조치 순서를 확인합니다.
1차 시도 : PNG/JPG 이미지 한 장씩 등록
동작은 했지만, PPT 슬라이드가 여러 장이면 일일이 내보내야 했습니다.
현장 매뉴얼이 20~30장짜리가 많아서 현실적으로 불편했어요.
2차 시도 : PDF 직접 embed
PDF를 그대로 올리고 <embed src="file.pdf#page=N"> 방식으로 페이지를 넘기려 했습니다.
Chrome의 PDF 뷰어는 #page=N으로 해당 페이지로 스크롤은 하는데, 스크롤 자체를 막을 수 없어서 여러 페이지가 동시에 보였습니다. 비율도 맞지 않았고요.
3차 시도 : pdf.js
Mozilla의 PDF 렌더링 라이브러리입니다.
Canvas에 한 페이지씩 그리는 방식이라 이게 정답인 것 같았어요.
그런데 Vite + FastAPI 오프라인 환경에서 계속 막혔습니다.
?url 접미사, public/ 폴더 복사, 동적 import, 전부 시도했는데 Setting up fake worker failed: Failed to fetch dynamically imported module 오류가 사라지지 않았어요.
pdfjs-dist v5가 ESM 전환된 상태였고, 제조 현장 특성상 CDN도 쓸 수 없는 환경이었습니다.
최종 해결 : 서버에서 PNG로 변환
생각을 바꿨습니다.
클라이언트에서 PDF를 렌더링하는 대신, 서버에서 PNG로 미리 변환해두면 됩니다.
브라우저는 이미지를 보여주기만 하면 되고요.
PyMuPDF(fitz)는 pip install PyMuPDF 한 줄로 설치됩니다.
PDF를 페이지별 이미지로 변환하는 코드도 10줄이면 됩니다.
def _pdf_to_images(filepath, site_id, rule_id):
import fitz # PyMuPDF
doc = fitz.open(str(filepath))
files = []
for i in range(len(doc)):
page = doc.load_page(i)
pix = page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5))
fn = f"{site_id}_{rule_id}_{int(time.time()*1000)+i}_p{i+1:03d}.png"
pix.save(str(_IMAGES_DIR / fn))
files.append(fn)
doc.close()
return files
PDF를 업로드하면 서버가 페이지별 PNG를 만들어두고, 팝업에서는 기존 이미지 캐러셀을 그대로 씁니다.
의외로 단순하고 가장 잘 동작했어요.
pdf.js 워커 문제를 해결하는 데 쓴 시간보다, 방향을 바꾸기로 결정하는 데 더 시간이 걸렸습니다.
"이미 3차 시도까지 왔는데 포기하기 아깝다"는 생각이 판단을 늦췄어요.
알람 팝업 UI의 진화
처음엔 380px 고정 모달이었습니다.
슬라이드를 추가하면서 크기를 늘렸는데, 고정 픽셀로는 어느 해상도에서도 최적화가 안 됐어요.
제조 현장 PC는 해상도가 제각각입니다.
1366×768 노트북, FHD 24인치 모니터, 가끔 4K. 결국 96vw × 90vh로 픽셀 상한 없이 뷰포트 % 기반으로 변경했습니다.
팝업은 3단 구조입니다.
- 알람 정보 헤더 (한 줄, compact)
- 이미지 영역 (flex:1로 나머지 공간 전부)
- 네비게이션 + 액션 버튼 (고정 높이)
이미지 영역은 objectFit: contain으로 비율을 유지하고, 이미지 주변 letterbox는 다크 배경으로 처리했습니다.
밝은 PPT 슬라이드가 어두운 배경 위에서 도드라져 보이는 효과가 있어요.
"조치 완료"를 누를 때 현재 슬라이드 번호도 함께 기록됩니다.
이력 목록에서 13:52 · S2처럼 표시되는데, 2번째 슬라이드를 보고 조치했다는 뜻입니다.
나중에 "어떤 슬라이드에서 조치가 이루어지는가"를 확인할 수 있는 데이터가 됩니다.
3축 : 버그들, 실제 하드웨어가 없으면 발견 못 했을 것들
AlarmPoller가 틀린 IP에 접속하고 있었다

Phase 2가 끝났을 때 "서버사이드 알람 폴링이 동작한다"고 생각했습니다.
실제로는 아니었어요.
Mitsubishi 하드웨어로 테스트하면서 발견했습니다.
AlarmPoller가 Keyence 시절 IP(192.168.0.10:5000)에 계속 접속하고 있었어요.
Mitsubishi(192.168.1.101:4008)로 바꿨는데도 알람이 안 오는 게 이상해서 로그를 뒤지다가 알았습니다.
원인은 이랬습니다.
브라우저는 localStorage에서 최신 IP를 읽습니다.
AlarmPoller는 DB의 site_connections 테이블에서 읽습니다.
그런데 이 테이블이 Keyence 시절 값 그대로였어요.
해결은 앱이 시작될 때 localStorage 값을 DB에도 동기화하는 코드 한 줄이었습니다.
이 한 줄이 없어서 Phase 2 내내 AlarmPoller가 엉뚱한 곳을 바라보고 있었습니다.
개발 모드에서는 괜찮았는데 프로덕션 빌드에서 터졌다

가장 당황스러운 버그였습니다.
npm run dev에서는 멀쩡히 동작하는데, npm run build 후 실행하면 알람 페이지에서 Cannot access 'je' before initialization 오류가 났습니다. je는 minified 변수명이라 어떤 변수인지도 모르는 상태에서 디버깅을 시작했어요.
원인은 useCallback 의존성 배열과 변수 선언 순서였습니다.
// 위험한 패턴
const handleUpload = useCallback((...) => {
const rid = safeAlarms[editIdx]?.id // safeAlarms 사용
}, [safeAlarms, editIdx]) // 의존성 배열은 즉시 평가됨
// ... 200줄 아래 ...
const safeAlarms = Array.isArray(alarms) ? alarms : [] // 나중에 선언
Rollup(프로덕션 번들러)은 const의 TDZ를 엄격하게 적용합니다. Babel(개발 모드)은 const를 var로 변환해서 TDZ가 숨겨집니다. 그래서 개발 모드에서는 아무 문제가 없었어요.
같은 패턴으로 두 번 발생했습니다. 이제는 useCallback 의존성 배열에 있는 변수는 반드시 위에 선언되어 있는지 확인하는 습관이 생겼습니다.
Mitsubishi M 디바이스가 잘못된 주소를 읽고 있었다
M100을 등록했는데 실제로는 M256(0x100)을 읽고 있었습니다.
Mitsubishi SLMP 스펙에서 X/Y 디바이스는 16진수 주소를 씁니다.
그런데 M 디바이스는 10진수입니다.
프론트와 백엔드 양쪽에서 모두 M을 16진수로 처리하고 있었어요.
디바이스 종류에 따라 진수 파싱을 분리하는 것으로 해결했습니다.
실제 하드웨어로 테스트하기 전까지는 발견하기 어려운 종류의 버그입니다.
Phase 2에서 Phase 3로 변화 요약

| 항목 | Phase 2 | Phase 3 |
|---|---|---|
| IO 출력 쓰기 | 없음 | Y : 확인/엔지니어 모두, M/R : 엔지니어만 |
| 알람 에스컬레이션 | 없음 | 30초 미해제 시 점멸 + 경보음 |
| 알람 통계 | 없음 | 일별 차트 + 룰별 빈도 TOP N |
| 알람 매뉴얼 | 없음 | PDF/이미지 업로드 + 팝업 캐러셀 표시 |
| AlarmPoller IP 동기화 | 수동 | 앱 시작 시 localStorage와 DB 자동 동기화 |
| 라벨 편집 | 폼 이동 필요 | 인라인 클릭 편집 |
| IO 포인트 순서 | 없음 | ▲▼ 버튼 순서 변경 |
Phase 3가 가르쳐준 것
단순한 해결이 더 잘 동작하는 경우가 있습니다.
pdf.js 워커 문제를 해결하려고 여러 방법을 시도했는데, 결국 PyMuPDF 서버사이드 변환이 가장 잘 동작했습니다.
방향을 바꾸기 어려웠던 건 기술적 이유가 아니라 "여기까지 왔는데"라는 심리였어요.
실제 하드웨어가 없으면 발견 못 하는 버그가 있습니다.
AlarmPoller IP 문제, M 디바이스 주소 문제, 둘 다 시뮬레이터로는 나오지 않는 버그였습니다.
개발 모드와 프로덕션 빌드는 다릅니다.
개발 모드에서 괜찮다고 프로덕션이 괜찮은 건 아닙니다.
주기적으로 프로덕션 빌드를 직접 실행해서 테스트해야 합니다.
다음은 Phase 3.5 : 캔버스 HMI
Phase 3가 마무리되면서 자연스럽게 다음이 보였습니다.
기존 HMI 소프트웨어는 특정 PC에 종속되고, 화면 하나 바꾸는 데 전문 툴과 라이선스가 필요합니다.
PLCLink의 다음 목표는 브라우저에서 PLC 주소를 LED, 숫자, 게이지 등의 위젯으로 배치하고 실시간으로 값을 모니터링하는 자유 화면입니다.
계획 중인 위젯 : LED(비트 ON/OFF 색상 표시), 숫자 표시(워드값, 소수점 설정), 게이지/바(0~N 범위), 기본 도형(레이아웃용).
Phase 4에서는 다채널 타이밍 차트(Gantt 형태)와 트리거 캡처 OK/NG 패턴 비교가 목표입니다.
'(개인Project)_개발 > PLC-PC 연결' 카테고리의 다른 글
| [Program][보족] PDF 매뉴얼을 알람 팝업에 연결하기까지 (3차 시도와 최종 해결) (0) | 2026.05.27 |
|---|---|
| [Program][보족] 래더가 내 값을 덮어쓴다 (PLC 스캔 사이클을 이해해야 SLMP 쓰기가 보인다) (0) | 2026.05.26 |
| [Program][보족] 현장 PC에서 서버를 켜면 자동으로 감시가 시작되게 만든 방법 (0) | 2026.05.23 |
| [Program][보족] 설정이 계속 날아가는 이유 (localStorage의 한계와 DB 전환 시점) (0) | 2026.05.22 |
| [Program][보족] 알람이 났는데 왜 배너에는 안 뜨나요? (레벨 알람과 이벤트 알람의 차이) (0) | 2026.05.21 |