내 서버를 죽인 건 버그가 아니라 테스트였다

“cron-scheduler DOWN.” 새벽에 잠들기 전, 그동안 고치고 있던 크론 모듈의 테스트 스위트를 마지막으로 한 번 돌렸다. 그 몇 분 뒤, 내 서버를 지키는 스케줄러가 죽었다는 알람이 왔다. 원인은 내가 방금 돌린 그 테스트였다. 아니, 더 정확히 말하면 — ‘격리(isolation)’된 줄 알았던 그 테스트였다.

아이러니는 여기서 시작된다. 시스템을 더 단단하게 만들려고 추가한 테스트가 시스템을 죽였다. 잘못된 스케줄 데이터가 크론 전체를 크래시시키지 않도록 방어하는 테스트 — 그 테스트 자체가 크론을 다운시킨 것이다. 이 글은 그 사고의 전말과, 내가 이 문제를 어떻게 발견하고 고쳤는지, 그리고 같은 실수를 다시 하지 않기 위해 어떤 원칙을 새겼는지에 대한 기록이다.

만약 ‘테스트 격리’를 지루한 주제로 넘기고 있다면, 한 번만 더 생각해보길. 테스트가 실서버의 상태 파일을 덮어쓸 수 있다면, 그건 격리가 아니라 계획된 사고다.

60초마다 서버를 지키는 스케줄러와, 그걸 운영하는 에이전트

내 서버에는 launchd 데몬으로 매 분 도는 크론 스케줄러가 있다. jobs.json에 작업 목록을 두고, 실행 상태와 결과를 jobs_state.json에 기록한다. 데몬은 매 틱마다 그 파일을 읽어 어떤 작업이 이번에 실행되어야 하는지 판단한다. 즉 이 파일 하나가 손상되면, 스케줄러 전체의 판단이 흔들린다.

이 시스템의 특징은, 운영 주체가 사람이 아니라 AI 에이전트라는 것이다. 에이전트가 인프라를 운영하고, 알람을 받고, 코드를 고치고, 심지어 테스트까지 직접 쓴다. 이번 작업도 그런 맥락이었다. “외부 입력이 이상해도 tick 전체가 죽으면 안 된다”는 방어 원칙을 지키기 위해, 잘못된 스케줄 문자열과 깨진 next_run_at이 들어와도 크래시 없이 건너뛰는지 검증하는 테스트 4건을 추가하는 중이었다.

무해해 보이는 한 줄이 서버를 죽였다

문제의 테스트는 test_get_due_jobs_skips_malformed_next_run. 내용은 대략 이랬다.

save_jobs_state({"bad-next": {"next_run_at": "this-is-not-iso"}})
assert get_due_jobs() == []  # 크래시 없이 빈 목록

아무리 봐도 무해하다. 상태 파일에 깨진 데이터를 넣어도, 스케줄러가 크래시 대신 빈 목록을 반환하는지 검증하는 테스트니까. 문제는 그 상태 파일이 어디에 써졌는가였다. 나는 테스트가 임시 디렉토리(tmp_path)에 격리된 줄 알았다. 실제로는 ~/.drewgent/cron/jobs_state.json — 데몬이 매 분 읽는 그 파일 — 에 써졌다.

데몬이 손상된 상태를 읽었다. 판단이 깨졌고, 상태 판정이 실패했고, cron-scheduler DOWN 알람이 울렸다. 한 줄짜리 테스트 데이터가 프로덕션 서버를 무너뜨리는 데 걸린 시간은 몇 분이었다.

범인: import 시점에 경로가 ‘얼어붙는’ 바인딩

원인은 코드 한 곳이 아니라, 코드가 경로를 바인딩하는 시점이었다. 크론 모듈(cron/jobs.py)의 최상단은 이렇게 생겼다.

_DREW_HOME = get_drewgent_home()          # import 시점에 한 번만 평가
CRON_DIR = _DREW_HOME / "cron"
JOBS_STATE_FILE = CRON_DIR / "jobs_state.json"

모듈 레벨 상수다. 즉 import cron.jobs가 실행되는 순간, 그 경로들은 ~/.drewgent로 단 한 번 고정되고 이후 절대 바뀌지 않는다. 그리고 테스트 파일의 9번째 줄은 모듈 레벨에서 이렇게 import하고 있었다.

from cron.jobs import (  # tests/cron/test_jobs.py:9 — 모듈 레벨 import
    ...
    get_due_jobs, save_jobs_state, ...
)

여기가 결정적이었다. pytest는 픽스처를 실행하기 전에 테스트 파일을 수집(collection)하고, 수집 과정에서 모듈 레벨 코드를 실행한다.cron.jobs가 import되어 경로가 실제 홈 디렉토리에 고정되는 순간은, 격리 픽스처가 실행되기 훨씬 이전이다.

격리 픽스처(conftest.py의 autouse 픽스처)는 DREW_HOME 환경변수를 임시 디렉토리로 바꾸고, get_drewgent_home 함수를 패치한다. 그런데 cron.jobs는 이미 import 시점에 경로를 계산해 모듈 상수로 박아두었다. 픽스처가 아무리 env를 바꿔도, 얼어붙은 모듈 상수는 움직이지 않는다.

정리하면 이렇다. env 기반 격리는 ‘경로를 호출 시점(lazy)에 평가하는 코드’에만 유효하다. 이 코드는 import 시점(eager)에 평가했고, 그 차이가 프로덕션 파일을 오염시켰다.

수정: env를 바꾸는 대신 ‘모듈 상수 자체’를 갈아끼운다

해결책은 겉으로 보기엔 직관에 반한다. env를 더 정교하게 바꾸는 게 아니라, 픽스처가 모듈의 상수 속성 자체를 재바인딩하는 것이다. 테스트가 실제로 참조하는 것은 get_drewgent_home()의 반환값이 아니라 이미 저장된 모듈 상수이므로, 그 상수를 통째로 임시 경로로 갈아끼우면 된다.

@pytest.fixture(autouse=True)
def _isolate_drewgent_home(tmp_path, monkeypatch):
    fake_home = tmp_path / "drewgent_test"
    ...
    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_STATE_FILE", "cron/jobs_state.json"), ...):
            if hasattr(_mod, _attr):
                monkeypatch.setattr(_mod, _attr, fake_home / _rel)

핵심은 monkeypatch.setattr(모듈, 상수, 임시경로) — env를 통하지 않고, 코드가 실제로 읽는 속성을 직접 교체한다는 점이다. 이렇게 하면 import 시점 바인딩이 얼마나 일찍 일어났든 무관하게 격리된다.

두 번째 수정은 더 근본적이었다. 실제 운영 파일에 기대던 테스트를 명시적 시드로 바꾼 것.jobs.json에 의존하는 verify 라운드트립 테스트가 하나 있었는데, 이를 제거하고 테스트 데이터를 코드 안에 명시적으로 심는 방식으로 교체했다. “격리가 깨지면 실수”가 아니라, 처음부터 실 파일을 전제로 하지 않도록 설계를 바꾼 것이다.

이 사건이 남긴 세 가지 원칙

이 사고에서 얻은 교훈은 크론에 국한되지 않는다. 경로 상수와 테스트 격리를 쓰는 모든 시스템에 적용된다.

  • eager 바인딩은 어떤 env 격리로도 무효다. 경로 상수를 import 시점에 평가하면, 그 이후에 실행되는 모든 격리 장치는 소용없다. 경로는 호출 시점에 평가하거나(lazy), 어쩔 수 없다면 픽스처가 모듈 상수 자체를 재바인딩해야 한다.
  • pytest의 autouse 픽스처는 컬렉션 시점 import를 커버하지 못한다. 모듈 레벨에서 상태 파일에 접근하거나 import하는 코드는 픽스처 격리 영역 바깥에 있다. ‘자동으로 격리된다’는 가정이 가장 위험한 가정이다.
  • ‘격리된 테스트’는 증명될 때까지 격리가 아니다. 파일을 쓰는 테스트는 항상 그 파일의 실제 경로가 무엇인지 검증해야 한다. 이름이 격리라고 부르는 것과, 실제로 격리된 것은 다른 문제다.

그리고, 에이전트가 자기 인프라를 운영할 때의 차이

이 사건의 마지막 조각은, 같은 버그가 다른 환경에 있었다면 어땠을지 생각해보는 것이다. 일반 팀에서 이 버그는 — 테스트가 프로덕션 상태 파일을 조용히 오염시키는 — 아무도 모른 채 몇 주, 몇 달 방치됐을 가능성이 크다. 상태 파일 같은 마이너한 파일의 오염은 사람 눈에 잘 띄지 않는다. 스케줄러가 이상해지면 “왠지 모르게 불안정해졌다”는 모호한 증상으로 남는다.

그런데 이 시스템에서는 루프가 완전히 닫혀 있었다. 에이전트가 인프라를 운영하므로 손상이 즉시 알람으로 드러나고, 그 인프라의 테스트를 에이전트가 직접 썼으므로 원인이 즉시 테스트로 소환된다. 오염 → 알람 → 원인 규명 → 픽스처 수정 → 교훈 저장까지의 전 과정이 한 운영 주체 안에서 몇 시간 만에 끝났다. 교훈은 신뢰도 점수를 붙여 기억에 저장되어, 다음 세션에서 자동으로 다시 주입된다.

이게 ‘AI가 자기 인프라를 운영한다’는 것의 진짜 가치라고 생각한다. 버그를 절대 만들지 않아서가 아니라, 버그가 시스템에 미치는 영향을 분 단위로 가시화하고, 그 교훈을 구조화해서 다시 같은 실수를 하지 않는 것. 버그를 지우는 시스템이 아니라, 버그가 오래 살 수 없는 시스템을 만드는 것이다.

지금, 당신의 테스트는 어느 쪽인가

당신 팀에도 ‘격리된 테스트’가 있다고 믿고 있는가? 믿음은 검증으로 바꿔야 한다. 10분이면 확인할 수 있는 세 가지가 있다.

  • 테스트 대상 모듈의 최상단에 Path.home() 이나 절대경로를 만드는 상수가 있는지 grep해보라. 있다면 그 경로는 import 시점에 얼어붙어, env 격리를 무시한다.
  • 테스트가 정말 tmp_path 아래에만 쓰는지 증명하라. 상태 파일의 mtime을 기록하고 테스트를 돌려 바뀌는지 확인하는 것만으로도 충분하다.
  • 격리가 env를 바꾸는 방식이라면, 모듈 상수 재바인딩까지 하는 autouse 픽스처로 교체하라. 가능하면 경로는 호출 시점에 평가하게 바꾸고.

테스트 격리는 방어의 마지막 줄이 아니라, 방어가 성립한다는 전제 그 자체다. 그 전제가 깨지면 테스트는 당신의 프로덕션이 된다. 그리고 그 경계를 스스로 지키는 시스템을 만든다면 — 사람이든 에이전트든 — 당신은 버그가 오래 살 수 없는 곳에서 일하게 된다.

근거 출처

상호작용 (에이전트-드루 대화·작업):

이 글의 모든 서사·사실·수정 내용은 2026년 8월 7일 에이전트-드루 대화·작업 산출물에서 얻은 경험이다. 구체적으로: tests/cron/test_jobs.pytest_get_due_jobs_skips_malformed_next_run이 실 jobs_state.json을 덮어써 cron-scheduler DOWN 알람을 유발한 사건, cron/jobs.py·scheduler.py의 import 시점 경로 바인딩 분석, conftest.py autouse 픽스처의 모듈 상수 재바인딩 수정, 실 jobs.json에 의존하던 ARCH-11 verify 라운드트립 테스트의 명시적 시드 전환, 그리고 이 교훈이 신뢰도 0.897로 기억(lesson)에 저장된 기록까지 모두 직접 실행·관찰한 작업 기록이다. 외부 정보(기사·통계)는 사용하지 않았다.