자동화 헬스체크 스크립트 — 돌아가는 자동화들을 한 번에 점검하기

자동화가 하나둘 늘어나면 “잘 돌고 있겠지”라는 믿음도 같이 늘어납니다. 이 블로그는 매일 정해진 시각에 발행 작업이 돌고, 실행할 때마다 logs/ 폴더에 날짜별 로그를 남깁니다. 이번에 이 로그들을 한 번에 훑는 자동화 헬스체크 스크립트를 만들어 직접 돌려보니, 로그 48개 중 9개가 정상 종료가 아니었습니다. 7개는 종료 코드 1로 끝났고, 2개는 종료 표시 자체가 없었습니다. 실패 알림이 간 날도 있었지만, 한자리에 모아 보기 전까지는 이 정도 비율인 줄 몰랐습니다. 이 글에서는 파이썬 표준 라이브러리만으로 헬스체크 스크립트를 만들면서 실제로 만난 문제 두 가지와, 실패 경로까지 시험한 결과를 정리합니다.

헬스체크에서 무엇을 볼까

“자동화가 건강하다”는 말을 확인 가능한 질문으로 바꾸는 게 첫 단계입니다. 제 경우 발행 작업에 대해 네 가지를 물었습니다.

점검 항목질문판정
로그 최신성최신 로그가 26시간 안에 생겼나넘으면 FAIL (아예 안 돌았음)
마지막 실행 완료최신 로그에 종료 표시가 있고 코드가 0인가표시 없음 WARN, 0 아님 FAIL
디스크 여유로그가 쌓이는 드라이브에 10GB 이상 남았나부족하면 WARN
사이트 응답블로그 피드가 HTTP 200을 돌려주나실패하면 FAIL

26시간은 매일 한 번 도는 작업 기준으로 하루에 여유 2시간을 더한 값입니다. 정각에 딱 맞추면 실행이 몇 분만 늦어도 경보가 울리기 때문입니다. 종료 표시는 발행 러너가 로그 마지막 줄에 남기는 형식을 그대로 씁니다.

2026-08-27 09:07:29  알림: SENT: 콘텐츠 발행 / 발행 실패 (1건 전송)
2026-08-27 09:07:37  알림: SENT: 콘텐츠 발행 / 일일 작업 결과 보고 (1건 전송)
2026-08-27 09:07:37  === 종료 (exit 1) ===

이렇게 “끝났다”는 줄을 로그에 남기는 습관이 있으면, 헬스체크는 그 줄을 정규식으로 찾기만 하면 됩니다. 아직 이런 줄이 없는 자동화라면 스크립트 마지막에 한 줄 추가하는 것부터 시작하는 게 좋습니다.

완성 스크립트

점검 하나를 함수 하나로 만들고, 각 함수가 (수준, 메시지)를 돌려주게 했습니다. 마지막에 가장 나쁜 수준을 종료 코드로 내보내서 OK면 0, WARN이면 1, FAIL이면 2로 끝납니다. 아래는 실제로 실행한 파일 그대로입니다.

import re
import shutil
import sys
import time
import unicodedata
import urllib.request
from datetime import datetime
from pathlib import Path

LOG_DIR = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("logs")
SITE = "https://codetipbox.com/feed/"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"
MAX_AGE_HOURS = 26      # 매일 도는 작업이면 하루 + 여유 2시간
MIN_FREE_GB = 10

OK, WARN, FAIL = 0, 1, 2
LABEL = {OK: "OK  ", WARN: "WARN", FAIL: "FAIL"}


def check_log_fresh():
    logs = sorted(LOG_DIR.glob("publish-*.log"), key=lambda f: f.stat().st_mtime)
    if not logs:
        return FAIL, f"{LOG_DIR} 에 로그가 없습니다"
    latest = logs[-1]
    age_h = (time.time() - latest.stat().st_mtime) / 3600
    level = OK if age_h <= MAX_AGE_HOURS else FAIL
    return level, f"최신 로그 {latest.name} ({age_h:.1f}시간 전)"


def check_log_finished():
    latest = max(LOG_DIR.glob("publish-*.log"), key=lambda f: f.stat().st_mtime)
    text = latest.read_text(encoding="utf-8-sig", errors="replace")
    m = re.search(r"=== 종료 \(exit (-?\d+)\) ===", text)
    if not m:
        return WARN, f"{latest.name} 에 종료 표시가 없습니다 (실행 중이거나 중간에 죽음)"
    code = int(m.group(1))
    return (OK if code == 0 else FAIL), f"{latest.name} 종료 코드 {code}"


def check_disk():
    free_gb = shutil.disk_usage(LOG_DIR.resolve().anchor).free / 1024**3
    level = OK if free_gb >= MIN_FREE_GB else WARN
    return level, f"여유 공간 {free_gb:.1f}GB (기준 {MIN_FREE_GB}GB)"


def check_site():
    req = urllib.request.Request(SITE, headers={"User-Agent": UA})
    start = time.perf_counter()
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            status = r.status
    except Exception as e:  # 연결 실패·4xx·5xx 모두 여기로
        return FAIL, f"{SITE} 접속 실패: {e}"
    ms = (time.perf_counter() - start) * 1000
    return (OK if status == 200 else FAIL), f"HTTP {status}, {ms:.0f}ms"


CHECKS = [
    ("로그 최신성", check_log_fresh),
    ("마지막 실행 완료", check_log_finished),
    ("디스크 여유", check_disk),
    ("사이트 응답", check_site),
]


def pad(text, width):
    # 한글은 터미널에서 두 칸을 차지하므로 :<10 대신 실제 표시 폭으로 맞춥니다
    w = sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in text)
    return text + " " * max(0, width - w)


def main():
    sys.stdout.reconfigure(encoding="utf-8")  # 파이프·스케줄러로 넘길 때 CP949로 깨지는 것 방지
    print("헬스체크", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
    worst = OK
    for name, fn in CHECKS:
        try:
            level, msg = fn()
        except Exception as e:  # 점검 함수 하나가 죽어도 나머지는 계속
            level, msg = FAIL, f"점검 자체가 실패: {type(e).__name__}: {e}"
        worst = max(worst, level)
        print(f"  [{LABEL[level]}] {pad(name, 16)} {msg}")
    print("결과:", LABEL[worst].strip())
    sys.exit(worst)


if __name__ == "__main__":
    main()

설계에서 신경 쓴 부분은 두 가지입니다. 첫째, main()에서 점검 함수마다 try/except로 감쌌습니다. 헬스체크 스크립트 자체가 예외로 죽어버리면 정작 다른 점검 결과까지 사라지기 때문입니다. 둘째, 점검을 CHECKS 목록에 함수로 등록하는 구조라서, 자동화가 하나 늘면 함수 하나와 목록 한 줄만 추가하면 됩니다.

첫 실행: 한글이 전부 깨졌다

처음 버전에는 sys.stdout.reconfigure 줄이 없었습니다. 소요 시간을 재려고 작은 실행기 스크립트로 감싸서 돌렸더니 출력이 이렇게 나왔습니다.

�コüũ 2026-10-08 10:02:09
  [OK  ] �α� �ֽż�     �ֽ� �α� publish-2026-10-08.log (0.0�ð� ��)
  [OK  ] ��ũ ����     ���� ���� 1652.2GB (���� 10GB)
  [OK  ] ����Ʈ ����     HTTP 200, 977ms

숫자와 영문(1652.2GB, HTTP 200)만 멀쩡하고 한글만 깨진 게 단서입니다. 윈도우의 파이썬은 출력을 콘솔이 아니라 파이프로 다른 프로그램에 넘길 때 시스템 코드페이지(한국어 윈도우는 CP949)로 인코딩합니다. 받는 쪽은 UTF-8로 읽으니 깨질 수밖에 없습니다. 작업 스케줄러에서 출력을 파일로 리다이렉트할 때도 같은 구조입니다. main() 첫 줄에 sys.stdout.reconfigure(encoding="utf-8")를 넣어 해결했습니다.

두 번째 실행: 열이 들쭉날쭉

인코딩을 고치니 이번에는 정렬이 어긋났습니다. 처음엔 이름 열을 f"{name:<10}"로 채웠는데, 이건 글자 수 기준이라 한글처럼 화면에서 두 칸을 차지하는 글자가 섞이면 맞지 않습니다.

  [OK  ] 로그 최신성     최신 로그 publish-2026-10-08.log (0.0시간 전)
  [WARN] 마지막 실행 완료  publish-2026-10-08.log 에 종료 표시가 없습니다 (실행 중이거나 중간에 죽음)
  [OK  ] 디스크 여유     여유 공간 1652.7GB (기준 10GB)

그래서 unicodedata.east_asian_width()로 글자 폭을 확인해 W(전각)·F(전폭)는 2칸, 나머지는 1칸으로 세는 pad() 함수를 만들었습니다. 사소해 보이지만, 매일 눈으로 훑는 출력이라면 정렬이 맞아야 FAIL 한 줄이 바로 눈에 들어옵니다.

최종 결과: 오늘의 WARN은 진짜였다

프로젝트 루트에서 실제 로그 폴더를 대상으로 돌린 결과입니다. 전체 1.13초가 걸렸고, 대부분은 사이트 응답을 기다린 시간(약 0.95초)이었습니다.

헬스체크 2026-10-08 10:02:58
  [OK  ] 로그 최신성      최신 로그 publish-2026-10-08.log (0.0시간 전)
  [WARN] 마지막 실행 완료 publish-2026-10-08.log 에 종료 표시가 없습니다 (실행 중이거나 중간에 죽음)
  [OK  ] 디스크 여유      여유 공간 1652.9GB (기준 10GB)
  [OK  ] 사이트 응답      HTTP 200, 950ms
결과: WARN
exit: 1, 소요 1.13초

WARN이 하나 떴는데, 오탐이 아니었습니다. 오전 10시에 시작한 오늘 발행 작업, 즉 바로 이 글을 쓰고 있는 작업이 아직 실행 중이라 오늘 로그에 종료 표시가 없었던 겁니다. 이 결과에서 한계도 하나 드러났습니다. "아직 실행 중"과 "중간에 죽음"은 로그만으로는 구분이 안 됩니다. 그래서 이 항목은 FAIL이 아니라 WARN으로 두었고, 헬스체크는 작업이 끝났을 시각 이후로 잡는 게 맞습니다.

실패 경로도 시험해보기

점검 스크립트는 문제가 있을 때 제대로 울리는지가 핵심입니다. 그래서 실패 상황을 일부러 만들었습니다. 먼저 종료 코드 1로 끝난 로그 하나만 있는 폴더를 만들고, os.utime으로 수정 시각을 72시간 전으로 돌려놓았습니다.

헬스체크 2026-10-08 10:03:07
  [FAIL] 로그 최신성      최신 로그 publish-2026-10-05.log (72.0시간 전)
  [FAIL] 마지막 실행 완료 publish-2026-10-05.log 종료 코드 1
  [OK  ] 디스크 여유      여유 공간 1652.9GB (기준 10GB)
  [OK  ] 사이트 응답      HTTP 200, 917ms
결과: FAIL
exit: 2, 소요 1.09초

두 항목이 FAIL로 잡혔고 종료 코드는 2였습니다. 다음은 아예 없는 폴더를 지정한 경우입니다.

  [FAIL] 로그 최신성      drafts\evidence\run\no_such_dir 에 로그가 없습니다
  [FAIL] 마지막 실행 완료 점검 자체가 실패: ValueError: max() iterable argument is empty
  [OK  ] 디스크 여유      여유 공간 1652.9GB (기준 10GB)
  [OK  ] 사이트 응답      HTTP 200, 903ms

두 번째 점검 함수는 빈 목록에 max()를 호출하다 ValueError로 죽었지만, try/except 덕분에 FAIL 한 줄로 기록되고 나머지 점검은 끝까지 돌았습니다. 사실 이건 제 코드의 빈틈이기도 합니다. 첫 번째 함수처럼 빈 경우를 먼저 확인하면 더 친절한 메시지가 나옵니다. 그래도 예외를 잡아두는 안전망이 있어서, 이런 빈틈이 있어도 헬스체크 전체가 멈추지는 않는다는 걸 확인했습니다.

덤: 과거 로그 전수 점검

헬스체크는 "지금" 상태만 봅니다. 지난 기록 전체에서 실패가 얼마나 있었는지 보려고, 같은 정규식으로 모든 로그를 훑는 짧은 스크립트도 만들었습니다.

import re
import sys
from pathlib import Path

sys.stdout.reconfigure(encoding="utf-8")
LOG_DIR = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("logs")
END = re.compile(r"=== 종료 \(exit (-?\d+)\) ===")

logs = sorted(LOG_DIR.glob("publish-*.log"))
no_end, bad_exit = [], []
for f in logs:
    m = END.search(f.read_text(encoding="utf-8-sig", errors="replace"))
    if not m:
        no_end.append(f.name)
    elif m.group(1) != "0":
        bad_exit.append((f.name, m.group(1)))

print("로그", len(logs), "개")
print("종료 표시 없음", len(no_end), "개:", ", ".join(no_end))
print("종료 코드 0 아님", len(bad_exit), "개:", ", ".join(f"{n}({c})" for n, c in bad_exit))
로그 48 개
종료 표시 없음 2 개: publish-2026-09-25.log, publish-2026-10-08.log
종료 코드 0 아님 7 개: publish-2026-08-20.log(1), publish-2026-08-21.log(1), publish-2026-08-22.log(1), publish-2026-08-27.log(1), publish-2026-08-28.log(1), publish-2026-08-29.log(1), publish-2026-09-21.log(1)
exit: 0, 소요 0.13초

도입부의 숫자가 이 결과입니다. 48개 중 종료 코드 1이 7개, 종료 표시 없음이 2개였습니다. 종료 표시가 없는 9월 25일 로그는 원본을 열어보니 "슬롯 1/2" 줄 다음에 아무것도 없이 끝나 있었습니다. 실행이 중간에 끊긴 겁니다. 나머지 하나는 위에서 본 오늘 실행 중인 로그입니다. 0.13초면 끝나는 점검이라 한 달에 한 번쯤 돌려보면 실패가 특정 기간에 몰려 있는지(이 블로그는 8월 하순에 몰려 있었습니다) 같은 패턴이 보입니다.

헬스체크 결과를 다시 다른 자동화가 처리하게 만들 때는 종료 코드를 기준으로 삼으세요. 출력 문구는 언젠가 바뀌지만 "0이면 정상"이라는 약속은 스케줄러와 CI가 모두 이해합니다.

정리

  1. "건강하다"를 확인 가능한 질문(최근에 돌았나, 정상 종료했나, 자원이 남았나, 결과물이 보이나)으로 바꿉니다.
  2. 점검 하나를 함수 하나로 만들고 try/except로 감싸 하나가 죽어도 나머지가 돌게 합니다.
  3. 윈도우에서 출력을 파이프·파일로 넘긴다면 sys.stdout.reconfigure(encoding="utf-8")를 넣습니다.
  4. 가장 나쁜 결과를 종료 코드로 내보내 스케줄러·CI가 실패를 알아보게 합니다.
  5. 실패 상황을 일부러 만들어 경보가 실제로 울리는지 시험합니다.

가장 작은 첫걸음은 지금 돌고 있는 자동화 하나의 마지막 줄에 "종료(exit 코드)"를 로그로 남기게 하는 것입니다. 그 한 줄만 있으면 위 스크립트의 정규식만 바꿔서 바로 점검을 시작할 수 있습니다.

다음 글에서는 헬스체크로도 잘 안 보이는 문제, 스크립트가 두 번 돌아 같은 일을 또 하는 상황을 막는 방법을 다루겠습니다.

댓글 남기기