무인 운전을 위한 쉘 스크립트를 한 달간 작성하며 겪은 3가지 실패 — '인간용 CLI'로 남겨두면 무엇이 망가지는가
요약
AI 에이전트를 위한 무인 자동화 환경에서 기존 CLI 스크립트가 겪는 실패 사례를 다룹니다. 상태 확인 함수의 종료 코드 처리 미흡과 타임아웃 없는 프로세스 호출로 인한 좀비 프로세스 누적 문제를 해결하는 방법을 제시합니다.
핵심 포인트
- 상태 확인 함수는 결과와 상관없이 반드시 종료 코드 0을 반환해야 함
- 무인 환경에서는 인간이 인지하지 못하는 에러 코드가 시스템 전체를 중단시킬 수 있음
- 외부 명령 호출 시 반드시 타임아웃 설정을 통해 프로세스 정체(Hang)를 방지해야 함
- AI 에이전트용 스크립트는 인간용 CLI와 달리 예외 처리에 훨씬 엄격해야 함
AI 에이전트에게 쉘 스크립트 (Shell Script)를 실행시켜 무인 운전을 시작하면, 인간이 사용하는 것을 전제로 작성된 기존의 CLI나 스크립트가 예상치 못한 곳에서 망가진다. 이번에는 나의 M1 Max 64GB 기기에서 「GPU를 하나씩 순서대로 사용하기」 위한 조율 시스템(신호등 스크립트·감시 데몬·상태 표시 앱의 3층 구조)을 실제로 약 1개월간 운용하며 겪은, 성격이 다른 3가지 실패를 실화 바탕으로 작성한다. 공통점은 모두 「인간이라면 알아차렸을 상태 변화」를, 무인으로 동작하는 코드가 묵묵히 놓치고 있었다는 점이다.
배경 — GPU를 다투는 3가지 툴을 하나씩 돌리는 메커니즘
나의 Mac에서는 Ollama (로컬 LLM)・ComfyUI (이미지·영상 생성)・ACE-Step (음악 생성)이 동일한 통합 메모리 상의 GPU를 다투고 있다. 두 개를 동시에 실행하면 Metal의 GPU 에러로 인해 종료되기 때문에, 조율용 쉘 스크립트 (이하 「신호등」)가 「지금 무언가가 생성 중인가」를 확인한 뒤 순서대로 실행하도록 설계했다. 이를 보완하는 것이 60초마다 동작하는 감시 데몬 (이하 「파수꾼」)과, 상태를 상시 표시하는 macOS 메뉴바 앱 (Swift 제작)이다. 3층 구조 모두 동일하게 「지금 GPU를 사용하고 있는 것은 누구인가」라는 상태를 보고 있어야 함에도 불구하고, 실제로는 3가지 독립적인 실패를 일으켰다.
실패 1 — 상태 확인 함수가 「말없이 실패하며 종료」되었다
신호등의 상태 확인 함수는 ComfyUI가 생성 중인지 여부를 내부 API로 판정하여 청신호 또는 적신호를 반환한다. 당초의 코드는 다음과 같은 형태였다.
gen_running() {
pgrep -f "ltx_pipelines|mlx_video|generate_av|stable_diffusion" ...
comfy_busy && echo "comfyui-job"
...
문제는 comfy_busy && echo ...라는 작성 방식이다. ComfyUI가 유휴 상태 (생성 중이 아님)라면 comfy_busy는 false를 반환하고, &&의 오른쪽은 실행되지 않으며, 함수 전체가 0이 아닌 종료 코드 (Exit Code)로 끝난다. 이 스크립트는 set -e 옵션이 붙은 채로 작성되었기 때문에, 호출 측의 r=$(gen_running)이 그 순간 통째로 종료되어 버렸다. 결과적으로 「ComfyUI가 한가할 때」에 한해서 신호등의 상태 확인 명령이 항상 적색 (혼잡)을 반환하는, 원인에 비해 찾아내기 어려운 버그가 되었다. 인간이 대화형으로 동일한 명령을 입력했다면 「어라, 동작하지 않는데 종료되네」라고 즉시 알아차렸겠지만, 무인으로 정기 실행되는 코드는 에러 코드를 보고 있지 않았다. 수정 방법은 한 줄이면 된다.
# 수정 후 (현재의 코드)
comfy_busy && echo "comfyui-job" || true
상태를 반환하기만 하는 함수는 상태가 어떠하든 반드시 0으로 종료시킨다. 이것이 첫 번째 교훈이다.
실패 2 — 타임아웃이 없는 쉘 실행 헬퍼가 129개의 프로세스를 쌓아두었다
상태 표시 앱은 몇 초 간격으로 ollama ps를 호출하여 「지금 LLM이 상주하고 있는가」를 확인하고 있었다. 그런데 신호등이 GPU를 다른 툴에 할당할 때, Ollama의 프로세스를 kill -STOP으로 일시 정지 (동결)시키는 설계로 되어 있다. 동결 중인 Ollama 서버에 ollama ps를 던지면 응답이 돌아오지 않고 행 (Hang) 상태가 된다. 당시의 쉘 실행 헬퍼에는 타임아웃 (Timeout) 개념이 없어서, 폴링 (Polling)을 할 때마다 행 상태인 프로세스가 계속 생겨났고, 아무도 모르는 사이에 축적되어 갔다. 실측 결과 이 정체가 129개에 달해 메모리를 압박했고, 「앱이 무겁다」 혹은 「GPU가 고장 난 것 아닌가」라는 오인을 불러일으켰다. 수정 후의 구현은 다음 2가지 사항이 적용되었다 (Swift 소스에서 확인 완료).
// ShellRunner.swift
static let defaultTimeout: TimeInterval = 12
...
...
12초 후에 타임아웃시키고, 기한이 오면 자식 프로세스를 포함하여 한꺼번에 kill 한다. 또한 호출 측에도 「락 (Lock)이 걸려 있는 (즉, Ollama가 동결 중임을 알고 있는) 동안에는 애초에 ollama ps를 호출하지 않는다」라는 가드 (Guard)를 추가했다.
// GpuSignal.swift
if info.lockName == nil {
// 락 상태가 아니라면 ollama ps로 상주 모델을 확인
...
「외부 명령을 호출하는 이상, 응답이 돌아오지 않을 가능성은 항상 존재한다」라는 당연한 전제가, 대화형으로 사용할 때는 신경 쓰이지 않을지 몰라도, 무인으로 초 단위로 계속 호출하는 코드에서는 즉각적인 실해(実害)가 된다. 타임아웃(Timeout)과 자식 프로세스별 kill은, AI에게 실행시키는 CLI·래퍼(Wrapper)에는 예외 없이 필요하다는 두 번째 교훈이 되었다.
실패 3 — 두 개의 독립된 가드(Guard)가 「말없이 서로 다른 기준」을 사용하고 있었다
감시자(Watchdog)에게는 「무거운 생성(Generation)이 진행 중이라면 Ollama를 해제(재개)하지 않는다」라는 가드가 처음부터 포함되어 있었다. 다만 그 판정은 프로세스 이름의 pgrep에만 의존하고 있었는데, ComfyUI의 작업(Job)은 서버 내부(main.py)에서 큐(Queue)로 처리되기 때문에 pgrep에는 전혀 나타나지 않는다. 반면 신호등(Traffic light) 측은 이미 「ComfyUI는 내부 API의 /queue를 통해 확인해야 한다」라고 수정되어 있었지만, 감시자 측은 그대로 방치되어 있었다. 결과적으로 감시자는 ComfyUI가 실제로 생성 중임에도 이를 감지하지 못하고 Ollama를 계속 해제했으며, 신호등이 어렵게 동결시킨 상태를 60초 이내에 무효화하고 있었다. 같은 「지금 누군가 GPU를 사용하고 있는가」라는 하나의 질문에 대해, 두 개의 스크립트가 서로 다른 방식으로 답을 내놓고 있었던 것이 근본 원인이었다. 수정 방법은 락 파일(Lock file)을 양측 공통의 기준으로 만드는 것이었다.
# ollama-watchdog.sh に追加した1行
[ -f /tmp/bigkiji_gpu.lock ] && exit 0
「생성 중인지 여부」를 판정하는 메커니즘을 두 곳 이상 갖춘다면, 탐지 방법을 반드시 일치시켜야 한다. 이것이 세 번째 교훈이다.
3건의 사례에서 알 수 있는 공통 패턴
| 실패 | 깨진 전제 | 해결 방법 |
|---|---|---|
| 상태 확인 함수의 암묵적 실패 종료 | 「유휴(Idle) 상태일 때는 아무것도 반환하지 않아도 된다」 | ` |
| 타임아웃 없는 외부 명령 호출 | 「명령은 언젠가 응답이 돌아온다」 | 타임아웃 12초 설정 + 자식 프로세스별 kill |
| 두 개의 독립된 가드의 탐지 방식 불일치 | 「같은 상태를 보고 있을 것이다」 | 락 파일(Lock file)이라는 단일 진실 공급원(Single Source of Truth) 기준으로 통일 |
세 가지 사례 모두, 대화형으로 사용하고 있었다면 인간이 그 자리에서 위화감을 눈치챌 수 있었을 부분들이다. 무인 운전으로 전환하는 순간, 그 피드백 루프가 사라진다. 역설적으로 말하면, 해결 방법은 모두 몇 줄이면 충분했다. 망가지는 깊이와 고치는 양이 균형을 이루지 못한다는 그 격차야말로, AI 주도(AI-driven) CLI 설계에서 얻어가야 할 핵심이라고 느낀다.
요약 — 그리고 「1년」이 아니라 「26일」이었다
이 기사는 당초 「1년 동안 계속 작성하며 겪은 실패」라는 관점으로 기획했으나, 쓰기 전에 자사의 기록을 다시 확인해 보니 이 신호등 스크립트의 설치는 2026-07-11이었고, 오늘은 2026-08-06으로 경과 시간은 26일이었다. 1년이라는 전제는 성립하지 않는다. 과장된 상태로 내보내기보다 실제 기간으로 쓰는 것이, 짧은 기간임에도 이만큼 망가지고 고쳤다는 밀도 있는 이야기로서 성립한다고 판단하여 제목과 본문을 정정한다. 숫자는 확인한 뒤에 내보낸다, 라는 원칙을 나 자신의 기사 제목에도 적용한 형태다.
참고 (관측만 되었을 뿐, 본문의 주장에는 사용하지 않음)
집필 시점에 Zenn의 daily trending에서 「AI-friendly한 CLI를 개발하는 테크닉」이라는 기사가 상위에 관측되었다 (본문은 미독 상태이므로 주장을 인용하지는 않았다). 이러한 관심의 높이 자체가 이번 실화를 기사로 만드는 동기가 되었다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기