AI 기반 서비스

Today
-
Total
-
← 목록으로

같은 실수를 반복하다 - Tapo 복구 스크립트에 남아있던 4가지 버그

이창연•2026년 2월 2일

같은 실수를 반복하다

또 알림이 왔다

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 별도 세션
상태 미검증"상태 확인 로직 없음""완료" 로그인데 실제 OFFcycle 후 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가 아니라 지금 당장 고쳐야 할 것들이다.