호스트 로그에는 no reply, transaction timed out이라고 찍혀 있다. 장비 벤더는 자기 쪽 로그에 응답을 보낸 기록이 남아 있다고 한다. 둘 다 맞다. 아래 두 줄은 실제로 소켓 위를 오간 것인데, 나간 프레임과 돌아온 프레임이 마지막 한 바이트만 다르다. 그 한 바이트 때문에 호스트는 이 응답을 자기 것으로 인정하지 않는다.
아래 hex는 전부 실제 소켓에서 잡은 것이다. SECS/GEM 시뮬레이터의 EQ1을 127.0.0.1:5501에 passive listener로 띄우고, 바깥에서 순수 Python 소켓 클라이언트로 붙였다. 런마다 TCP 연결을 새로 열었고, 장비 쪽 로그에 RX로 남은 프레임만 실었다. 시간은 클라이언트에서 잰 값이다. 캡처 날짜는 2026-09-18, fault는 wrongSystemBytes 하나만 켰고, 원본 export는 content/demos/hsms-systembytes-mismatch.json에 그대로 커밋해 뒀다.
호스트는 SystemBytes로 짝을 찾는다
SEMI E37 헤더는 4바이트 길이 접두사 뒤에 붙는 10바이트다. 바이트 번호는 E37이 0부터 센다.
| offset | 필드 | Select.req | Select.rsp |
|---|---|---|---|
| 0-1 | SessionID | 00 0B (11) | 00 0B (11) |
| 2 | 컨트롤 메시지는 0 / 데이터 메시지는 W-bit + Stream | 00 | 00 |
| 3 | 컨트롤 메시지는 SType별 용도 / 데이터 메시지는 Function | 00 | 00 = Select Status 0, accepted |
| 4 | PType | 00 | 00 |
| 5 | SType | 01 Select.req | 02 Select.rsp |
| 6-9 | SystemBytes | 요청이 정한 값 | 요청 값을 그대로 |
E37은 응답이 요청의 SystemBytes를 그대로 되돌려 준다고 못박는다. 트랜잭션을 짝짓는 근거가 이것 하나뿐이기 때문이다. 그래서 호스트 스택은 보통 이렇게 생겼다. 요청을 내보내면서 SystemBytes를 키로 pending reply 테이블에 항목을 하나 넣고, 타이머를 건다. 프레임이 들어오면 헤더 6-9바이트를 읽어 그 테이블을 조회한다.
조회가 빗나가면 깨울 대기자가 없다. 항목은 테이블에 그대로 남아 타이머가 만료될 때까지 기다리고, 도착한 프레임은 어디에도 전달되지 않는다. 대부분의 구현은 이 지점에서 로그 한 줄도 남기지 않는다. 그래서 증상이 "응답 없음"으로 보인다. 실제로는 "응답이 왔는데 내 테이블에 그 키가 없었음"이다.
바이트 순서를 의심하는 사람이 가끔 있는데, 매칭하는 쪽 입장에서 SystemBytes는 정수가 아니라 4바이트 덩어리다. 그냥 같은지 비교한다. 엔디안이 뒤집혔다면 값이 미묘하게 다른 게 아니라 완전히 달라지고, 캡처에서 00 00 00 42가 42 00 00 00으로 보인다. 실제로 이 형태를 만난 적은 없어서 가능성만 적어둔다.
정상 세션 — 값이 그대로 돌아온다
fault를 {}로 둔 상태. Select 한 번, 그리고 같은 연결에서 S1F13.
06:03:35.061 TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 00 41
06:03:35.074 RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 00 41 13.2 ms
06:03:35.124 TX S1F13 W 00 00 00 1A 00 0B 81 0D 00 00 00 00 00 42 01 02 41 07 48 4F 53 54 2D 30 31 41 03 31 2E 30
06:03:35.128 RX S1F14 00 00 00 37 00 0B 01 0E 00 00 00 00 00 42 01 02 21 01 00 01 02 41 15 ... 3.7 ms
| 메시지 | SessionID (0-1) | byte 2 | byte 3 | SType (5) | SystemBytes (6-9) |
|---|---|---|---|---|---|
| TX Select.req | 00 0B = 11 | 00 | 00 | 01 | 00 00 00 41 |
| RX Select.rsp | 00 0B = 11 | 00 | 00 = accepted | 02 | 00 00 00 41 그대로 |
| TX S1F13 W | 00 0B = 11 | 81 = W-bit 1, Stream 1 | 0D = Function 13 | 00 데이터 | 00 00 00 42 |
| RX S1F14 | 00 0B = 11 | 01 = W-bit 0, Stream 1 | 0E = Function 14 | 00 데이터 | 00 00 00 42 그대로 |
읽는 순서는 이렇다. 앞 4바이트 00 00 00 0A는 뒤에 10바이트가 온다는 뜻이고, 컨트롤 메시지는 본문이 없어서 항상 10이다. S1F13은 본문이 붙으니 00 00 00 1A, 26바이트다. 데이터 메시지에서는 byte 2의 최상위 비트가 W-bit라 81이 "Stream 1, 응답 요구"가 되고, 응답인 S1F14는 W-bit를 내려 01이 된다. 그리고 두 쌍 모두 6-9바이트가 나간 값 그대로 돌아왔다. 호스트는 테이블에서 항목을 찾아 지우고 넘어간다.
어긋난 세션 — 딱 한 바이트
같은 장비, 같은 요청, wrongSystemBytes fault만 켠 상태다. 이 knob은 요청의 SystemBytes에 1을 더해서 돌려준다.
06:03:35.589 TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 00 42
06:03:35.591 RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 00 43 1.9 ms
^^^^^^^^^^^
| 메시지 | SessionID (0-1) | byte 2 | byte 3 | SType (5) | SystemBytes (6-9) |
|---|---|---|---|---|---|
| TX Select.req | 00 0B = 11 | 00 | 00 | 01 | 00 00 00 42 보냄 |
| RX Select.rsp | 00 0B = 11 | 00 | 00 = accepted | 02 | 00 00 00 43 받음 |
구조적으로는 흠잡을 데 없는 Select.rsp다. SType 02, SessionID 일치, Select Status 0. 길이도 같다. 딱 6-9바이트 네 개 중 마지막 하나가 42가 아니라 43이다. 그래서 pending reply 테이블의 0x00000042 항목은 손대지지 않고, 도착한 0x00000043은 주인이 없다.
장비 쪽 로그에는 06:03:35.591에 TX Select.rsp가 정상으로 남는다. 호스트 쪽에는 아무것도 안 남는다. 두 로그를 나란히 놓고 봐도 원인이 안 보이는 이유가 이것이다 — 양쪽 다 자기 관점에서는 정상이다.
한 가지 더. 이 knob은 Select.rsp만 고쳐 쓴다. fault를 켠 채로 같은 연결에서 S1F13을 보내면 SystemBytes 00 00 00 44가 그대로 돌아온다. 데이터 메시지 경로는 건드리지 않는다는 뜻이고, 이 캡처가 증명하는 범위도 거기까지다. 실제 장비에서 불일치가 컨트롤 트랜잭션에만 생길지 데이터 메시지에도 번질지는 그 스택 구현에 달렸다.
기다리는 타이머가 T3인지 T6인지
여기서 예전에 내가 틀렸던 부분이다. 호스트가 기다리는 타이머는 무엇을 보냈느냐로 갈린다.
- Select, Linktest, Separate는 E37의 컨트롤 트랜잭션이다. 여기서 만료되는 건 T6(control transaction timeout, 기본값 5초)다. 위 캡처처럼 Select.rsp의 SystemBytes가 어긋나면 호스트는 T6이 끝날 때까지 앉아 있다가 select timeout을 찍는다.
- S1F13 같은 데이터 메시지에서 만료되는 건 T3다. E5와 E30이 쓰는 reply timeout이고, 보통 45초, 범위는 1-120초다. 실무에서
no reply가 45초 뒤에 찍혔다면 컨트롤이 아니라 데이터 트랜잭션 얘기다.
로그에 찍힌 시간만으로도 어느 쪽인지 가를 수 있다. 5초면 T6, 45초면 T3. 타이머별로 만료 시 무엇이 일어나는지는 HSMS 타임아웃, T3인지 T6인지 T7인지에 정리해 뒀다.
데이터 트랜잭션에서 T3이 만료됐을 때 E5는 Stream 9에 S9F9(transaction timer timeout)를 두고 있고, 본문 SHEAD에 응답받지 못한 primary 메시지의 헤더가 들어간다. 헤더가 통째로 들어가니 SystemBytes도 같이 온다 — 어긋난 값을 붙잡는 데 이보다 나은 증거가 없다. 다만 상대가 S9F9를 실제로 보내주는지는 구현 나름이고, 이 캡처로 확인한 내용이 아니다. 기대하고 설계하면 안 되는 이유는 장비가 S9를 보내줄 거라 기대하고 호스트를 짜면 안 된다에 따로 적었다. 헤더 자체가 깨져서 디코딩이 안 되는 경우는 S9F7(illegal data)이지, S9F9가 아니다.
침묵과 무시를 구분하기
현장에서 제일 먼저 갈라야 하는 건 이 두 가지다.
- 아무것도 안 온다 — TCP는 붙었는데 응답 프레임 자체가 없다. 장비가 Select를 처리 못 하는 상태거나, SessionID를 모르거나, 세션이 이미 물려 있다.
- 왔는데 무시했다 — 프레임은 도착했고 호스트가 버렸다. SystemBytes 불일치가 대표적이다.
가르는 방법은 하나뿐이다. 호스트 애플리케이션 로그 말고 소켓을 직접 본다.
tcpdump -i any -nn -X port <장비 HSMS 포트>
프레임이 보이면 호스트 문제고, 안 보이면 장비 문제다. 이 한 줄이 벤더와의 회의 두 번을 줄인다.
시간도 같은 일을 해준다. 어긋난 응답은 위 캡처에서 1.9 ms 만에 돌아왔다. 침묵은 몇 초를 기다려도 아무것도 없다. 애플리케이션 로그에는 둘 다 똑같이 timeout 한 줄로 찍히지만, 소켓 레벨에서는 즉시 도착한 프레임과 무(無)의 차이다.
어긋난 응답 하나를 받고 나서 16초를 더 기다려 봤다.
06:04:53.897 TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 00 81
06:04:53.899 RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 00 82 2.2 ms
(이후 16,016.1 ms 동안 프레임 없음, 상대가 소켓을 닫지도 않음)
E37 기본값이면 그 사이 T6(5초)도 T7(10초)도 이미 지났다. 이 시뮬레이터는 두 타이머를 돌리지 않는다 — 시뮬레이터가 표준의 대체물이 아니라는 증거로 읽으면 된다. 실제 장비라면 그 전에 연결을 끊었어야 한다. 16,016.1 ms는 내 클라이언트가 참은 시간이지 T6이나 T7의 값이 아니다.
호스트 라이브러리를 고를 때 이런 걸 로그로 남기는지 확인해두면 좋다. 버려진 프레임을 debug 레벨로라도 찍어주는 구현이 있고, 아예 안 찍는 구현이 있다. 후자를 쓰고 있으면 이 문제는 tcpdump 없이는 안 보인다.
값이 어긋나는 흔한 경로
원인은 대개 장비 쪽 구현에 있다. 자주 보이는 형태는 응답을 만들 때 요청 값을 복사하지 않고 새로 채번하는 것이다. GEM 스택을 직접 구현했거나 네이티브 프로토콜에 wrapper를 씌운 장비에서 나온다. 벤더 입장에서 "응답을 보냈다"는 말은 거짓이 아니다. 캡처를 붙이기 전까지 대화가 안 굴러가는 이유가 그것이다.
반대 방향의 사고도 있다. 호스트가 한 카운터를 여러 세션에 돌려 쓰다가 같은 SystemBytes로 두 건을 동시에 띄우면, 장비가 규칙대로 그대로 돌려줘도 호스트가 어느 쪽 답인지 못 가른다. 그건 장비 잘못이 아니고, 같은 SystemBytes로 두 건을 띄우면 응답을 구분할 수 없다에서 따로 다뤘다.
직접 재현해보기
위 캡처는 SECS/GEM 시뮬레이터에서 만들었다. Passive listen을 켜고 바깥에서 소켓으로 붙이면 정상 세션이 그대로 나오고, 장비에 wrongSystemBytes fault를 걸면 어긋난 쪽이 나온다. fault를 끄면 다시 값이 그대로 돌아오는 것까지 확인했다.
06:05:10.378 TX Select.req 00 00 00 0A 00 0B 00 00 00 01 00 00 00 91
06:05:10.381 RX Select.rsp 00 00 00 0A 00 0B 00 00 00 02 00 00 00 91 3.4 ms
06:05:10.431 TX S1F13 W 00 00 00 1A 00 0B 81 0D 00 00 00 00 00 92 ...
06:05:10.432 RX S1F14 00 00 00 37 00 0B 01 0E 00 00 00 00 00 92 ... 0.7 ms
증상이 Select 단계에서 멈춘 것이고 원인이 아직 셋 중 어느 쪽인지도 모르겠다면, 응답 없는 Select의 세 가지 원인부터 보는 게 순서다.
다음에 no reply를 보면, 네트워크보다 tcpdump가 먼저다. 그리고 6-9바이트부터 읽어라.