시리즈: 날씨 시계를 Codex·Claude 사용량 모니터로 바꾸기
1편: 하드웨어와 화면
2편: MQTT TLS와 안정적인 운영 (이 글)
1편에서는 SD Pro를 두 서비스의 사용량 화면으로 바꿨다. 이번에는 Mac의 데이터를 어디로 보내고, 기기가 어떻게 안전하게 받아오며, 재로그인 후에도 계속 갱신되게 했는지를 정리한다. 서버를 띄우는 것보다 각 구간을 나눠 확인하는 과정이 중요했다. 글에 나오는 도메인은 example.com 예시다.

먼저 큰 순서부터 적어 둔다. 서버 → Mac → 기기 순서로 한 구간씩 확인하며 진행한다.
clock-bridge로 발행하고 clock-device로 구독해 tls-test가 오는지 본다.--once로 실제 JSON을 한 번 보낸다.
원격 Ubuntu 서버에는 이미 lscr.io/linuxserver/swag:latest가 여러 웹사이트를 서비스하고 있었다. SWAG는 Cloudflare DNS 방식으로 wildcard 인증서를 발급하고 80/443을 사용했다. MQTT를 위해 기존 웹서비스나 SWAG Compose를 바꾸고 싶지는 않았다.
최종 구조는 Mosquitto가 별도 TCP 8883에서 직접 TLS를 종료하고, 기존 SWAG 인증서를 읽기 전용으로 재사용하는 방식이다. MQTT 때문에 새 Certbot을 운영하거나 Nginx stream을 추가할 필요가 없었다. 1883 평문 포트도 열지 않았다. Mosquitto 클라이언트 도구도 Docker Compose로 실행했다.
SWAG: 기존 wildcard 인증서 발급·갱신
↓ 읽기 전용
cert-init / cert-sync → 인증서 사본 볼륨 → Mosquitto :8883
↑ ↑
Mac bridge SD Pro
SWAG 설정은 그대로 두고 조회만 한다.
docker inspect swag --format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}'
docker exec swag ls -1 /config/etc/letsencrypt/live
docker exec swag openssl x509 -in /config/keys/letsencrypt/fullchain.pem \
-noout -dates -ext subjectAltName
여기서 나온 호스트 절대 경로와 live 폴더 이름을 MQTT 쪽 .env에 적는다.
SWAG_LETSENCRYPT_DIR=/opt/swag/config/etc/letsencrypt
SWAG_CERT_NAME=example.com
SAN에 *.example.com이 있으면 mqtt.example.com은 포함되지만 clock.mqtt.example.com은 아니다. SWAG의 live 인증서는 archive로 연결되는 symlink를 사용하므로 letsencrypt 디렉터리 전체를 읽기 전용으로 마운트했다. PEM 파일 하나만 bind mount하면 갱신으로 교체된 파일을 놓칠 수 있다. 반면 Cloudflare 토큰이 들어 있는 전체 SWAG 설정 디렉터리(dns-conf 포함)까지 공유하지는 않았다.
| 파일 | 역할 |
compose.swag.yaml |
인증서 최초 복사, TLS 브로커, 갱신 감시, 암호 도구 |
mosquitto.swag.conf |
TLS 8883, 익명 거부, 계정/ACL |
sync-swag-certificates.py |
인증서·키 쌍 검증, 사본 권한 설정, 바뀌면 재로드 |
acl |
clock-bridge 발행 전용, clock-device 구독 전용 |
compose.client.yaml |
외부 호스트에서 TLS 접속 확인 |
파일 이름이 기본값과 다르므로 모든 서버 명령에 -f compose.swag.yaml을 붙인다. 다른 기본 Compose 파일을 읽는 실수를 피하려고 최종 구성만 따로 보관했다. SWAG의 websvr 네트워크에 붙일 필요도 없다. 이 프로젝트가 만든 네트워크와 볼륨만 쓰고, 8883만 공개하므로 SWAG의 80/443과 충돌하지 않는다.
cert-init이 최초 복사를 완료한 뒤 브로커가 시작한다. 복사가 실패하면 브로커는 뜨지 않는다. cert-sync는 60초마다 원본을 검사하고, 인증서와 키가 실제 쌍인지 확인한 뒤에만 사본을 바꾸고 Mosquitto에 SIGHUP을 보낸다. 검증에 실패하면 기존 사본을 유지하고 다음 주기에 다시 시도한다. Mosquitto의 PID namespace만 공유하며 Docker 소켓은 마운트하지 않는다. 발급·갱신은 SWAG가, MQTT 적용은 동기화 컨테이너가 맡는다.
계정도 분리했다. clock-bridge는 사용량 토픽에 발행만, clock-device는 구독만 가능하다. 토픽은 llm-usage/summary이고 익명 접속은 막았다. 시계가 AI 계정의 토큰을 가질 필요는 없다.
docker compose -f compose.swag.yaml config --quiet
sudo chown 1883:1883 acl
sudo chmod 600 acl
docker compose -f compose.swag.yaml run --rm --no-deps --user 0:0 mqtt-tools sh -c \
'chown 1883:1883 /mosquitto/auth && chmod 700 /mosquitto/auth'
docker compose -f compose.swag.yaml run --rm --no-deps --user 1883:1883 mqtt-tools sh -c \
'umask 077; mosquitto_passwd -c /mosquitto/auth/passwords clock-bridge && mosquitto_passwd /mosquitto/auth/passwords clock-device'
docker compose -f compose.swag.yaml up -d
여기서 권한 경고 때문에 한 번 헤맸다. Mosquitto 2.0.22는 ACL·암호 파일의 소유자를 실행 중인 프로세스 사용자와 비교한다. 브로커는 UID 1883으로 돌기 때문에 파일을 root 소유로 바꾸면 브로커가 못 읽고, 반대로 root로 mosquitto_passwd를 돌리면 경고가 난다. 그래서 암호 도구도 --user 1883:1883으로 실행하도록 맞췄다. 기존 암호를 바꿀 때는 -c를 빼야 다른 계정이 남는다.
암호 해시, 인증서 사본, retained 메시지는 named volume에 저장한다. .env와 비밀키는 Git에 올리지 않고, down -v로 운영 볼륨을 지우지 않는다.
고정 공인 IP 자체가 TLS의 필수 조건은 아니다. 인증서는 DNS 이름을 검증하므로 도메인이 현재 서버 주소를 가리키면 된다. 유동 IP라면 DNS를 갱신해야 하고 TCP 8883도 도달 가능해야 한다. Cloudflare의 일반 웹 프록시를 이용하는 대신 MQTT 레코드는 DNS only(회색 구름)로 두었다. Docker로 공개한 포트는 UFW를 우회할 수 있으니 클라우드 방화벽과 공유기 포트포워딩도 함께 확인한다.
개발 중 내부 컨테이너 이름으로 TLS는 성공하는데 공인 도메인으로는 timeout이 나는 경우가 있었다. 이런 결과만으로 인증서 문제라고 단정할 수 없다. 외부 네트워크에서 다시 검사하고 포트포워딩·방화벽·NAT loopback을 구분해야 한다. 이번에도 외부 TLS 검사와 발행/구독으로 실제로 도달하는 것을 확인했다.
확인 순서는 다음처럼 나누었다.
tls-test가 전달되는지 확인한다.
3번은 외부 Docker 호스트에서 터미널 두 개로 확인했다. 호스트에 MQTT 클라이언트를 설치할 필요가 없다.
# 터미널 A: 구독
docker compose -f compose.client.yaml run --rm --no-deps mqtt-tools \
mosquitto_sub -h mqtt.example.com -p 8883 \
--cafile /etc/ssl/certs/ca-certificates.crt \
-u clock-device -P 'DEVICE_PASSWORD' -t llm-usage/summary -q 1 -C 1
# 터미널 B: 발행 (시험 문자열은 retained로 남기지 않는다)
docker compose -f compose.client.yaml run --rm --no-deps mqtt-tools \
mosquitto_pub -h mqtt.example.com -p 8883 \
--cafile /etc/ssl/certs/ca-certificates.crt \
-u clock-bridge -P 'BRIDGE_PASSWORD' -t llm-usage/summary -q 1 -m tls-test
익명, 틀린 암호, 허용되지 않은 방향의 접근이 거부되는지도 함께 봤다. tls-test는 시계용 JSON이 아니므로 시계는 거부하는 게 정상이다.
브로커 로그에는 기본적으로 메시지 본문이 출력되지 않는다. 접속 로그만 보고 데이터가 안 왔다고 판단하지 말고, 구독 클라이언트에서 JSON을 확인해야 했다. --once 브리지는 발행 후 연결을 닫으므로 접속 직후 disconnect도 정상 동작일 수 있다.

서버와 Mac에서 정상 데이터를 확인했지만 처음 기기는 Not configured / waiting에 머물렀다. 웹의 /status를 보니 한 번은 MQTT off, 설정 후에는 MQTT low memory였다. 화면 문구만으로는 설정 문제와 연결 문제를 구분하기 어려웠다.
첫 TLS 코드에는 연결 전 힙이 35,000 B 이상이어야 한다는 조건이 있었다. 실제 기기는 32,608 B 정도여서 아예 연결을 시도하지 못했다. 그렇다고 제한 숫자만 낮추면 연결 중 할당에 실패하거나 불안정해질 수 있다.
해결은 서버의 TLS Maximum Fragment Length Negotiation, MFLN 지원을 확인하는 것이었다. MQTT JSON이 1024 B라고 TLS 레코드도 작게 온다고 보장되지는 않는다. 서버와 작은 fragment를 협상할 수 있어야 수신 버퍼를 안전하게 줄일 수 있다.
최종 코드는 서버의 MFLN 4096 지원을 먼저 확인하고, 실제 연결에서도 협상 결과를 검증한다. 성공한 경우 RX 4096/TX 512 버퍼를 사용한다. 지원하지 않는 서버는 RX 16384를 그대로 쓰고, 여유가 있을 때만 연결한다. 기준은 초기 객체 생성 전 24 KB, 이후 작은 버퍼 경로 16 KB, 큰 버퍼 경로 28 KB다. 가용 힙뿐 아니라 최대 연속 블록도 검사한다. 이 과정에서 인증서 검증을 끄거나 평문 MQTT로 바꾸지는 않았다.
ESP8266 쪽은 TLS 1.2를 사용한다. 서버의 Python 점검에서 TLS 1.3이 나왔더라도 기기까지 TLS 1.3을 쓰는 것은 아니다. NTP로 시각을 맞춘 뒤 CA·호스트명을 검증하고 MQTT에 연결한다.
수정 후 실제 기기에서 Codex와 Claude의 사용량이 표시되는 것을 확인했다. 이후 OTA를 시작할 때는 TLS 버퍼를 먼저 해제하도록 해 업데이트 공간도 확보했다. 추가 기능을 넣을 때도 flash와 실행 중 메모리를 함께 확인해야 한다.
Mac에서는 두 프로세스가 필요하다. open-usage daemon이 원본 캐시를 갱신하고 Python 브리지가 그 캐시를 읽어 약 5분마다 MQTT로 발행한다. 브리지만 켜두면 오래된 캐시를 계속 보낼 수 있으므로 둘 다 확인해야 한다.
open-usage daemon start --interval 5
.venv/bin/python scripts/openusage_bridge.py --config bridge.local.json --dry-run
.venv/bin/python scripts/openusage_bridge.py --config bridge.local.json --once
--dry-run: 캐시 변환 결과만 출력하고 네트워크로는 보내지 않는다.--once: 실제 메시지 하나를 retained로 보내고 끝난다.
bridge.local.json에는 브로커 호스트, 포트, clock-bridge 암호를 넣고 chmod 600으로 둔다. cafile을 생략하면 Python 시스템 CA를 쓴다. 펌웨어 CA는 따로 빌드되므로 이 값이 시계에 전파되지는 않는다.
macOS LaunchAgent에 두 작업을 등록해 로그인 때 시작하고 종료 시 다시 실행하게 했다(30초 throttle). 가상환경 Python과 스크립트·설정 경로는 절대 경로로 지정했다. 터미널과 다른 PATH 때문에 원본 CLI를 찾지 못하는 문제도 피해야 한다. plist는 스크립트로 생성하고 암호는 넣지 않는다.
.venv/bin/python scripts/render_launchagents.py
plutil -lint build/launchagents/*.plist
cp build/launchagents/io.llm-monitor.*.plist ~/Library/LaunchAgents/
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/io.llm-monitor.open-usage.plist
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/io.llm-monitor.bridge.plist
소스를 새 위치로 옮겼다고 기존 자동 실행 경로가 바뀌지는 않는다. 예전 작업을 내리지 않고 새 작업을 추가하면 중복 발행이 생길 수 있다. 그래서 launchctl bootout으로 이전 작업을 내리고, 이전 plist도 ~/Library/LaunchAgents/ 밖으로 옮겨 다음 로그인에 다시 뜨지 않게 했다. open-usage daemon stop만으로는 KeepAlive 작업이 다시 살아난다.
동작 확인은 launchctl print의 state = running과 브리지 로그의 Published Codex/Claude usage로 한다. 코드를 바꾼 뒤에는 launchctl kickstart -k로 바로 재시작한다. 로그 rotation은 없으니 오래 쓰면 크기를 한 번씩 본다.
또한 로그인 자동 실행은 Mac이 꺼졌거나 잠든 동안의 갱신을 보장하지 않는다. 브로커의 retained 값과 기기의 STALE 표시로 이 상태를 구분한다. 24시간 갱신이 필요하면 항상 켜진 환경에서 원본 조회가 가능한지부터 다시 설계해야 한다.

기기의 http://기기IP/status에서 mqtt, time, heap, heapLow, heapMaxBlock, resetReason을 본다.
| 상태 | 확인할 것 |
MQTT off |
기기 로그인 → enabled=true → Save |
Waiting for NTP |
NTP 서버·DNS·UDP 123 접근 |
MQTT low memory |
MFLN 수정본인지, heap과 최대 블록 |
TLS needs MFLN / more RAM |
서버 MFLN 지원과 접속 경로 |
TLS: ... |
CA·인증서 체인, DNS 이름, NTP, 만료일 |
MQTT failed: 4 / 5 |
사용자명·암호·권한 (시계는 clock-device) |
| 연결됐는데 계속 대기 | 토픽, retained JSON. 브리지 --once로 재발행 |
Invalid MQTT data |
1024 B, 필수 필드, 범위. tls-test 거부는 정상 |
STALE |
Mac 깨어 있는지, daemon 갱신과 브리지 로그 |
Not configured / waiting은 시계에서 AI 로그인을 하라는 뜻이 아니다. 아직 그 카드에 유효한 데이터가 없다는 뜻이다. AI 로그인은 Mac의 원래 CLI에서 관리한다.
최종 저장소에는 펌웨어 소스뿐 아니라 고정한 라이브러리 버전, 브리지, SWAG 기반 Compose, 호스트 테스트와 화면 미리보기, 동작 확인 바이너리와 SHA-256을 함께 보관했다. 암호·Wi-Fi 설정·사용량 캐시·인증서 비밀키는 제외했다.
업그레이드할 때는 다음 순서를 지키기로 했다.
/status를 기록한다.--dry-run → 서버 구독 + --once → 기기 OTA → 실제 화면 순서로 확인한다.
실기기 표시와 서버 송수신은 확인했지만 인증서 실제 갱신 주기와 재부팅을 포함한 장기 운영은 계속 확인해야 한다. 특히 open-usage 캐시 형식과 CA, 라이브러리 업데이트는 각 구간의 계약에 영향을 준다.
작은 화면에 숫자 두 줄을 띄우는 프로젝트였지만 하드웨어 배치, OTA 공간, TLS 버퍼, 데이터 신선도, 자동 실행이 모두 연결되어 있었다. 화면이 갱신되지 않을 때 어느 구간부터 살펴봐야 하는지 기록해둔 것이 다음 업그레이드에도 도움이 될 것 같다.
이전 글: 1편: 하드웨어와 화면
※ 이 글은 AI의 도움을 받아 작성했고, 작성자가 내용을 직접 검토한 뒤 올렸다.
| 날씨 시계를 Codex·Claude 사용량 모니터로 바꾸기 — 1편: 하드웨어와 화면 (0) | 2026.11.03 |
|---|---|
| 토스증권 Open API로 나만의 포트폴리오 리포트 만들기 (2) — Claude Code·Codex CLI로 종목 AI 분석 붙이기 (0) | 2026.10.28 |
| 토스증권 Open API로 나만의 포트폴리오 리포트 만들기 (1) — 매일 쌓고, 과거는 복원하고 (0) | 2026.10.28 |
| 맥북 한 대로 AI 개발팀 굴리기 (3) — Agent Office와 삽질 모음 (0) | 2026.10.28 |
| 맥북 한 대로 AI 개발팀 굴리기 (2) — 사용량 관리와 모델 배치 (0) | 2026.10.28 |
댓글 영역