자동화 파이프라인9 분 읽기

팔로워 목표 처방 하나가, 나머지 편성 전체를 조용히 삼켰습니다

봇은 매일 게시에 성공했고 로그도 초록불이었습니다. 그런데 하루 5종이던 편성이 3건으로 줄어 있었습니다. 원인은 함수 맨 앞의 return 한 줄이었습니다.

#automation#gotchas#bots#code-review
왼쪽은 운세·앱·개발일지·정보·모집 다섯 종류가 도는 기존 편성, 오른쪽은 처방 모드가 켜진 뒤 정보 두 건과 모집 한 건만 남은 편성
왼쪽은 내가 생각한 편성, 오른쪽은 실제로 나가던 편성. 코드에는 '우선'이라고 적어뒀지만 실행은 '대체'였습니다.

소셜 계정 하나를 자동 게시 봇에 맡겨두고 있습니다. 운세, 앱 소개, 가이드 글, 그리고 주 3회 개발일지 타래까지 다섯 종류가 날짜 규칙대로 돕니다. 며칠 전 여기에 하나를 더 얹었습니다. 팔로워 5,000명까지 매일 정보글 2개와 팔로우 모집글 1개를 우선 게시한다는 처방입니다.

질문 하나 드리겠습니다. 당신이 파이프라인에 새 모드를 "우선"으로 넣었을 때, 그건 병행입니까 대체입니까? 저는 병행이라고 믿고 있었습니다.

봇은 매일 성공하고 있었습니다

로그는 흠잡을 데가 없었습니다. 게시 원장에는 매일 세 줄이 정확히 쌓였고, 회차 결과는 [end] ok, launchd 종료 코드도 0이었습니다. 감시판도 초록이었습니다.

오늘 다른 걸 물어보다가 게시 종류별 분포를 세어봤습니다.

$ 최근 30일 published 집계
30  fortune   (운세)
19  app       (앱 소개)
12  devlog    (개발일지 타래)
 2  article/information   ← 처방 정보글
 1  recruitment           ← 처방 모집글

운세 30, 앱 19는 처방을 켜기 기록이었습니다. 처방을 켠 다음 날부터 오늘까지 나간 건 정보 2건과 모집 1건뿐. 운세도 앱도 개발일지 타래도 한 건도 없었습니다.

원인은 함수 맨 앞의 return 한 줄

일일 오케스트레이터를 열어봤습니다.

def main():
    ...
    if growth.active():
        return run_growth(args, topic)     # ← 여기
 
    today = dt.date.today()
    # 월·수·금 개발일지 타래
    # 운세 / 앱 / 가이드 글 단건
    ...

growth.active()는 "팔로워가 아직 5,000명 미만인가"를 묻습니다. 현재 1,387명이니 매 회차 참입니다. 그러니까 이 봇은 처방을 켠 순간부터 나머지 편성 코드에 도달한 적이 한 번도 없습니다.

기능을 넣을 때 나는 "처방을 우선 적용한다"고 적었고, 코드는 그 문장을 return으로 번역했습니다. 문서에도 그렇게 남아 있었습니다. "처방 중에는 기존 로테이션과 긴 개발일지 타래 대신 처방 게시가 실행된다." 읽으면 분명한데, 나는 이 문장을 쓰고도 두 트랙이 따로 도는 줄 알고 있었습니다.

초록불이 거짓말한 게 아닙니다. 봇은 시킨 일을 정확히 했습니다. 시킨 일이 내가 생각한 일이 아니었을 뿐입니다.

당신이라면 어떻게 고치겠습니까

여기서 갈림길이 하나 있었습니다.

  1. return을 지우고 처방 뒤에 로테이션을 이어 붙인다.
  2. 처방과 로테이션을 아예 다른 실행(다른 스케줄)으로 쪼갠다.

2번이 깔끔해 보이지만, 두 트랙이 같은 계정·같은 게시 원장·같은 중복 방지 잠금을 공유합니다. 실행을 쪼개면 잠금과 원장을 두 곳에서 관리해야 합니다. 그래서 1번을 골랐습니다. 대신 세 가지를 같이 넣었습니다.

if growth.active():
    error = None
    try:
        run_growth(args, topic)
    except (Exception, SystemExit) as e:
        error = e                      # 처방이 깨져도 로테이션은 나간다
        print(f"처방 게시 실패(로테이션은 계속): {e}")
    if not args.growth_only:
        run_rotation(args, topic)
    if error:
        raise error                    # 실패는 회차 결과에 그대로 남긴다
    return
run_rotation(args, topic)
  • 로테이션을 함수로 분리해서 "처방 다음에 이어서 돈다"가 코드에 보이게 했습니다.
  • 처방 실패가 로테이션을 막지 못하게 했습니다. 예전에도 타래 하나가 죽어서 그날 게시가 통째로 0건이 된 적이 있습니다(중복 가드 하나가 하루 게시를 0건으로 만든 이야기와 같은 계열의 실패입니다).
  • --growth-only 플래그를 만들었습니다. 자동 보충 잡이 30분마다 같은 스크립트를 부르는데, 여기서 로테이션까지 같이 돌면 운세와 앱 소개가 하루에 수십 건 나갑니다. 보충 호출에만 이 플래그를 붙였습니다.

세 번째가 특히 중요합니다. return을 지우는 순간, 그 함수를 부르는 모든 호출자가 로테이션을 갖게 됩니다. 고칠 때는 항상 호출자 목록을 먼저 세어야 합니다.

자가진단 3개

같은 사고를 자기 파이프라인에서 찾고 싶다면 이 셋을 확인해보세요.

  1. 조건부 조기 종료가 실행 시간의 몇 퍼센트에서 참인가. 내 경우 팔로워 < 5,000은 사실상 영구 참이었습니다. "임시 모드"의 조건이 몇 달 동안 참이면 그건 임시가 아니라 새 기본값입니다.
  2. 산출물 종류를 세고 있는가, 성공 여부만 세고 있는가. 감시판은 "게시 성공 3건"만 보고 있었습니다. 종류별 분포를 보고 있었다면 하루 만에 잡혔습니다.
  3. 문서에 "대신"이라는 단어가 있는가. 내 README에는 "기존 로테이션 대신"이라고 적혀 있었습니다. 그 단어가 곧 명세였는데, 나는 그걸 읽으면서도 병행이라고 기억하고 있었습니다.

솔직한 부분

이 버그로 잃은 건 게시 이틀치입니다. 크지 않습니다. 무서운 건 발견 경로입니다. 나는 이 봇을 고치려고 코드를 연 게 아니라, 다른 걸 물어보다가 우연히 분포를 세었습니다. 그러지 않았다면 팔로워가 5,000명이 될 때까지 — 지금 속도로 몇 달입니다 — 운세도 앱 소개도 한 건도 안 나가는 상태가 조용히 유지됐을 겁니다.

로그와 종료 코드는 "무엇이 성공했는가"만 답합니다. "무엇이 아예 실행되지 않았는가"는 답하지 않습니다. 후자를 보려면 성공 건수가 아니라 산출물의 구성을 세야 합니다.

지금 돌고 있는 자동화 하나를 골라, 최근 30일 산출물을 종류별로 세어보세요. 개수가 아니라 구성입니다. 예상한 비율이 나옵니까?

관련 글