← 글 목록

자동화 파이프라인 트러블슈팅: systemd 진단부터 API 폴백 설계까지

/ 9분 분량

오늘은 원래 있던 라즈베리파이 자동화 파이프라인의 systemd 서비스 파일이 실제로 뭘 하는지 확인하다가, 결국 API 장애 대응용 폴백 시스템을 직접 설계하고 성능 최적화까지 하게 된 하루였다. 시작은 단순한 진단이었는데 끝은 동시성 프로그래밍까지 갔다.

학습 주제

  • 주제: 리눅스 서비스 관리(systemd/cron) 진단, API 장애 대응 폴백 아키텍처, 프로세스 재사용 최적화, 스레드 간 공유 상태 관리
  • 날짜: 2026-07-14

탐구 과정

1) systemd가 진짜 이 서비스를 돌리고 있는 게 맞나?

내가 관리하는 라즈베리파이에 /etc/systemd/system/learningetl.service라는 유닛 파일이 있길래, 이게 실제로 내 프로젝트 디렉토리를 정상적으로 참조하고 있는지 궁금해졌다. 확인해보니 프로젝트 디렉토리 이름이 LearningETL → LearningCollector → LearningCollector_v1.0 순으로 리네이밍을 거쳤는데, systemd 유닛 파일들은 이 변화를 하나도 따라가지 못하고 있었다. 존재하지도 않는 경로를 가리키고 있으니 당연히 실패할 수밖에.

journalctl로 확인해보니 systemd 쪽(3개 유닛 파일)은 사실상 다 죽어있었고, 실제로 매일 데이터를 수집해온 건 crontab의 @daily 한 줄이었다. 여기서 배운 건 **"등록되어 있다고 해서 그게 실제로 동작 중이라는 보장은 없다"**는 것. 코드/설정 파일과 실제 런타임 상태는 항상 별개로 검증해야 한다.

2) cron이 진짜 안정적으로 돌아왔나?

cron 로그 전수 조사를 해보니, 흥미롭게도 실행 자체는 실패한 적이 없었다. 대신 기기가 꺼져있던 시간대에는 아예 스킵되고 있었다. 일반 cron은 systemd timer의 Persistent=true 같은 "놓친 작업 캐치업" 기능이 없어서, 자정에 기기가 꺼져있으면 그날은 그냥 넘어간다. 다행히 우리 코드가 "마지막 성공 수집 시각 ~ 현재"를 조회 구간으로 잡는 방식이라 데이터 유실은 없었지만, 이 구조 자체를 이해하고 나니 왜 batch/ETL 파이프라인에서 "멱등성(idempotency)"과 "조회 구간을 상태 기반으로 계산하는 설계"가 중요한지 체감했다.

3) API 할당량이 다 떨어지면 어떻게 해야 하나?

파이프라인은 Gemini API로 블로그 초안을 생성하는데, 무료 할당량이 하루 9개 항목 만에 소진되고 나머지 100개 이상이 전부 스킵되는 문제가 있었다. 여기서 진짜 재미있는 문제가 시작됐다 — 다른 유료 API로 갈아타지 않고, 이미 로그인되어 있는 구독 서비스의 CLI를 서브프로세스로 호출해서 폴백으로 쓸 수 있을까?

핵심 학습 내용

CLI를 폴백 클라이언트로 감싸기

기존 GeminiClient가 generate_draft(prompt, json_content) -> str이라는 단순한 인터페이스로 추상화되어 있었던 덕분에, 똑같은 인터페이스를 가진 새 클라이언트를 하나 더 만들어서 갈아끼우기만 하면 됐다. 이게 인터페이스를 잘 추상화해두면 왜 좋은지 실감한 순간이었다.

# 단순화한 개념
class ClaudeCodeClient:
    def generate_draft(self, prompt: str, json_content: dict) -> str:
        result = subprocess.run(
            ["claude", "-p", "--system-prompt", TEXT_ONLY_PROMPT],
            input=prompt, capture_output=True, text=True
        )
        return result.stdout

핵심은 헤드리스 자동화 환경에서 인증을 어떻게 유지하느냐였다. 처음엔 브라우저 기반 OAuth 세션으로 잘 되는 것처럼 보였지만, 이건 cron처럼 사람이 개입할 수 없는 환경에서는 세션 만료 시 조용히 실패할 위험이 있었다. CLAUDE_CODE_OAUTH_TOKEN 같은 장기 토큰을 환경변수로 발급받아 서브프로세스에 명시적으로 전달하는 방식으로 바꾸니, env -i로 다른 환경변수를 다 지운 완전히 격리된 상태에서도 정상 인증되는 걸 확인할 수 있었다.

오류를 구분해서 처리하기

첫 병렬화 시도에서 "empty output" 오류가 대량 발생했는데, 이걸 그냥 "일시적 오류"로 취급해서 계속 헛되이 재시도하고 있었다. 실제로는 5시간 단위 사용량 한도(rate_limit_info.status: "rejected")에 걸린 거였는데, 응답 텍스트가 비어있다는 표면적 증상만 보고 원인을 오판한 것이다.

일시적 오류(네트워크 지연, 순간적 과부하)와 영구적 실패(할당량 소진)는 겉보기엔 비슷해 보여도 완전히 다른 처리 전략이 필요하다. 전자는 재시도가 답이지만, 후자는 재시도할수록 오히려 손해다.

이 구분을 제대로 안 하면 실패 이유도 모른 채 자원만 낭비하는 코드가 된다는 걸 직접 겪고 나서야 이해했다.

프로세스 재사용으로 오버헤드 줄이기

매 항목마다 claude CLI를 새 프로세스로 띄우니 순수 부팅 비용만 항목당 9초씩 들었다. stream-json 입출력 모드로 프로세스를 살려두고 /clear 명령으로 맥락만 리셋하는 방식으로 바꾸니 속도가 확실히 개선됐다.

claude -p --input-format stream-json --output-format stream-json

다만 기대했던 만큼 드라마틱한 개선은 아니었다. /clear 자체도 한 번의 요청/응답 왕복이라 비용이 들고, 파일 I/O(중복 체크, JSON 읽기, draft 저장) 같은 다른 병목이 여전히 남아있었기 때문이다. 하나의 병목을 없앤다고 전체 처리량이 그만큼 늘어나는 게 아니다 — 시스템에는 여러 병목이 동시에 존재할 수 있다는 걸 실감했다.

병렬 처리와 스레드 간 공유 상태

여러 워커가 동시에 "Gemini 한도 초과 여부"를 알아야 하는 상황이라 공유 플래그 클래스를 만들었다. 각 워커는 독립적인 AIClient(= 전용 claude 프로세스)를 갖게 해서 요청이 서로 섞이지 않도록 격리했다.

class SharedFlag:
    def __init__(self):
        self._lock = threading.Lock()
        self._value = False

    def set(self):
        with self._lock:
            self._value = True

    def is_set(self) -> bool:
        with self._lock:
            return self._value

이해한 내용

  • 설정 파일과 실제 런타임 상태는 별개다. systemd 유닛이 등록돼 있다고 그게 동작한다는 보장은 없고, 반드시 journalctl, 프로세스 상태 등으로 교차 검증해야 한다.
  • 깔끔한 인터페이스 추상화의 값어치. generate_draft()라는 단순한 계약 덕분에 완전히 다른 백엔드(API → CLI 서브프로세스)로 교체하는 게 몇 줄짜리 작업이 됐다.
  • 자원 공유 시스템에서 병렬화의 함정. Claude Pro 구독처럼 시간 단위 고정 예산으로 제한되는 자원은, 병렬로 호출한다고 해서 전체 처리량이 느는 게 아니라 그 예산을 더 빨리 소진시킬 뿐이다. 게다가 이 계정을 대화형 세션과 자동화 스크립트가 공유하고 있었기 때문에, 병렬 워커를 늘리는 게 오히려 다른 작업에 영향을 줄 수 있다는 걸 실시간으로 겪었다(내 Bash 실행 자체가 막히는 걸로 확인됨).
  • 오류의 "증상"과 "원인"을 구분해야 한다. 빈 응답이라는 증상 뒤에 숨어있던 진짜 원인(rate limit)을 못 찾으면, 겉으로는 "재시도 로직이 도는 것처럼 보이지만" 실제로는 의미 없는 헛수고를 반복하게 된다.

실전 적용

  • 앞으로 자동화 파이프라인을 만들 때는 처음부터 "1차 수단이 실패했을 때의 폴백 경로"를 설계에 포함시켜야겠다. 오늘처럼 나중에 붙이는 것보다 훨씬 깔끔할 것 같다.
  • 외부 API/CLI를 감싸는 클라이언트는 항상 얇고 교체 가능한 인터페이스로 만들어두는 습관을 들이고 싶다.
  • 공유 자원에 접근하는 멀티스레드 코드에서는 threading.Lock 같은 최소한의 동기화 장치를 처음부터 넣는 게 나을 것 같다 — 나중에 레이스 컨디션 디버깅하는 것보다 훨씬 싸게 먹힌다.

추가 학습 계획

  • systemd timer의 Persistent=true 옵션과 cron의 차이를 더 깊이 파보고, 언제 systemd timer로 갈아타는 게 나은지 기준을 세워보고 싶다.
  • 스레드 기반 병렬 처리 말고 asyncio 기반 비동기 처리로 바꾸면 이런 구조에서 어떤 이점/단점이 있을지 비교해보고 싶다.
  • Rate limit을 사전에 감지해서 워커 수를 동적으로 조절하는 방식(백프레셔)에 대해 더 공부해보려 한다.