
산업용 센서를 LoRa로 모아 관제 서버로 올리는 시스템을 만들었습니다. 센서는 MODBUS-RTU 슬레이브, 중간에 RP2040 기반 LoRa 디바이스, 그 위에 LoRaWAN 게이트웨이. 여기까지는 흔한 구성입니다.
문제는 마지막 구간에서 났습니다. 5분마다 보내는데 서버에는 10분 간격으로만 쌓였습니다. 전송은 매번 OK 응답을 받았고요.
원인을 찾는 데 하루가 걸렸고, 그 사이 세 번 잘못 짚었습니다. 이 글은 그 과정입니다.
LoRaWAN Class C 폴링 구조 — 요약
LoRaWAN Class C 폴링은 장치가 알아서 올리는 대신 부르면 답하는 구조입니다. 30대가 각자 쏘면 겹치고, 겹치면 그대로 손실이라 부르는 쪽을 하나로 모았습니다.
디바이스: 센서를 0.5초 간격으로 돌아가며 읽어 최신값을 들고만 있는다
게이트웨이: 1초에 한 대씩 순서대로 호출한다
디바이스: 자기 차례에 무선으로 응답한다
30대면 한 바퀴 30초입니다. 항상 듣고 있어야 부를 수 있으므로 Class C 를 씁니다. 왜 이렇게 정했는지, 응답을 안 기다리는 이유가 무엇인지는 3부에 자세히 적었습니다.
게이트웨이 안으로 폴링 에이전트 밀어 넣기
게이트웨이 안에서 도는 폴링 에이전트는 원래 PC가 돌리던 것입니다. PC가 MQTT 브로커를 띄우고, 게이트웨이가 거기 붙고, PC가 다운링크를 던지는 구조였죠.
현장에는 PC가 없습니다. 그래서 전부 게이트웨이 안으로 옮겼습니다.
게이트웨이는 OpenWrt 기반이고 overlay 여유가 6MB뿐입니다. 파이썬 MQTT 라이브러리를 깔 자리가 없어서 프로토콜을 직접 구현했습니다. 필요한 게 CONNECT / SUBSCRIBE / PUBLISH / PINGREQ 넷뿐이라 오히려 간단했습니다.
var = b"\x00\x04MQTT\x04\x02\x00\x3c" # 3.1.1, clean session, keepalive 60
body = var + struct.pack(">H", len(cid)) + cid
sock.sendall(bytes([CONNECT << 4]) + _rem_len(len(body)) + body)
그리고 게이트웨이의 MQTT 연동 주소를 PC에서 127.0.0.1로 바꿨습니다. 이 한 줄이 PC 의존을 끊었습니다.
uci set lorasrv.lorasrv.broker_ip='127.0.0.1'
이제 PC를 꺼도 현장이 돕니다. PC는 곁에서 지켜보는 감시 창으로 내려갔습니다.
서버 전송은 OK인데 데이터 절반이 사라졌다
서버 전송 누락, 여기서부터가 본론입니다.
에이전트는 5분마다 두 장치의 데이터를 서버로 보냅니다. 로그에는 매번 이렇게 찍혔습니다.
12:50:00 보낼 패킷: {...}
12:50:00 원격 전송 OK — 응답 "OK"
12:55:00 보낼 패킷: {...}
12:55:00 원격 전송 OK — 응답 "OK"
그런데 서버 화면에는 12
있고 12 없었습니다. 완전히 같은 패킷인데 하나는 들어가고 하나는 안 들어갔습니다.첫 번째 오답 — “모듈 번호를 붙이면 버린다”
서버 목록 몇 줄을 보고 규칙을 세웠습니다. 모듈 번호를 넣은 회차만 안 보이더군요. 그래서 “서버가 모듈 번호를 거부한다”고 결론지었습니다.
틀렸습니다. 버리는 게 아니라 다른 장치로 들어가고 있었습니다.
서버는 Type과 Id를 이어붙여 장치를 식별합니다.
"Type":T01"Id":00001,00002 → T01-00001-00002
"Type":T01"Id":002,00001,00002 → T01-002-00001-00002 ← 다른 장치
모듈 번호를 넣고 빼는 건 장치를 갈아치우는 일이었습니다. 예전 키를 보고 있으면 그 시각에서 멈춘 것처럼 보입니다.
두 번째 오답 — “같은 초에 두 대를 보내면 하나만 남는다”
두 장치를 1초 안에 연달아 보내니 하나만 남는 것처럼 보였습니다. “동시 도착이 문제구나” 하고 장치 사이에 10초 간격을 넣었습니다.
틀렸습니다. 나중에 받은 자료에서 두 건이 연속된 행번호로 나란히 등록된 게 확인됐습니다. 넣었던 간격은 도로 뺐습니다.
세 번째 오답 — “10분 규칙 같은 건 없다”
한 번은 제대로 짚었습니다. “10분에 한 번만 저장되는 것 같다”고요. 그런데 다음에 받은 목록에서 5분 간격으로 들어간 건이 나오길래 스스로 철회했습니다.
그 반례들은 내용이 서로 달랐던 회차였습니다. 규칙이 맞았는데 제가 접었습니다.
네 번째 — 전체를 놓고 보니
문제는 계속 부분 자료로 규칙을 세운 것이었습니다.
서버 목록에는 우리 것 말고도 기록이 분당 수십 건씩 흐르고 있었습니다. 우리 것은 5분에 한 줄이니, 그 홍수 속에서 한 줄을 놓치고 “안 들어왔다”고 읽기가 너무 쉬웠습니다.
우리 것만 걸러낸 전체 목록을 받아서 우리 로그와 한 줄씩 맞췄더니, 13건이 예외 없이 하나로 맞아떨어졌습니다.
| 보낸 시각 | 직전 저장과 간격 | 내용 | 서버 |
|---|---|---|---|
| 12:45 | 7.7분 | 같음 | ✗ |
| 12:50 | 12.6분 | 같음 | ✓ |
| 12:55 | 5.0분 | 같음 | ✗ |
| 13:00 | 10.0분 | 같음 | ✓ |
| 13:15 | 7.0분 | 같음 | ✗ |
| 13:15 | 7.6분 | 바뀜 | ✓ |
| 13:20 | 4.4분 | 같음 | ✗ |
규칙: 같은 장치의 같은 내용은 10분에 한 번만 저장된다. 내용이 바뀌면 즉시 저장된다.
13:15
결정적입니다. 7.6분밖에 안 지났는데 들어갔고, 그때 값 표기를 소수에서 정수로 바꿨습니다. 시간만이 아니라 내용이 판정에 들어간다는 증거였습니다.우리가 걸린 이유는 단순했습니다. 시험 중이라 센서 값이 몇 시간째 한 자리도 안 변했습니다. 20초 간격으로 보내는 장비도 전부 저장되는 걸 보면, 실제 센서는 값이 미세하게 계속 변하기 때문입니다.
대응 — 값이 그대로면 흔든다
값이 직전과 한 글자도 다르지 않으면 최소 자리를 +1 / −1 번갈아 흔들어 매 회차를 다르게 만듭니다.
13:45:00 V:00032 ← 값 변화 없음, +1 흔듦
13:50:00 V:00031 ← 직전(32)과 다르므로 그대로
13:55:00 V:00030 ← 값 변화 없음, −1 흔듦
방향을 번갈아야 값이 한쪽으로 밀려나지 않습니다. 그리고 센서 값과 1 차이가 나므로 흔든 회차는 로그에 남깁니다. 나중에 서버 값이 실측과 안 맞을 때 되짚을 수 있어야 하니까요.
다만 이건 우회입니다. 값이 안 변하는 것도 감시에서는 정보고, 센서가 단락돼 같은 값이 굳어버리면 서버가 그걸 인지하지 못합니다. 그래서 수신 측에 “동일 값도 주기마다 기록해달라”고 정식으로 요청했습니다. 받아들여지면 흔들기는 설정 한 줄로 끕니다.
그 사이 잡은 재시작·로그·알람 문제
재시작·로그 유실·알람 하한은 원인을 쫓는 동안 곁가지로 나온 것들인데, 오히려 더 실용적이었습니다.
재시작이 회차를 통째로 먹는다
전송 루프가 이랬습니다.
while True:
time.sleep(self.interval) # 300초를 먼저 잔다
send()
재시작하면 300초가 처음부터 다시 시작됩니다. 설정을 고치느라 5분 안에 재시작하면 그 회차는 아예 안 나갑니다. 형식을 맞추느라 오전 내내 재시작했는데, 그게 서버에서는 “전송 누락”으로 보였습니다.
고친 방향은 두 가지입니다. 하나는 벽시계 정렬 — :00 :05 :10에 보내면 재시작해도 다음 정시는 그대로 옵니다. 다른 하나는 마지막 전송 시각을 파일에 남겨 재시작을 건너 기억하는 것입니다.
since = time.time() - self._read_last_send()
if since >= self.interval:
send_now()
else:
log("마지막 전송 %d초 전 — 기동 전송 없이 다음 정시를 기다립니다" % since)
이게 없으면 반대 문제가 생깁니다. 정시에 보내고 1분 뒤 재시작하면 같은 값이 정시 사이에 한 번 더 끼어들어 목록을 덮습니다.
로그 파일이 사라진다
“재부팅해도 남는 기록”을 만들겠다고 /var/log에 로그를 뒀습니다. 그런데 자꾸 없어졌습니다.
/var가 tmpfs인 것도 문제였지만, 진짜 원인은 따로 있었습니다. 게이트웨이가 30분마다 자기 시스템 로그를 그 폴더에 갈아치우면서 우리 파일까지 지우고 있었습니다.
SD 카드로 옮겼습니다. fstab에 UUID로 등록돼 있어 재부팅해도 자동으로 붙고, 29GB가 비어 있었습니다.
dir = /mnt/mmcblk0p1/agent
require_mount = /mnt/mmcblk0p1 # 카드가 빠졌으면 /var/log 로 물러선다
require_mount가 핵심입니다. 카드 없이 makedirs를 부르면 루트에 같은 이름의 디렉터리가 생기고 6MB뿐인 overlay를 로그로 채웁니다. 그러면 게이트웨이 자체가 이상해집니다.
알람 하한이 복구 알림을 삼킨다
상태가 바뀌면 주기를 기다리지 않고 즉시 보냅니다. 그런데 상태가 떨리는 센서 하나가 접속을 남발하면 수신 서버에 차단당하니, 장치별로 하한을 뒀습니다.
문제는 하한에 걸린 변화를 버리고 있었다는 것입니다.
14:26:33 경보 0→3 즉시 전송 ✅ 서버: 경보
14:26:43 복귀 3→0 9초 전이라 보류 ❌ 서버: 여전히 경보
14:30:00 정기 전송으로야 정상 반영
3분 17초 동안 이미 풀린 경보가 화면에 걸려 있었습니다. 늦게 뜨는 것보다 나쁩니다.
처음엔 “복귀는 하한을 면제하자”고 생각했는데, 계산해 보니 안 됩니다. 1초마다 떨리면 상태가 3,0,3,0…이라 복귀만 면제해도 분당 30회가 나옵니다. 지금(분당 6회)보다 나빠지죠.
답은 버리지 말고 미루는 것이었습니다. 눌린 상태를 들고 있다가 하한이 풀리는 즉시 내보냅니다.
14:26:43 복귀 3→0 → "2초 뒤로 미룸"
14:26:45 → "미뤄 둔 상태 0, 지금 전송"
접속 횟수는 그대로입니다. 내보내는 조건이 하한 그 자체니까요. 떨리는 동안에는 미뤄 둔 값이 최신 것으로 계속 덮어써지므로, 나가는 건 밀린 변화의 행렬이 아니라 하한이 풀린 순간의 상태입니다.
전송 스레드가 조용히 죽을 수 있었다
전송 루프가 데몬 스레드인데 예외 처리가 없었습니다. 예외가 하나 빠져나가면 그 스레드만 죽고 폴링은 계속 돕니다. 프로세스는 살아 있고, 로그에 오류 한 줄 없이 전송만 영영 멈춥니다.
겉모습이 “전송이 안 된다”와 똑같아서 현장에서 나면 원인 찾기가 대단히 어렵습니다. 루프 본문을 try/except로 감싸고 log.exception을 남기도록 했습니다. 한 회차를 버리더라도 다음 주기는 돌아야 하니까요.
남는 것 — 부분 자료로 규칙을 세우면 안 되는 이유
Class C 폴링도, 게이트웨이 안에서 도는 파이썬 에이전트도 기술적으로 새로운 건 없습니다. 둘 다 흔한 구성입니다.
기억에 남는 건 원인을 세 번 잘못 짚은 방식입니다. 세 번 다 같은 실수였습니다 — 부분 자료 몇 줄로 규칙을 세우고, 또 다른 부분 자료로 스스로 반박했습니다. 그때마다 그럴듯한 설명이 나왔고, 그럴듯해서 더 오래 붙들었습니다.
전체를 놓고 한 줄씩 맞추자 13건이 한 번에 맞았습니다. 그 대조를 처음부터 했으면 하루가 아니라 30분이었을 겁니다.
규칙은 양쪽 전체를 1
맞춰서만 세울 것. 부분으로 세운 규칙은 다음 부분에 반증당한다.
그리고 하나 더. 로그에 보낸 내용을 그대로 남겨둔 것이 결국 답을 줬습니다. “무엇을 보냈나”를 추측하지 않아도 됐으니까요. 값을 흔든 회차도 로그에 남기는 이유가 같습니다 — 나중에 실측과 안 맞을 때 되짚을 수 있어야 합니다.
규칙은 양쪽 전체를 1
맞춰서만 세울 수 있고, 부분 자료로 세운 규칙은 다음 부분 자료에 반증당합니다.🔧 시리즈 전체: 1부 — 무수신과 sync word · 2부 — 센서 시뮬레이터 · 3부 — 기다리지 않는 폴러 · 4부 — 케이스 설계기 · 5부 (이 글)
문의
- Email : [email protected]
- Insta : https://www.instagram.com/going.sen/
- Website : https://intosen.com/kr/consult/
댓글
닉네임만 입력하면 바로 댓글을 남길 수 있어요. Google/GitHub 로그인도 가능합니다.