background agent는 종료 시 1번만 알린다: Monitor 이중 채널 패턴
백그라운드로 배포 에이전트를 띄워 놓고, 작업이 끝나면 그 결과를 알아서 알려줄 거라 기대했습니다. 그런데 에이전트는 167초 만에 자기 할 일을 마치고 조용히 종료해버렸고, 그 뒤로 약 15분간 실제 배포가 도는 동안 제 화면은 완전히 무음이었습니다. 이 글은 그 작은 사고에서 출발해 '일하는 채널'과 '보는 채널'을 분리하게 된 이야기입니다.
이 글은 Claude Code 같은 AI 코딩 에이전트로 장시간 배포·CI·폴링 작업을 자동화해 본 분, 그리고 run_in_background로 백그라운드 작업을 돌려본 경험이 있는 분을 독자로 가정합니다. 배포 도구는 셀프호스팅 PaaS를 예로 들지만, 핵심 패턴은 외부 상태를 폴링하는 모든 장시간 작업에 그대로 적용됩니다.
'백그라운드 에이전트 = 진행 알림'이라는 착각
제가 처음에 가졌던 가정은 단순했습니다. 백그라운드 에이전트를 띄우면, 그 에이전트가 작업이 끝날 때까지 살아 있으면서 진행 상황을 계속 알려줄 것이라는 생각이었습니다. 돌이켜 생각해보면 이 가정이 사고의 근본 원인이었습니다.
실제 메커니즘은 달랐습니다. 백그라운드 에이전트는 자기 lifecycle이 끝나는 시점에 단 한 번 종료 알림을 발사합니다. 그 에이전트가 배포를 직접 폴링하며 기다리는 구조가 아니라면, 트리거만 걸어놓고 사전 분석을 마친 뒤 곧바로 종료해버립니다. 제 경우가 정확히 그랬습니다. 에이전트는 배포 API를 호출하고 약 167초 만에 "할 일을 다 했다"며 종료했고, 정작 빌드와 헬스체크는 그 후로도 15분 가까이 더 돌아갔습니다.
문제는 이 무음 구간 동안 제가 할 수 있는 게 없었다는 점입니다. 배포가 성공했는지, 어느 서비스에서 멈췄는지, 빌드 로그에 에러가 떴는지 — 아무것도 알 수 없었습니다. 결국 사용자가 직접 DB를 조회해서 상태를 확인하게 된다면, 자동화의 의미가 크게 퇴색하는 셈입니다.
해법: 일하는 채널과 보는 채널을 동시에 띄운다
해결의 핵심은 의외로 단순했습니다. 작업을 수행하는 채널(background agent)과 진행을 관찰하는 채널(Monitor)을 분리하는 것입니다. 한 메시지 안에서 두 채널을 동시에 발사하면, 에이전트가 일찍 종료하더라도 Monitor가 외부 상태를 계속 지켜보며 변화를 한 줄씩 흘려보냅니다.
여기서 중요한 발상의 전환이 있었습니다. 에이전트에게 폴링까지 맡기면 에이전트의 생애가 길어지고 토큰도 많이 소모됩니다. 반면 Monitor는 단순한 폴링 루프라서 토큰 비용이 작고, 변화가 있을 때만 출력합니다. 그래서 무거운 실행은 에이전트에, 가벼운 관찰은 Monitor에 맡기는 역할 분담이 자연스럽게 자리잡았습니다.
| 채널 | 역할 | 비용 특성 |
|---|---|---|
| background agent | 배포 트리거·실패 복구 등 실제 실행 | 무겁다 (종료 시 1회 알림) |
| Monitor | 외부 상태 변화 관찰·스트리밍 | 가볍다 (변화 시에만 출력) |
무엇을 스트리밍할 것인가 — 외부 상태의 변화만
Monitor가 관찰하는 대상은 에이전트 내부 상태가 아니라 외부에 적재된 배포 이력이었습니다. 저는 배포 진행 상황을 AI 에이전트용 Postgres의 한 테이블에 lifecycle 단계마다 INSERT/UPDATE로 기록해 두었습니다. Monitor는 그 테이블의 행(row)을 주기적으로 읽어 prev ≠ cur, 즉 직전 스냅샷과 다를 때만 한 줄로 출력합니다.
그 테이블에는 다음과 같은 컬럼이 담겨 관측 가능성을 확보했습니다. 단순히 "성공/실패"만이 아니라 빌드 단계와 런타임 헬스를 분리해서 봐야 정확한 진단이 가능했습니다.
| 컬럼 | 의미 |
|---|---|
status / build_status |
전체 상태 / 빌드 단계 상태 |
runtime_status |
런타임 헬스 (running:healthy / running:unknown) |
build_duration_seconds |
빌드 소요 시간 |
fqdn_check_status_code |
서비스 헬스체크 응답 코드 (예: 200) |
error_category |
실패 시 분류된 에러 카테고리 |
triggered_at / finished_at |
트리거 시각 / 종료 시각 |
모든 행이 종료 상태(terminal state)에 도달하면 Monitor는 스스로 멈춥니다. 이 자동 종료 조건이 깔끔하게 동작해야 사람이 끝까지 지켜보지 않아도 되는데, 바로 여기서 제가 두 번 넘어졌습니다.
폴링 코드에서 만난 함정 두 가지
패턴 자체는 단순했지만, 실제로 폴링 쿼리를 짜는 과정에서 두 개의 사소하지만 치명적인 버그를 만났습니다. 부끄럽지만 둘 다 한참을 헤맨 끝에야 원인을 찾았습니다.
함정 1: 존재하지 않는 컬럼명으로 짠 WHERE 절
첫 번째 Monitor 쿼리는 행을 단 한 건도 가져오지 못했습니다. 에러도 없이 그저 0건이었습니다. 한참을 들여다본 끝에 원인을 찾았는데, WHERE 절을 started_at으로 작성했지만 실제 테이블의 컬럼명은 triggered_at이었습니다. 머릿속에서 "시작 시각"이라는 의미를 자연스럽게 started_at으로 옮겨 적은 것이 화근이었습니다.
-- 실제 컬럼은 triggered_at 인데 started_at 으로 조회 → 0건
SELECT status, build_status, runtime_status
FROM deployments
WHERE started_at > now() - interval '20 minutes';
-- 컬럼명을 맞춰 재발사
SELECT status, build_status, runtime_status
FROM deployments
WHERE triggered_at > now() - interval '20 minutes';
교훈은 단순합니다. 폴링이 "에러 없이 0건"을 반환할 때는, 조건이 맞는 행이 없는 게 아니라 조건 자체가 잘못 짜였을 가능성을 먼저 의심해야 한다는 것입니다. 컬럼명을 실제 스키마와 한 글자씩 대조하는 게 가장 빠른 길이었습니다.
함정 2: 종료 상태를 다 못 잡는 정규식
두 번째 함정은 더 교묘했습니다. 모든 행이 종료 상태에 도달하면 Monitor가 자동으로 멈추도록 정규식을 짰는데, 이 정규식이 일부 종료 상태를 놓쳤습니다. 구체적으로는 success는 잡으면서 finished는 누락하는 식이었습니다. 그 결과 배포는 진작에 끝났는데도 Monitor는 "아직 진행 중인 행이 있다"고 판단해 멈추지 않았고, 결국 timeout까지 헛돌았습니다.
# finished 를 빠뜨린 종결 정규식 — 끝났는데도 멈추지 않음
if echo "$rows" | grep -qE "running|queued|building"; then
continue # 아직 진행 중으로 오판
fi
# 종료 상태를 모두 열거하도록 보정
TERMINAL='finished|success|succeeded|failed|error|cancelled'
if echo "$rows" | grep -vqE "$TERMINAL"; then
continue # terminal 이 아닌 행이 하나라도 있으면 계속
fi
echo "all terminal — stop"
이런 상태 머신을 다룰 때는 "진행 중 상태를 나열"하는 것보다 "종료 상태를 빠짐없이 나열"하는 편이 안전하다는 것을 배웠습니다. 진행 중 상태는 시스템이 새 값을 추가할 여지가 크지만, 종료 상태는 상대적으로 닫혀 있기 때문입니다. 당시에는 결국 명시적으로 stop을 걸어 정리했고, 이후 정규식을 보강했습니다.
MCP를 버리고 REST(curl)로 직접 트리거한 이유
배포를 트리거하는 방법으로는 전용 MCP 서버를 붙이는 선택지도 있었습니다. 그럼에도 저는 REST API를 curl로 직접 호출하는 방식을 택했습니다. 이유는 메인 세션의 토큰과 메모리 부담을 0으로 만들고 싶었기 때문입니다. MCP 서버를 연결하면 도구 스키마가 매 turn마다 컨텍스트에 실리는데, 배포 트리거처럼 호출 형태가 고정된 결정적 작업에는 그 비용이 아깝다는 생각이 들었습니다.
다만 외부 배포 API를 운영 환경에 직접 쏘는 일은 조심스러웠습니다. 그래서 호출 형태를 추측으로 박지 않고, 그 PaaS용으로 공개된 MCP 패키지의 소스를 읽어 교차검증했습니다. 그 과정에서 몇 가지 직관과 어긋나는 사실을 발견했습니다.
- 배포 트리거는 POST가 아니라 GET
/api/v1/deploy?uuid=...&force=false형태였습니다. - application uuid와 deployment uuid는 서로 다른 값이었습니다. 트리거할 때 넘기는 식별자와, 이후 진행 상황을 조회할 때 받는 식별자가 다르다는 점을 헷갈리면 폴링이 엉뚱한 대상을 보게 됩니다.
# 토큰은 환경변수로만 — 평문 금지
curl -s -H "Authorization: Bearer ${PAAS_ACCESS_TOKEN}" \
"https://paas.example.com/api/v1/deploy?uuid=${APP_UUID}&force=false"
여기서 접근 토큰은 반드시 PAAS_ACCESS_TOKEN 같은 환경변수로만 다뤄야 합니다. 평문으로 노출되면 그대로 배포 권한이 새어 나가기 때문입니다. 배포 이력 테이블 역시 마찬가지로 connection string을 코드에 박지 않고 환경변수(NEON_DATABASE_URL 등)로 주입했습니다. 결국 트리거도, 이력 적재도 curl과 셸 변수 몇 개만으로 구현할 수 있었습니다.
모노레포 다중 서비스 순차 배포
제 환경은 하나의 모노레포에서 여러 서비스가 함께 배포되는 구조였습니다. 그래서 "이번 변경이 어떤 서비스에 영향을 주는가"를 먼저 판단해야 했습니다. 변경된 파일 경로를 기준으로 영향받는 서비스를 매핑하는 매트릭스를 만들었는데, 의존 그래프를 정교하게 따지기보다는 보수적인 정책으로 단순화했습니다.
| 변경 경로 | 영향 서비스 |
|---|---|
| backend/** | 서비스 A(백엔드) + 서비스 B(워커) |
| admin/** | 서비스 C(어드민) |
| 공유 타입 패키지 또는 lockfile | 전체 서비스 (보수적으로 모두 배포) |
배포 순서는 의존 그래프 분석이 아니라 단순한 정책으로 정했습니다. 백엔드를 먼저 배포한다는 식의 고정된 규칙입니다. 과하게 똑똑한 순서 결정보다, 예측 가능하고 디버깅하기 쉬운 정책이 1인 운영 환경에서는 더 낫다는 판단이었습니다.
순차 배포의 실측 소요 시간도 흥미로웠습니다. 백엔드 빌드는 대략 365~396초, 워커는 423~426초, 어드민은 122~215초가 걸렸고, 세 서비스를 순차로 돌리면 합계 약 919초였습니다. 여기서 한 가지 주의할 점은, 워커는 HTTP 헬스체크 엔드포인트가 없어 running:unknown으로 표시되는 게 정상이라는 사실입니다. 이걸 모르면 멀쩡한 워커를 실패로 오인하게 됩니다.
빌드 중 CPU 90%는 정상일까
배포가 도는 동안 호스트의 CPU 사용률이 90%까지 치솟는 것을 보고 잠깐 긴장했습니다. 그러나 곰곰이 생각해보니 TypeScript 컴파일은 CPU-bound 작업이라, 빌드 중에 CPU가 높게 찍히는 것은 자연스러운 현상이었습니다. 다만 단일 호스트에서 운영 서비스와 빌드가 공존할 때는 운영 응답이 느려질 위험이 있으므로, 동시에 여러 빌드를 무작정 돌리지 않도록 신경 써야 했습니다.
이때 큐에서 대기 중인(queued) 빌드만 취소하는 게 합리적이었습니다. 이미 진행 중인(in_progress) 빌드를 취소하면 다음 트리거 때 처음부터 다시 빌드해야 하니 오히려 낭비이기 때문입니다.
배포 스크립트를 운영 서버 안으로 흡수할까
마지막으로 고민했던 것은, 이 배포 자동화 스크립트를 어디에 둘 것인가였습니다. 백엔드 모듈로 흡수할지, 별도 도구 패키지로 둘지, 아니면 자동화 전용 레포에 둘지 — 세 가지 선택지를 두고 비교했습니다.
결론적으로 외부에 두기로 했습니다. 이유는 chicken-and-egg 문제 때문입니다. 배포 대상인 백엔드 자체가 죽어 있는 상황에서 배포를 복구해야 한다면, 그 복구 로직이 죽은 백엔드 안에 들어 있으면 호출할 수가 없습니다. 자기 자신을 배포하는 코드를 자기 안에 두는 것은, 그 자신이 멈췄을 때 작동하지 않는다는 본질적 한계가 있습니다.
결정적 호출(curl)은 박제하고, 판단(에러 분류·복구 라우팅)만 LLM에 맡긴다.
이 한 줄이 전체 설계의 방향을 요약합니다. 배포 트리거나 상태 조회처럼 형태가 고정된 부분은 스크립트로 박아 두어 토큰도 안 들고 오작동도 없게 만들고, 빌드 로그를 읽고 실패 원인을 분류해 복구 경로를 정하는 판단의 영역만 에이전트에게 맡기는 것입니다. 실패 시에는 로그를 추출해 에러 패턴을 분류하고, 복구용 작업 공간에서 수정한 뒤 다시 리뷰 루프로 진입하는 흐름을 갖췄습니다.
정리하며
이번 사고에서 제가 배운 가장 중요한 교훈은, "에이전트가 알아서 끝까지 알려줄 것"이라는 가정을 버려야 한다는 것입니다. 에이전트는 자기 lifecycle이 끝나면 종료할 뿐이고, 진행 상황을 보고 싶다면 그것을 관찰하는 채널을 별도로 마련해야 합니다. 일하는 채널과 보는 채널의 분리 — 단순하지만 장시간 작업에서는 반드시 필요한 구조라는 생각이 들었습니다.
그 위에서 폴링 코드의 작은 실수들도 다시 보게 됐습니다. 컬럼명 하나가 어긋나면 조용히 0건이 되고, 종료 상태 하나를 빠뜨리면 끝난 작업이 영원히 멈추지 않습니다. 이런 사소한 함정일수록 자동화의 신뢰도를 갉아먹기 때문에, 앞으로는 스키마와 상태 집합을 코드에 박기 전에 한 번 더 대조해야겠다고 다짐했습니다.
아직은 부족하지만, 이렇게 한 번 넘어지면서 얻은 패턴들은 다음 자동화에서도 그대로 쓰일 것 같습니다. 장시간 배포든 CI든, 외부 상태를 폴링해야 하는 작업이라면 결국 같은 질문으로 돌아오게 됩니다. 일하는 채널과 보는 채널이 분리되어 있는가, 그리고 그 관찰은 끝나는 순간을 정확히 알고 있는가.
관련 글
사람 손 없이 PR→머지→배포: AI 에이전트 자동 연쇄를 설계하며 배운 것
코드 리뷰·머지 게이트·배포는 다 자동화했는데, 사슬의 진입점만 수동 호출로 남아 있었습니다. PR 생성 직후 자동 트리거, 보고가 아닌 GitHub를 직접 조회하는 머지 게이트, 배포 보류 게이팅까지 — 자동 연쇄를 직접 설계하며 겪은 시행착오를 정리했습니다.
Claude Code 라우팅 매트릭스 빈 행 3개를 새 전문 에이전트로 메운 회고
Claude Code 라우팅 매트릭스를 글로벌 CLAUDE.md 와 review-loop SKILL.md 에 박은 직후, 24행을 다시 들여다보니 fallback 으로 흘러갈 수밖에 없던 도메인 세 개가 어렵지 않게 떠올랐습니다. devops-engineer · dba · test-data-verifier 세 전문 에이전트를 더하며 만난 정의 작업의 무게를 정리한 후속편입니다.
대화 세션에서 돌리던 slash skill을 배치 자동화로 옮긴 이야기
매일 같은 slash skill을 손으로 돌리던 습관을 LaunchAgent 배치로 옮기면서 느낀 것은 기술 장벽보다 역할 재정의가 본질이라는 점이었습니다. 집행자에서 큐레이터로의 전환에 대한 기록입니다.