같은 실수를 반복하다
또 알림이 왔다
2026년 2월 2일 월요일 아침. 텔레그램에 익숙한 알림.
⚡ Spark 전원 꺼짐 감지
• Tapo P110M 전원 사이클 실행 중...
• 플러그 IP: 192.168.50.77
지난번과 똑같은 메시지다. 이번엔 DHCP 예약도 해놨고 IP도 고정이니까 당연히 살아났겠지?
CraftBay MCP에 접속해보니, 연결 실패. Spark Recovery MCP(miniPC)로 상태를 확인했다.
[07:35:32] WARN: CraftBay MCP 헬스체크 실패 (연속 266 회)
[07:35:34] WARN: Spark 서버 ping 실패 - 전원 꺼짐 추정
[07:35:34] INFO: 전원 사이클 이미 실행됨, 부팅 대기 중...
266번 연속 실패. 그리고 또 "전원 사이클 이미 실행됨, 부팅 대기 중..."
기시감이 든다. 지난번이랑 완전히 같은 상황이다.
이번엔 DHCP가 아니다
state.json을 확인:
{
"craftbay": {
"failures": 267,
"power_cycled": true,
"last_power_cycle": "2026-02-02T03:10:32+09:00"
}
}
새벽 3시 10분에 전원 사이클을 실행하고, 4시간 25분 동안 "부팅 대기 중"만 반복한 것이다.
먼저 IP 문제인지 확인했다. Tapo 플러그 자체는 ping이 된다:
$ ping -c 2 192.168.50.77
64 bytes from 192.168.50.77: icmp_seq=1 ttl=255 time=33.6 ms ← 살아있음
$ python3 tapo-spark.py status
장치명: 서버 플러그
전원상태: OFF ← !!!
IP는 정상, 플러그에 정상 접속, 그런데 전원 OFF.
DHCP가 아니면 뭐가 문제였을까?
원인 추적: 03:09 → 03:10의 1분
Watchdog 로그를 정밀하게 확인했다.
[03:09:31] INFO: CraftBay MCP 헬스체크 성공 ← 마지막 정상
[03:10:32] WARN: CraftBay MCP 헬스체크 실패 (연속 1 회)
[03:10:35] WARN: Spark 서버 ping 실패 - 전원 꺼짐 추정
[03:10:36] TELEGRAM: ⚡ Spark 전원 꺼짐 감지
[03:10:41] TAPO: 전원 사이클 완료 (off → 5초 대기 → on)
[03:10:41] TAPO: 전원 사이클 완료, 90초 부팅 대기 필요
[03:11:32] WARN: CraftBay MCP 헬스체크 실패 (연속 2 회)
[03:11:34] INFO: 전원 사이클 이미 실행됨, 부팅 대기 중...
... (이하 4시간 반복) ...
로그상으로는 완벽하다. "전원 사이클 완료"라고 찍혀있다. 그런데 실제 플러그는 OFF 상태였다.
여기서 깨달았다. 이건 지난번 블로그에서 "개선해야 할 것들"로 적어놓고 안 고친 바로 그 버그들이다.
버그 #1: 세션 재사용 — OFF는 됐는데 ON이 안 된다
지난번 포스팅의 "개선해야 할 것들 2번"에 이렇게 적어놓았었다:
전원 사이클 스크립트에 예외 처리 없음 — 에러 시 플러그가 OFF로 남을 수 있음
그리고 개선안 코드까지 적어놨었다. 안 고쳤다.
기존 power_cycle() 코드:
async def power_cycle(delay=5):
device = await get_device() # 한 번만 인증
await device.off() # ✅ OFF 성공
await asyncio.sleep(delay) # 5초 대기
await device.on() # ❌ 같은 세션 — stale 상태 가능
문제는 get_device()를 한 번만 호출해서 하나의 연결 객체로 OFF와 ON을 모두 처리한다는 것. Tapo API는 클라우드 인증 기반이고, OFF 명령 후 5초가 지나면 세션이 stale 상태가 될 수 있다. device.on()이 예외 없이 실패하고, Python은 정상 종료하고, bash는 "완료"를 찍는다.
수정한 코드:
async def power_cycle(delay=5):
# 1단계: OFF (별도 연결)
device_off = await get_device()
await device_off.off()
await asyncio.sleep(delay)
# 2단계: ON (새로운 연결로 재인증)
device_on = await get_device()
await device_on.on()
교훈: IoT API는 요청마다 새 세션을 만들어라.
버그 #2: 확인 없는 "완료" — 로그는 거짓말을 한다
지난번 포스팅의 "개선해야 할 것들 3번":
전원 상태 확인 로직 없음 — 플러그가 실제로 ON인지 확인하는 로직이 필요하다.
기존 bash 코드:
tapo_power_cycle() {
python3 "$TAPO_SCRIPT" cycle 5
log "TAPO: 전원 사이클 완료" # ← exit code도 안 봄
return 0 # ← 무조건 성공
}
Python이 실패해도, exit code를 체크하지 않으므로 "완료"가 찍힌다. 그리고 return 0으로 항상 성공을 반환한다.
수정 후:
tapo_power_cycle() {
if ! python3 "$TAPO_SCRIPT" cycle 5; then
log "ERROR: Tapo cycle 명령 실패"
python3 "$TAPO_SCRIPT" on || true # 안전장치
fi
# 실제 플러그 상태 확인
local plug_state
plug_state=$(python3 "$TAPO_SCRIPT" status_json | jq -r '.device_on')
if [ "$plug_state" = "true" ]; then
log "TAPO: 플러그 ON 확인됨"
return 0
else
log "ERROR: 플러그 상태 비정상 - 강제 ON 재시도"
python3 "$TAPO_SCRIPT" on || true
return 1
fi
}
Python 쪽에서도 cycle 후 상태를 검증하고, 실패 시 재시도 후 sys.exit(1)을 반환하도록 했다.
교훈: "실행했다" ≠ "성공했다". 실제 상태를 확인해야 한다. 지난번에도 같은 말을 했다. 그때 고쳤어야 했다.
버그 #3: 영원히 기다리는 Watchdog
지난번 포스팅의 "개선해야 할 것들 4번":
복구 타임아웃 없음 — 새벽 3시에 전원 사이클 후 5시간 동안 그냥 기다렸다. 일정 시간 후 재시도하는 로직이 필요하다.
이번에도 정확히 같은 일이 벌어졌다. 03:10에 전원 사이클 후, 07:35까지 267회에 걸쳐 "부팅 대기 중..."만 출력했다.
기존 로직:
if [ "$power_cycled" != "true" ]; then
tapo_power_cycle # 딱 한 번
set_state "power_cycled" "true" # 영구 플래그
else
log "전원 사이클 이미 실행됨, 부팅 대기 중..." # ← 무한 반복
fi
power_cycled=true가 한 번 설정되면, 영원히 재시도하지 않는다. 타임아웃도 없다. 10분이든 5시간이든 그냥 기다린다.
수정 후 should_retry_power_cycle() 함수를 추가했다:
POWER_CYCLE_RETRY=10 # 10분 간격 재시도
should_retry_power_cycle() {
local last_cycle=$(get_state "$mcp_name" "last_power_cycle")
local elapsed_min=$(( ($(date +%s) - $(date -d "$last_cycle" +%s)) / 60 ))
[ "$elapsed_min" -ge "$POWER_CYCLE_RETRY" ]
}
그리고 체크 로직도 변경:
if [ "$power_cycled" != "true" ] || should_retry_power_cycle "$mcp_name"; then
# 최초 시도 또는 10분 경과 후 재시도
tapo_power_cycle
fi
이제 전원 사이클이 실패해도 10분 후에 자동으로 재시도한다.
버그 #4: Claude Code 인증 만료
이건 지난번 포스팅에선 없던 새로운 발견이다.
Watchdog에는 Spark이 켜져있지만 MCP 서비스만 죽었을 때 Claude Code로 자동 복구하는 로직이 있다. 이번에도 호출됐어야 했는데, claude.log를 확인해보니:
API Error: 401 {"type":"error","error":{"type":"authentication_error",
"message":"OAuth token has expired. Please obtain a new token or
refresh your existing token."}} · Please run /login
OAuth 토큰이 만료되어 있었다. Claude Code는 인증 토큰에 유효기간이 있고, 갱신하려면 사람이 /login을 실행해야 한다. 무인 서버에서 cron으로 돌리는 스크립트가 OAuth 갱신을 할 수 없으니, 사실상 Claude Code 자동 복구는 언제부턴가 작동하지 않는 상태였다.
이건 구조적으로 해결하기 어렵다. Claude Code의 --dangerously-skip-permissions 모드가 OAuth 갱신을 자동으로 처리하지 않는 한, 정기적으로 사람이 로그인을 갱신해줘야 한다. 당장은 알림만 추가하고, 전원 사이클 재시도 로직에 더 의존하기로 했다.
요약: 지난번에 적어놓고 안 고친 것들
| 버그 | 지난번 포스팅 | 이번에 터진 증상 | 수정 |
|---|---|---|---|
| 세션 재사용 | "예외 처리 없음" | OFF 후 ON 실패, 플러그 OFF 방치 | OFF/ON 별도 세션 |
| 상태 미검증 | "상태 확인 로직 없음" | "완료" 로그인데 실제 OFF | cycle 후 status 검증 |
| 재시도 없음 | "타임아웃 없음" | 4시간 25분 무한 대기 | 10분 간격 자동 재시도 |
| 인증 만료 | (미발견) | Claude Code 백업 복구 무력화 | 인증 모니터링 추가 |
3개는 지난 포스팅에서 이미 파악한 것이고, 1개는 새로 발견한 것이다.
반성
블로그에 "개선해야 할 것들"을 쓰는 건 좋다. 문제는 쓰고 안 고치는 것이다.
지난번에 DHCP 고치고 "해결했다!"는 성취감에 취해서, 나머지 3개는 "다음에 하지 뭐"로 넘겼다. 결과는 2일 뒤 같은 장애 재발.
이번엔 4개를 모두 고쳤다. Watchdog v4.0으로 업데이트:
- tapo-spark.py v2.0: OFF/ON 분리 세션, 상태 검증, 실패 시 재시도
- watchdog.sh v4.0: 전원 상태 확인, 10분 간격 자동 재시도, 실패 알림 강화
이제 진짜 안 죽겠지. 지난번에도 같은 말을 했다.
추신: "개선해야 할 것들"은 TODO가 아니라 지금 당장 고쳐야 할 것들이다.