자동화는 성공 경로보다 실패 후 상태가 더 중요하다. 같은 요청을 반복해도 되는지, 이미 결과가 저장됐는지, 사람이 무엇을 보고 복구할지를 정하지 않으면 재시도가 중복 작업을 만든다.
이 글은 추상적인 체크리스트 대신 이 저장소의 scripts/auto_post.py와 .github/workflows/auto_post.yml을 2026년 7월 23일 기준으로 점검한다.
현재 구현에서 확인한 값
| 항목 | 현재 구현 | 의미 |
|---|---|---|
| 최대 시도 | MAX_ATTEMPTS = 4 | 최초 요청을 포함해 최대 네 번 생성·검수한다. |
| 대기 | time.sleep(2 ** attempt) | 실패 뒤 2초, 4초, 8초를 기다린다. |
| 오류 분류 | except Exception | 일시 오류와 영구 오류를 구분하지 않는다. |
| jitter | 없음 | 여러 실행이 동시에 실패하면 같은 시점에 재시도할 수 있다. |
| 기본 공개 상태 | AUTO_POST_DRAFT 기본 true | 성공해도 우선 초안으로 저장한다. |
| 주제 기록 | 성공 후 posted_topics.json 저장 | 실패한 생성은 사용 완료로 기록하지 않는다. |
| 워크플로 동시성 | group: blog-notion-sync | 같은 그룹 실행의 중첩을 제한한다. |
| 실패 큐·알림 | 별도 구현 없음 | Actions 로그를 사람이 확인해야 한다. |
현재 재시도 코드는 대략 다음 구조다.
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
...
except Exception as error:
if attempt < MAX_ATTEMPTS:
time.sleep(2 ** attempt)
길이가 짧거나 YAML이 잘못된 AI 응답을 다시 생성하는 목적에는 맞지만, 인증 실패와 요청 제한도 같은 방식으로 처리한다.
재시도 여부는 오류 종류로 결정한다
| 오류 | 기본 판단 | 이유 |
|---|---|---|
| 연결 끊김·일시 timeout | 제한적으로 재시도 | 다음 요청에서 회복할 수 있음 |
| HTTP 429 | Retry-After를 존중해 재시도 | 즉시 반복하면 제한이 심해질 수 있음 |
| HTTP 500·502·503·504 | 상한을 두고 재시도 | 서버 일시 장애 가능 |
| HTTP 400·422 | 재시도하지 않음 | 같은 입력은 다시 실패할 가능성이 큼 |
| HTTP 401·403 | 자동 반복 중지 | 키·권한 수정이 먼저 |
| YAML 파싱 실패 | 새 응답 생성 시 재시도 | 동일 응답 재전송은 의미 없음 |
| AI 품질 기준 미달 | 횟수를 제한해 재생성 | 비용과 시간이 늘어나며 사실성은 보장하지 않음 |
AWS의 SDK 재시도 동작 공식 문서는 표준 모드가 지수 백오프와 jitter를 사용하고, 일시 오류와 throttling의 지연을 다르게 처리한다고 설명한다. 서비스 SDK가 이미 재시도를 제공한다면 애플리케이션 재시도와 겹치지 않는지도 확인해야 한다.
분류와 jitter를 포함한 최소 예제
다음 코드는 설계 예시이며 현재 auto_post.py에 적용된 코드는 아니다.
import random
import time
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
def should_retry(error):
status = getattr(error, "status_code", None)
if status is None:
return isinstance(error, (TimeoutError, ConnectionError))
return status in RETRYABLE_STATUS
def delay_seconds(attempt, base=1.0, cap=20.0):
upper = min(cap, base * (2 ** attempt))
return random.uniform(0, upper)
def run_with_retry(operation, max_attempts=4):
for attempt in range(max_attempts):
try:
return operation()
except Exception as error:
last = attempt == max_attempts - 1
if last or not should_retry(error):
raise
time.sleep(delay_seconds(attempt))
실제 라이브러리의 예외 타입과 상태 코드 속성은 다를 수 있다. 문자열 메시지를 파싱해 분류하기보다 SDK가 제공하는 명시적 오류 타입을 사용하는 편이 안전하다.
중복 방지 키는 부작용 앞에서 확인한다
현재 글 생성은 최종 파일 저장과 posted_topics.json 갱신이 성공 경로 뒤에 있다. 외부 메일, 주문, 결제처럼 부작용이 있는 자동화에서는 다음 순서를 명확히 해야 한다.
입력 수신
→ idempotency key 계산
→ 이미 처리됐는지 확인
→ 처리 중 상태 기록
→ 외부 작업 실행
→ 성공 결과와 식별자 기록
키 후보는 원본 시스템이 제공하는 불변 ID가 가장 좋다.
| 입력 | 권장 키 | 피할 키 |
|---|---|---|
| GitHub 이벤트 | delivery ID | 제목과 날짜 |
| Notion 페이지 | page ID + last_edited_time | 페이지 제목 |
| 이메일 | Message-ID | 제목 |
| 주문 | 주문 시스템 ID | 고객명 + 금액 |
| 글 생성 | canonical slug + 생성 요청 ID | 현재 시각만 사용 |
단순히 date + title을 쓰면 제목 변경, 시간대 차이, 동시 실행에서 중복이 생길 수 있다.
로그에 남길 필드
민감한 본문과 비밀 키를 통째로 로그에 남기지 않고 복구에 필요한 식별자만 남긴다.
{
"run_id": "github-run-id",
"workflow": "auto_post",
"attempt": 2,
"stage": "frontmatter_validation",
"topic_hash": "sha256:...",
"error_type": "YamlParseError",
"retryable": true,
"next_action": "regenerate",
"timestamp_utc": "..."
}
GEMINI_API_KEY, 생성된 개인정보, 원문 이메일, 인증 헤더는 로그 대상이 아니다.
동시성과 재실행
현재 워크플로는 같은 concurrency group을 사용하고 cancel-in-progress를 false로 둔다. GitHub의 정확한 pending·취소 동작은 Actions concurrency 공식 문서를 따라야 한다.
실패한 Actions 작업은 사람이 다시 실행할 수 있다. GitHub의 워크플로 재실행 문서는 같은 커밋과 ref를 사용한다는 점을 설명한다. 재실행 전에 이미 일부 외부 작업이 끝났는지 확인해야 한다.
운영 대시보드에 필요한 최소 지표
| 지표 | 질문 |
|---|---|
| 성공률 | 전체 실행 중 최종 성공은 몇 개인가? |
| 최초 성공률 | 재시도 없이 성공한 비율은 얼마인가? |
| 오류 유형별 건수 | 인증, 제한, 입력, 외부 장애 중 무엇이 많은가? |
| 중복 차단 건수 | idempotency key가 실제로 중복을 막았는가? |
| 검수 대기 시간 | 초안이 사람 판단을 기다린 시간은 얼마인가? |
| 마지막 정상 실행 | 자동화가 조용히 멈추지 않았는가? |
한계
- 현재 저장소에는 구조화 로그 저장소, 실패 큐, 운영 알림이 구현되어 있지 않다.
- 예시 코드는 Google GenAI SDK의 실제 예외 계층에 맞춘 완성 구현이 아니다.
- 지수 백오프는 잘못된 입력이나 권한 오류를 고치지 않는다.
- 중복 방지는 키 저장소의 원자성까지 확보해야 한다. 스프레드시트 한 행 조회만으로는 동시 실행 경쟁을 막지 못할 수 있다.
- 워크플로 성공은 생성된 글의 사실성과 공개 가치를 보장하지 않는다.
- 재시도 횟수와 지연은 호출 비용, 사용자 지연, 공급자 한도를 함께 보고 조정해야 한다.
운영 가능한 자동화는 “다시 해 본다”에서 끝나지 않는다. 무엇을 다시 해도 되는지 분류하고, 이미 끝난 작업을 식별하며, 사람이 복구할 정보를 남겨야 한다.