아침 브리핑 장애 대응: Codex 전환과 로그 정리
아침 브리핑은 자료 수집, AI 요약, Discord 전송 순서로 실행됩니다. 브리핑이 오지 않으면 이 중 어디에서 멈췄는지 먼저 확인해야 합니다. 수집된 파일이 남아 있더라도 요약이나 전송까지 끝났다는 뜻은 아니기 때문입니다.
6월에 브리핑이 멈췄을 때도 수집 작업은 매일 실행되고 있었습니다. 로그를 확인해 보니 Gemini CLI의 UNSUPPORTED_CLIENT 오류로 요약이 실패하고 있었습니다. 그보다 앞선 날에는 전송 단계에서 실패한 기록도 있었습니다.
이 글에서는 당시 Gemini CLI를 Codex로 교체하면서 바꾼 출력 처리와 음성 연결, 이후 모델 설정 때문에 다시 발생한 문제를 정리합니다.
Gemini에서 Codex로 전환
AI 호출은 브리핑 외에도 로스트아크 공지와 청약 분석에서 사용하고 있었습니다. 각 스크립트에서 CLI를 직접 실행하면 호출 방식이 바뀔 때마다 여러 곳을 수정해야 합니다. 이미 AI 응답을 정리하는 공통 래퍼를 사용하고 있어서, run_json.sh에서 실행하는 CLI를 Codex로 바꾸고 각 자동화는 기존처럼 래퍼의 결과를 읽도록 했습니다.
여기서 구분해야 할 것이 최종 응답과 실행 이벤트였습니다. 브리핑에 필요한 것은 요약문이지만, Codex CLI는 실행 과정도 JSON 이벤트로 출력합니다. 둘을 같은 출력에 섞으면 자동화 스크립트가 어느 내용을 읽어야 할지 모호해집니다.
run_json.sh의 stdout에는 text 필드가 있는 최종 JSON만 남겼습니다. Codex의 진행 이벤트는 stderr로 보내 별도 로그에 저장했습니다. 터미널에서는 두 출력이 함께 보여서, 각각 파일로 받아 실제로 분리되는지도 확인했습니다.
이렇게 구성하면 브리핑 전송 스크립트는 CLI의 이벤트 형식을 알 필요 없이 text만 읽을 수 있습니다. 전환 후에는 요약 생성과 브리핑 발송까지 확인했습니다.
자동화가 읽는 JSON 명세
CLI를 바꾸는 작업은 실행 파일 이름만 바꾸는 것으로 끝나지 않았습니다. 결과를 읽는 프로그램에 어떤 값을 반환할지도 맞춰야 했습니다. 모델의 진행 이벤트를 해석하는 부분은 공통 호출 스크립트에 두고, 브리핑 쪽에서는 최종 결과만 읽도록 했습니다.
다음은 공통 호출 스크립트가 반환하는 JSON 예시입니다. 실제 브리핑 내용 대신 테스트 문장을 넣었습니다.
{"text":"오늘 확인할 일정은 두 건입니다."}stdout에 실행 시작 같은 한 줄만 섞여도 문서 전체는 JSON이 아니게 됩니다. 로그를 stderr로 보내면 출력이 섞이지 않아 프로그램이 결과를 JSON으로 읽을 수 있습니다. 마지막 줄만 무조건 JSON으로 간주하는 방식은 실패 메시지를 정상 결과로 잘못 처리할 수 있어 피했습니다.
호출하는 쪽에서는 JSON 구문과 필요한 필드까지 확인합니다. 아래 함수는 공통 스크립트의 출력을 읽는 예시이며 Codex 이벤트 자체의 형식을 가정하지 않습니다.
import json
import subprocess
def run_summary(command, prompt):
result = subprocess.run(
command, input=prompt, capture_output=True,
text=True, encoding="utf-8", timeout=180,
)
if result.returncode != 0:
raise RuntimeError(f"summary failed: exit={result.returncode}")
payload = json.loads(result.stdout)
if not isinstance(payload, dict):
raise ValueError("summary must be an object")
text = payload.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("summary text is empty")
return text이렇게 하면 종료 코드가 0이어도 빈 결과나 잘못된 JSON은 발송 단계로 넘기지 않습니다. 모델 호출 로그에는 요청 실패 원인이 남고, 전송 쪽에는 전송할 문장만 전달됩니다. 시간 제한은 사용 예시의 값으로, 실제 작업에서는 입력 길이와 실행 환경에 맞춰 정해야 합니다.
터미널에서도 두 출력을 별도 파일로 받아 볼 수 있습니다.
./run_json.sh < prompt.txt > result.json 2> events.log
python3 -m json.tool result.json위 명령은 이미 준비한 run_json.sh의 입출력을 검사하는 예시입니다. 실행에 성공하면 result.json을 확인하고, 실패하면 종료 코드와 events.log를 먼저 봅니다. python3 -m json.tool은 JSON 구문만 검사하므로 text가 비어 있지 않은지까지 보려면 위 함수처럼 별도 확인이 필요합니다.
| 확인 상황 | 전송 단계의 처리 |
|---|---|
| 종료 코드가 0이 아님 | 발송하지 않고 호출 실패 기록 |
| JSON 구문 오류 | 출력 형식 오류로 기록 |
text 누락·빈 문자열 | 정상 요약으로 저장하지 않음 |
유효한 text | 해당 문장으로 전송 진행 |
전역 모델 설정 문제도 호출 결과를 검사하는 단계에서 발견할 수 있습니다. 전송 프로그램에는 HTTP 400 응답을 요약문처럼 넘기지 않고 호출 실패로 알려야 합니다. 호출이 끝난 것과 사용할 문장이 생긴 것을 구분한 뒤에야 실패한 단계부터 다시 실행할 수 있었습니다.
TTS로 읽어줄 응답 추출
래퍼를 만들게 된 계기 중 하나는 직접 추가한 Home Assistant의 TTS 기능이었습니다. Home Assistant가 맥미니에 AI 응답 생성을 요청하면, 맥에서 그 내용을 음성으로 읽어주도록 만들었습니다.
그런데 AI 응답 외에 다른 텍스트까지 함께 넘어오면서, TTS가 읽어줄 문장뿐 아니라 불필요한 내용까지 읽는 문제가 생겼습니다. 그래서 CLI 출력을 그대로 넘기는 대신, 래퍼에서 최종 응답을 따로 추출해 전달하도록 했습니다.
이후 Codex로 전환할 때도 이 방식을 유지했습니다. gemini_query라는 서비스 이름은 남아 있었지만, 실제 응답은 Codex에서 생성했습니다. 래퍼가 반환한 JSON에 from_json을 적용하고 text만 추출해 TTS로 전달하도록 했습니다.
로그도 용도에 따라 나눴습니다. 브리핑 실행 로그에는 어느 단계에서 성공하거나 실패했는지 남기고, Codex 이벤트는 별도 파일에 저장했습니다. 브리핑에 넣은 자료와 생성된 요약은 실행 시각별로 보관했습니다. 메시지 내용이 이상할 때 입력 자료와 결과를 함께 확인하기 위해서입니다.
전역 모델 설정으로 인한 요약 실패
10월에는 다른 원인으로 브리핑이 멈췄습니다. 수집과 예약 실행은 정상이었지만, 요약 요청이 HTTP 400으로 실패했습니다.
자동화가 Codex의 전역 기본 모델을 따르고 있었던 것이 원인이었습니다. 전역 설정의 모델이 바뀌었는데, 당시 자동화에 사용하는 CLI 계정에서는 그 모델을 호출할 수 없었습니다. 개발 중에 바꾼 설정이 정기 작업에도 영향을 주는 구조였습니다.
공통 스크립트에 자동화용 기본 모델을 따로 지정했습니다. 작업마다 다른 모델이 필요하면 환경변수로 바꿀 수 있도록 했습니다. 수정 후 수집된 메일, 일정, 뉴스, 시장 자료로 요약을 다시 생성했습니다. 이 테스트에서는 Discord 전송을 생략하고 생성 결과까지만 확인했습니다.
실패 알림도 추가했습니다. 수집, 요약, 전송 중 어느 단계에서 멈췄는지 처음 한 번 알리고, 같은 오류가 이어지는 동안에는 반복해서 보내지 않습니다. 다시 정상 실행되면 복구 알림을 보냅니다.
알림에는 작업 이름과 실패한 단계, 모델 미지원이나 인증 오류 같은 원인을 넣습니다. 상세 오류와 입력 자료는 로그에서 확인하도록 했습니다. 브리핑에 포함되는 개인 메일 내용이나 인증정보까지 오류 메시지에 섞이지 않게 하기 위해서입니다.
TTS가 불필요한 내용까지 읽는 문제를 해결하려고 만든 래퍼는 이후 Codex로 전환할 때도 사용했습니다. 래퍼에서 실행하는 CLI를 바꾸고, 브리핑과 TTS는 기존처럼 정리된 결과를 읽도록 했습니다.
전역 모델 설정으로 다시 실패한 뒤에는 자동화용 모델도 따로 지정했습니다. 수정 후 요약을 다시 만드는 것까지 확인했고, Discord 재전송은 이 테스트에 포함하지 않았습니다.