테스트가 운영 크론을 다운시켰다 — import 시점 바인딩이라는 조용한 함정
테스트가 통과했는데, 운영이 죽었다. 아침에 cron-scheduler DOWN 알람이 울렸다. 그날 밤 나는 진짜 범인이 유닛 테스트라는 걸 확인하고 꽤 오래 멍하니 있었다. 테스트는 시스템을 지키는 안전망이어야 하지 않나. 그런데 내 테스트 스위트가, 그중에서도 “malformed 스케줄을 건너뛰는지”를 검증하던 아주 사소한 테스트가, 실서버의 상태 파일을 덮어썼고 스케줄러가 쓰러졌다. 이 글은 그 하루의 기록이다. 그리고 AI 에이전트가 당신의 인프라를 운영하기 시작하면, 테스트 격리는 단순한 코드 위생이 아니라 안전 경계(safety boundary)가 된다는 이야기다.
테스트가 안전망이 아니라 운영 코드가 됐다
내가 만드는 것의 정체를 먼저 말해야겠다. 나는 에이전트(LLM)가 크론 스케줄러를 통해 스스로 작업을 실행하고, 결과를 메신저로 배달하고, 실패 시 자동 재시도하는 구조를 운영하고 있다. 스케줄러는 jobs.json에서 잡 목록을 읽고, jobs_state.json에 각 잡의 실행 상태를 기록한다. 이 상태 파일은 잡이 언제 실행됐고, 언제 다시 실행돼야 하는지, 실패를 재시도해야 하는지를 결정하는 핵심 장부다. 단일 프로세스, 단일 상태 파일. 단순해서 좋은 구조인데, 단순함이 이번 사고의 배경이 됐다.
그런 시스템에 테스트를 붙일 때, 누구나 같은 가정을 한다. “테스트가 만지는 건 테스트 전용 디렉토리다.” 그래서 나는 conftest에 autouse 픽스처를 하나 넣어 모든 테스트가 임시 디렉토리(tmp_path)를 가짜 홈으로 쓰도록 격리했다. 환경 변수 DREW_HOME을 바꾸고, 홈 경로를 반환하는 함수를 패치했다. 이쯤이면 충분하다고 생각했다. 그 생각이 빗나갔고, 8월 7일 아침에 그 대가를 치렀다.
문제를 꿰뚫은 순간을 기억한다. “격리된 줄 알았는데 왜 실파일을 건드렸지?”라는 의문에서 출발해서, 한 줄에 도달했다.
범인은 import 시점에 굳은 모듈 상수였다
스케줄러와 잡 관리를 담당하는 cron/jobs.py의 맨 위는 이렇게 생겼다.
_DREW_HOME = get_drewgent_home()
DREWGENT_DIR = _DREW_HOME
CRON_DIR = _DREW_HOME / "cron"
JOBS_FILE = CRON_DIR / "jobs.json"
OUTPUT_DIR = CRON_DIR / "output"
JOBS_LOCK = CRON_DIR / "jobs.lock"
JOBS_STATE_FILE = CRON_DIR / "jobs_state.json"
CONFIG_FILE = CRON_DIR / "config.json"
이 코드는 함수 안이 아니라 모듈 레벨에서 실행된다. 즉 이 모듈이 import되는 순간, get_drewgent_home()이 한 번 호출되고 그 값이 _DREW_HOME이라는 상수로 영원히 박힌다. 이후 아무리 환경 변수를 바꿔도, JOBS_STATE_FILE은 이미 “실제 홈 디렉토리 + cron/jobs_state.json”으로 결정돼 있다. 스케줄러(cron/scheduler.py)의 _drewgent_home도 같은 패턴이다.
테스트 파일 tests/cron/test_jobs.py의 9번째 줄은 이렇게 생겼다.
from cron.jobs import (parse_duration, parse_schedule, ... )
모듈 레벨 import. 여기에 pytest의 동작 순서가 얽혔다. pytest는 테스트를 발견하기 위해 컬렉션(collection) 단계에서 테스트 모듈을 먼저 import한다. 픽스처가 실행되는 건 그 이후다. 그래서 어떤 autouse 픽스처가 DREW_HOME을 가짜 경로로 바꿔도, 이미 cron.jobs의 상수는 컬렉션 시점에 실경로로 굳어버린 뒤다. env 격리는 파티가 끝나고 도착한 손님이었다.
한 테스트가 실제 jobs_state.json을 덮어썼다
결과는 단순했다. test_get_due_jobs_skips_malformed_next_run라는 테스트가 “이상한 next_run 값이 들어오면 건너뛰는지”를 검증하면서 save_jobs_state({"bad-next": ...})를 호출했고, 그건 가짜 홈이 아니라 실제 ~/.drewgent/cron/jobs_state.json을 통째로 덮어썼다. 장부가 망가지니 스케줄러가 정상적으로 도는 게 불가능해졌고, 상태 모니터가 크론 다운 알람을 울렸다. 테스트 하나가 운영 인프라를 때린 것이다.
여기서 가장 불쾌했던 점은 이 버그가 “통과하는 테스트”였다는 것이다. 테스트가 실패했다면 바로 잡혔을 것이다. 실패 없이, 무해하게 보이면서 조용히 실파일을 오염시켰기 때문에, 발견된 건 사고 이후였다. 격리 버그는 보통 그렇다. 빨간 불이 아니라 새는 수도관처럼 새고, 문제가 커져서야 드러난다.
수정: 세 겹의 방어선
고치는 방법은 세 갈래로 나뉜다. 당장의 원인 제거부터, 구조적인 해결까지.
1. autouse 픽스처에서 모듈 상수를 재바인딩한다
가장 직접적인 수정은 conftest의 autouse 픽스처가 env만 바꾸지 말고, 이미 굳어버린 모듈 상수 자체를 가짜 홈으로 덮어쓰는 것이다.
for _mod_name in ("cron.jobs", "cron.scheduler"):
_mod = __import__(_mod_name, fromlist=["*"])
for _attr in ("_DREW_HOME", "DREWGENT_DIR", "_drewgent_home"):
if hasattr(_mod, _attr):
monkeypatch.setattr(_mod, _attr, fake_home)
for _attr, _rel in (
("CRON_DIR", "cron"),
("JOBS_FILE", "cron/jobs.json"),
("JOBS_STATE_FILE", "cron/jobs_state.json"),
...
):
monkeypatch.setattr(_mod, _attr, fake_home / _rel)
핵심은 “경로를 읽는 시점이 아니라 상수가 정의된 시점”을 정조준한다는 것이다. env 격리는 런타임에서 경로를 읽는 코드만 막는다. import 시점에 굳은 상수는 상수 자체를 덮어써야 한다.
2. 실상태에 의존하던 테스트는 명시적 시드로 바꾼다
둘째로, 실제 jobs.json에 존재하는 잡을 읽어 라운드트립을 검증하던 테스트들을 명시적으로 시드된 픽스처 데이터를 쓰도록 바꿨다. 테스트가 “지금 실제 시스템에 뭐가 있는지”를 은연중에 가정하면, 그 가정은 어느 순간 실파일을 읽거나 쓰는 경로로 이어진다. 테스트의 입력은 테스트가 만들어야 한다. 운영 상태는 테스트를 위해 존재하지 않는다.
3. 근본적으로는 바인딩을 함수로 옮긴다
구조적 해법은 단순하다. 환경에서 유도되는 값은 import 시점에 상수로 굳히지 말고, 함수 호출 시점에 읽는다. CRON_DIR 상수가 아니라 def cron_dir(): return get_drewgent_home() / "cron" 같은 형태가 테스트 친화적이다. 그러면 env 격리만으로도 충분하고, 상수 재바인딩 같은 꼼수 자체가 필요 없어진다. 물론 상수 재바인딩보다 코드가 더 길어진다는 트레이드오프는 있다. 그래서 나는 당장의 보호는 재바인딩으로 하고, 리팩토링은 잡 단위로 천천히 진행하고 있다.
에이전트 인프라에서 테스트는 곧 운영 코드다
이 사고가 주는 진짜 교훈은 테스트 기법이 아니라 태도에 있다. LLM 에이전트가 당신의 크론을 돌리고, 상태를 쓰고, 메시지를 배달하기 시작하면, 테스트 스위트는 개발 도구가 아니라 운영 구성 요소가 된다. 그 순간 테스트가 실파일을 만질 수 있는 경로는 “버그”가 아니라 “잠재 인시던트”다. 내가 격리를 ‘위생’으로만 생각했기에 이 버그는 한 달 넘게 잠복했다. 안전 경계로 생각했다면 import 시점 바인딩부터 의심했을 것이다.
이제 나는 새 모듈을 만들 때마다 한 가지 검사를 먼저 한다. “import 시점에 환경을 읽는 코드가 있나?” 그리고 이미 있는 코드는 스크립트 하나로 빠르게 걸러낸다.
# 모듈을 로드한 뒤 실제로 어느 경로를 가리키는지 확인
DREW_HOME=/tmp/fake-home python3 -c "import cron.jobs; print(cron.jobs.JOBS_STATE_FILE)"
가짜 홈을 줬는데도 ~/.drewgent/cron/jobs_state.json이 출력되면, 그 모듈은 import 시점 바인딩을 하고 있다는 명백한 증거다. 이 한 줄이면 방어선을 뚫을 모듈을 발행 전에 잡을 수 있다. 격리 실패는 무증상으로 오래 숨어 있다가, 사고로 드러난다. 조기 발견은 곧 수익이다.
지금 당신의 스케줄러를 열어보라
만약 당신이 크론 스케줄러, 워커, 백그라운드 잡, 혹은 에이전트가 운영하는 어떤 인프라를 만들고 있다면, 이번 주에 세 가지를 확인해보자.
① 모듈 레벨에서 경로·설정을 읽는 상수를 찾는다. HOME = Path(...), CONFIG = load(), API_KEY = os.environ[...] 같은 것. 이 중 어떤 것이든 import 시점에 굳는다. ② 테스트가 “실상태”가 아니라 “자기가 만든 상태”를 읽는지 확인한다. 시드 없는 라운드트립 테스트가 있으면 그건 실파일을 만질 준비가 된 테스트다. ③ autouse 픽스처가 상수 자체를 재바인딩하는지 확인한다. env만 바꾸는 격리는 import 시점 바인딩 앞에서 무력하다.
내 경험에선 이 검사가 30분이면 끝난다. 그리고 30분의 절약보다, 테스트가 조용히 실서버를 건드리지 않는다는 확신이 더 큰 가치다. 당신의 테스트가 운영을 지키는지, 아니면 운영 코드의 일부가 됐는지 — 아침에 알람이 울리기 전에 확인하는 걸 권한다. 이 글을 읽으면서 같은 함정을 본 사람이 있다면, 어떤 경로로 잡았는지 댓글로 알려달라. 격리 버그는 혼자 찾기엔 너무 조용하다.
근거 출처
외부 정보(하베스터 수집) — 없음. 이 글은 하베스터가 수집한 기사·트렌드·데이터에 의존하지 않는다.
상호작용(에이전트-드루 대화·작업) — 이 글 전체는 2026-08-07 실제 인시던트에서 얻은 경험이다. cron/jobs.py·cron/scheduler.py의 모듈 레벨 상수 바인딩(_DREW_HOME = get_drewgent_home(), CRON_DIR·JOBS_STATE_FILE 등), tests/cron/test_jobs.py:9의 모듈 레벨 import, tests/conftest.py의 env 격리 픽스처가 import 시점 바인딩을 막지 못해 test_get_due_jobs_skips_malformed_next_run이 실제 jobs_state.json을 덮어써 cron-scheduler DOWN 알람이 발생한 경위, 그리고 autouse 픽스처의 모듈 상수 재바인딩 + 명시적 시드로의 수정 과정은 모두 나와 에이전트의 실제 대화·작업 산출물이다. 코드 인용은 현재 저장소의 실제 소스를 기준으로 한다. 파이썬의 import 시점 실행과 pytest의 컬렉션/픽스처 순서 같은 언어·도구 동작 원리는 일반적인 기술 상식이며, 특정 외부 자료를 인용한 것은 없다.