1인 회사 시스템 build

만료된 토큰이 닷새를 먹었다 — 조용히 안 도는 자동화를 찾는 법

만료된 GitHub PAT 때문에 블로그 자동 발행이 닷새간 멈췄던 기록과, 실행이 아니라 산출물로 보는 헬스체크·실패 상태 전환·컨테이너 env 검증을 정리합니다.


초록불이 계속 켜져 있었습니다

8월 중순의 주간 회고를 하다가 블로그에 새 글이 없다는 걸 알아챘습니다. 발행 로그를 되짚어 보니 8월 10일, 12일, 14일 세 번의 자동 발행이 전부 실패해 있었습니다. 발견은 그로부터 닷새 뒤였습니다. 그동안 저는 파이프라인이 멀쩡히 돈다고 믿고 있었습니다.

만료된 건 코드가 아니었습니다. 블로그 자동 발행에 쓰던 GitHub fine-grained PAT의 기한이 지나 있었습니다. 코드는 한 줄도 바뀌지 않았고, 로직도 그대로였습니다. 그저 마지막 문을 여는 열쇠가 조용히 만료됐을 뿐입니다. 손실 자체는 크지 않았습니다 — 블로그 세 편이 제때 나가지 못한 것이 전부입니다. 문제는 손실의 크기가 아니라, 닷새 동안 아무도 몰랐다는 사실이었습니다.

실패는 마지막 한 칸에서 났습니다

파이프라인을 뜯어 보니 실패 지점이 정확히 한 곳이었습니다. 스케줄러는 제시간에 돌았습니다. LLM은 본문을 끝까지 생성했습니다. GitHub Contents API로 커밋하는 마지막 단계에서만 401이 났습니다. 앞 단계가 전부 성공했기 때문에 “돌고 있다”는 신호는 계속 초록이었습니다.

여기서 배운 것이 하나 있습니다. 자동화의 건강을 “실행됐는가”로 보면 안 된다는 것입니다. 스케줄러가 트리거됐는지, 프로세스가 시작됐는지는 앞쪽 신호일 뿐입니다. 정작 봐야 할 것은 산출물이 나왔는가입니다. 저는 “오늘 커밋된 글이 실제로 저장소에 존재하는가”를 확인하는 헬스체크가 없었습니다. 그래서 마지막 한 칸의 실패가 통째로 가려졌습니다.

헬스체크는 실행의 끝에서 산출물을 되짚는 방향으로 짜야 합니다. 예를 들어 이런 식입니다.

# "돌았는가"가 아니라 "결과물이 나왔는가"를 본다
today=$(date +%Y-%m-%d)
latest=$(git -C /srv/blog log -1 --format=%cd --date=short)

if [ "$latest" != "$today" ]; then
  echo "WARN: 오늘($today) 커밋된 글이 없음 (마지막: $latest)"
  # 여기서 알림 — 프로세스가 아니라 산출물 기준
fi

소음이 된 알림, 조용했던 실패

같은 사건을 두 경로가 다르게 처리했다는 점도 뒤늦게 드러났습니다. 블로그 경로는 실패하면 시트 상태를 실패로 바꾸고, 텔레그램으로 알리고, 생성된 초안을 파일로 보존했습니다. 그래서 시도 자체는 멈췄고, 최소한 흔적이 남았습니다.

반면 SNS 발행 경로에는 실패 상태 마킹이 없었습니다. 실패해도 승인 상태가 그대로 유지됐고, 10분 폴링이 같은 행을 계속 집어 갔습니다. 하루에 60번 넘게 같은 실패를 반복했습니다. 알림도 그만큼 반복됐습니다. 처음 몇 번은 신호였지만, 곧 배경 소음이 됐습니다. 아이러니하게도 조용했던 블로그 쪽은 흔적이라도 남겼고, 시끄러웠던 SNS 쪽은 소음에 묻혀 아무 조치도 못 했습니다.

여기서 두 번째 원칙이 나옵니다. 재시도로 풀리지 않는 실패를 반복 재시도로 두면 안 됩니다. 만료된 토큰은 열 번 시도해도 열 번 다 401입니다. 이런 실패는 상태를 실패로 전환해 흐름을 끊고, 사람이 개입해 수동으로 재개하는 편이 낫습니다. 폴링이 물어야 할 것은 “승인됐고 아직 발행 안 된 행”이지 “이미 실패한 행”이 아닙니다.

토큰을 바꾸고, 실제로 바뀌었는지 확인하기

조치는 네 가지였습니다.

첫째, 새 PAT을 발급하고 이번에는 만료일을 달력에 등록했습니다. 토큰·인증서·결제수단처럼 만료가 있는 자격증명을 한자리에 목록화하고, 발급할 때 만료일을 캘린더에 함께 넣는 것을 규칙으로 삼았습니다. 조용히 멈추는 실패의 대부분은 여기서 예방됩니다.

둘째, 컨테이너를 재생성했습니다. 환경변수는 컨테이너 생성 시점에 박혀 들어가기 때문에, 재시작만으로는 옛 토큰이 그대로 유지됩니다. restart가 아니라 up -d --force-recreate가 필요했습니다.

# 재시작(restart)으로는 env가 안 바뀐다 — 재생성해야 한다
docker compose up -d --force-recreate blog-worker

셋째, 컨테이너 안에서 실제 값이 반영됐는지 직접 확인했습니다. 밖에서 .env를 고쳤다고 안심하지 않고, 컨테이너 내부에서 실제로 무엇이 들어 있는지를 봤습니다.

# 컨테이너 안의 실제 값 검증 (앞 4자리만 노출)
docker compose exec blog-worker \
  sh -c 'echo "${GITHUB_TOKEN%${GITHUB_TOKEN#????}}..."'

# 또는 실제로 인증이 통과하는지 최종 확인
docker compose exec blog-worker \
  sh -c 'curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Authorization: Bearer $GITHUB_TOKEN" \
    https://api.github.com/user'

200이 떨어지는 걸 눈으로 보고 나서야 조치를 끝냈습니다.

넷째, SNS 경로에도 실패 시 상태 전환을 추가했습니다. 이제 두 경로 모두 실패하면 상태가 실패로 바뀌고, 폴링이 같은 행을 다시 집지 않습니다.

정리하며

이번 일에서 남은 판단 기준은 세 가지입니다. 자격증명은 만료일을 가진 목록으로 관리하고 갱신을 달력에 걸어 둘 것. 헬스체크는 “실행됐는가”가 아니라 “산출물이 나왔는가”로 볼 것. 재시도로 풀리지 않는 실패는 상태 전환으로 끊고 수동으로 재개할 것. 조용히 안 도는 자동화가 무서운 이유는 실패의 크기가 아니라, 초록불 뒤에서 아무도 모르게 닷새가 지나간다는 데 있습니다.

자주 묻는 질문

자동화가 돌고 있는지만 확인하면 왜 부족한가요?

스케줄러가 제시간에 돌고 앞 단계가 다 성공해도 마지막 한 칸에서만 실패하면 산출물은 나오지 않습니다. "실행됐는가"는 초록으로 보이지만 정작 결과물이 없을 수 있으므로, 헬스체크는 "산출물이 실제로 나왔는가"를 기준으로 잡아야 합니다.

컨테이너 환경변수를 바꿨는데 왜 재시작으로는 안 되나요?

환경변수는 컨테이너 생성 시점에 박혀 들어가기 때문에 재시작만으로는 이전 값이 유지됩니다. 토큰 같은 값을 바꿨다면 컨테이너를 재생성해야 하고, 재생성 후에는 컨테이너 안에서 실제 값이 반영됐는지 직접 확인하는 것이 안전합니다.

자동화 실패를 자동 재시도로 두면 안 되나요?

만료된 토큰처럼 재시도로 풀리지 않는 실패는 폴링이 같은 작업을 반복해서 집어 하루에 수십 번 같은 실패와 알림을 반복하게 됩니다. 이런 실패는 상태를 실패로 전환해 흐름을 끊고 수동으로 재개하는 편이 소음을 막습니다.

만료되는 자격증명은 어떻게 관리하나요?

토큰, 인증서, 결제수단처럼 만료일이 있는 자격증명을 목록으로 만들고 각 만료일을 달력에 등록해 두는 것이 기본입니다. 새 토큰을 발급할 때 만료일을 함께 캘린더에 넣어 두면, 조용히 멈추기 전에 미리 갱신할 수 있습니다.

#자동화#토큰 만료#헬스체크#1인 개발#컨테이너