ZPi Controller 를 만들며 디버깅한 세 가지 함정 — Modbus 포트, mDNS 캐시, Regex `$1`
ZPi Controller 만들면서 만난 세 가지 비-자명한 버그를 정리. 각자 다른 도메인이지만 공통점은 “사용자가 실수해도 망가지지 않는 시스템” 을 만드는 과정에서 드러났다는 것.
함정 1: Modbus 포트 5000 입력 → 웹 UI 통째로 죽음
증상
사용자가 시스템 페이지에서 모드버스 슬레이브 포트를 5000 으로 잘못 입력하고 저장. 웹 UI 가 안 열림. SSH 로 들어가서 보니:
zpi-controller.service: Main process exited, code=killed, status=6/ABRT
Restart=on-failure, restart counter = 27
서비스가 27회 재시작 실패 반복. 로그:
ZPi.IO08R.Plc.Modbus.ModbusTcpSlaveService[0]
Modbus TCP slave listening on port 5000 (unit 1)
Failed to bind to address http://[::]:5000: address already in use.
Modbus 슬레이브가 5000 (HTTP 포트와 충돌) 에 먼저 바인딩 → Kestrel 이 같은 포트 못 잡고 죽음 → systemd 재시작 → 무한 반복. 사용자는 웹에 못 들어와서 설정을 고칠 수도 없다. 치명적.
첫 시도: typed-level 가드
public void Load()
{
_opts = JsonSerializer.Deserialize<ModbusOptions>(...);
if (_opts.TcpSlave.Port == HttpPort) {
_opts.TcpSlave.Port = 502; // auto-correct
Save(_opts);
}
}
배포. 인위적으로 port=5000 강제 주입 후 재시작 → 자동 복구 안 됨.
왜?
GET /api/modbus 로 deserialize 결과 확인:
{
"tcpSlave": {
"enabled": false, ← 사용자가 true 로 설정했는데 false?
"port": 502, ← 5000 으로 설정했는데 default 502?
"unitId": 1, ← 이건 정상
"map": { "p": 0, "m": 4096, ... } ← map 안의 값들은 다 정상
}
}
enabled 와 port 만 default 로 fallback. unitId 와 map 은 정상.
같은 객체 안의 다른 property 들이라 일관성 없는 deserialize 실패.
지금도 정확한 원인은 모르지만 (System.Text.Json + CamelCase
PropertyNamingPolicy + 중첩 객체 조합의 어딘가), 결론은 typed
deserialize 가 일부 필드를 silently drop 한다는 것. 그래서 _opts. TcpSlave.Port 는 항상 default 502, 우리의 typed-level 가드는 영영
트리거 안 됨.
진짜 해결: raw text 단계 사전 처리
deserialize 가 못 믿을 거면 그 전에 막자. 파일을 string 으로 읽어서 정규식으로 직접 치환:
public void Load()
{
var raw = File.ReadAllText(_path);
// Phase 1: raw-text pre-sanitise — bulletproof
var patched = Regex.Replace(
raw,
@"(""port""\s*:\s*)5000(?!\d)",
$"${{1}}{DefaultModbusPort}", // ${1} 명시! $1 로 쓰면 $15 + 02 로 해석됨
RegexOptions.IgnoreCase);
if (patched != raw) {
File.WriteAllText(_path, patched);
Console.Error.WriteLine("[ModbusStore] raw JSON: \"port\": 5000 → 502 (HTTP port collision)");
}
// Phase 2: deserialize as before (now safe)
_opts = JsonSerializer.Deserialize<ModbusOptions>(patched, JsonOpts);
// Phase 3: typed-level guards (belt and braces)
if (_opts.TcpSlave.Port == HttpPort) { ... }
}
3중 방어 (raw text + typed + UI-side validation on Replace()). 인위적
포트 충돌 주입 후 재시작 → 정상 자동 복구:
[ModbusStore] raw JSON: "port": 5000 → 502 (HTTP port collision)
Modbus TCP slave listening on port 502 (unit 1)
Now listening on: http://[::]:5000
HTTP: 302
교훈
- 사용자 입력 검증은 저장 시점 + 로드 시점 둘 다 필요. UI 만 검증하면 SSH 로 직접 편집하거나 옛 버전이 만든 파일에 당함
- typed deserialize 만 신뢰하지 말 것. 치명적 필드는 raw string 단계에서 한 번 더 잡기. “이중 처리” 가 신뢰성 비용 대비 가치 큼
- 자동 복구 로그는 무조건 남기기 — “조용한 마법” 은 디버깅을 망친다
함정 2: mDNS — Pi 호스트명 바꾸면 getent 가 빈값 반환
증상
호스트명 변경 헬퍼 (zpi-set-hostname.sh) 가 hostnamectl + /etc/hosts
업데이트 + systemctl reload-or-restart avahi-daemon 까지 다 하는데:
sudo /usr/local/sbin/zpi-set-hostname.sh going-zpi-XXX
hostname changed: going-zpi → going-zpi-XXX
getent hosts going-zpi-XXX.local
← 빈값!
getent hosts going-zpi.local
192.168.0.x going-zpi.local ← 옛 이름은 아직 살아있음
같은 LAN 의 PC 에서 ping 도 안 됨. 호스트명 바꿨는데 새 이름으로 mDNS 광고가 안 됨.
원인
Debian Trixie 의 avahi-daemon 은 systemctl reload-or-restart 의 “reload”
경로에서 호스트명 변경을 picked up 안 함. reload 가 성공 리턴해도
실제로는 stale 광고를 그대로 유지. fully restart 가 필요.
해결
# 헬퍼 마지막 부분
systemctl restart avahi-daemon 2>/dev/null || \
systemctl reload-or-restart avahi-daemon 2>/dev/null || true
# 자체검증
sleep 1
if getent hosts "${NEW}.local" > /dev/null 2>&1; then
echo "hostname changed: $OLD -> $NEW (mDNS verified)"
else
echo "warning: ${NEW}.local not yet resolving — avahi may need another second"
fi
restart 가 살짝 더 무겁지만 (1초 추가) 안정성 비교가 안 됨. 자체검증
한 줄로 “성공했는지” 확실히 알 수 있게.
교훈
reloadvsrestart의미는 데몬마다 다름. 재시작 후 동작 검증 까지 helper 에 포함하는 게 안전- mDNS 는 의외로 캐시 / 광고 갱신이 까다로움. 호스트명 같은 핵심 속성을 바꾸면 그냥 restart 추천
함정 3: C# Regex.Replace 의 $1 가 $15 로 해석됨
함정 1 의 부산물. raw text 정규식으로 "port": 5000 을 "port": 502
로 치환했는데 결과 JSON 이 깨졌다:
{
"tcpSlave": {
"port": $1502, ← 무엇??
...
}
}
코드:
var patched = Regex.Replace(
raw,
@"(""port""\s*:\s*)5000(?!\d)",
$"$1{DefaultModbusPort}", // $1 + 502 = ???
RegexOptions.IgnoreCase);
C# 의 $"..." (interpolated string) 안에서 $1 은 그냥 문자열 $1 그대로.
{DefaultModbusPort} 는 보간되어 502. 결과 replacement string:
"$1502"
이걸 Regex.Replace 가 받으면 $1, $15, $150, $1502 중 어느 것이
capture group 번호인지 모름. 문서상:
“Substitutions that use a value greater than the highest number of capturing groups are interpreted as literal text.”
근데 실제로는 $1502 가 group 1502 로 시도 → 없으니 literal? group 15
로 시도 → 없으니 literal? 정확한 동작이 모호하고, 우리 케이스에서는
$1 가 group 1 으로 매치 + 502 가 literal 이 되지 않고 그냥 통째로
literal 처리됨.
해결: ${1} 명시
C# Regex 의 documented 방법:
$"${{1}}{DefaultModbusPort}"
// ↑ ${1} 명시적 group reference
// ↑↑↑ C# 의 `{{` → `{` literal
// 결과 replacement string: "${1}502"
이제 ${1} 가 capture group 1, 502 가 literal — 의도대로.
교훈
Regex.Replacereplacement 의$패턴은 PowerShell escape 만큼 까다로움- 숫자 literal 이 group 번호 뒤에 올 때는 반드시
${N}명시 - 이 함정은 다른 언어 (PowerShell, sed, perl) 에서도 마찬가지
정리
세 함정 다 “그냥 동작할 것” 같았지만 안 했다. 공통 패턴:
- 검증을 여러 layer 에서: Modbus 포트는 UI 저장 시점 / 디스크 raw text 단계 / typed deserialize 후 — 3중 방어
- 재시작 후 자체검증: mDNS 변경 같은 비-즉시 효과 동작은 helper 가 직접 확인까지 책임
- escape 가 모호한 곳은 명시적 표기:
${1}같은 명시형은 길어 보여도 디버깅 시간 아낌
다 만들고 나면 “당연한 것” 인데, 만들기 전에는 모름.
댓글
로그인 없이 이름만 적고 남기실 수 있어요. 남긴 댓글은 바로 게시됩니다.
댓글을 불러오는 중…