에이전트 루프 오케스트레이터 구현 — 작업 큐와 상태 관리로 자율 실행 시스템 만들기
새벽 3시에 잠에서 깨서 확인했더니 에이전트가 멈춰 있었어요. 로그를 보니 3시간 전에 예외가 터졌는데 재시도 로직이 없어서 그대로 죽었더라고요. 그날 아침 제가 만든 건 "한 번 돌리면 끝"이 아니라 "며칠간 혼자 돌아가는" 오케스트레이터였어야 한다는 걸 깨달았습니다.
이 글을 읽고 나면 작업 큐와 상태 관리로 사람 개입 없이 자율 실행되는 에이전트 루프를 직접 만들 수 있습니다. 스키마 설계부터 데드락 회피까지 실제로 동작하는 구조를 단계별로 설명합니다.
준비물
이 실습을 따라 하려면 다음이 필요합니다.
- SQLite나 JSON 파일을 다룰 수 있는 프로그래밍 언어(Python, Node.js 등)
- 기본적인 데이터베이스 개념(테이블, 인덱스, 트랜잭션)
- 파일 시스템 락(flock)이나 DB 트랜잭션 경험이 있으면 좋지만 필수는 아닙니다
SQLite는 서버 없이 파일 하나로 동작하니 프로토타입에 적합해요. 우리 팀은 JSON Lines 형식으로 시작했다가 나중에 SQLite로 갈아탔습니다.
작업 큐 스키마는 어떻게 설계하나요?
오케스트레이터의 핵심은 작업 큐입니다. 각 작업을 티켓처럼 관리하는 테이블을 먼저 만들어야 해요.
제가 처음 설계한 스키마는 이랬습니다.
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
worker TEXT,
result TEXT,
attempts INTEGER DEFAULT 0
);
각 컬럼의 역할입니다.
- id: 작업 고유 식별자. UUID나
task-20260814-001같은 타임스탬프 기반 문자열을 씁니다. - status: 작업 현재 상태.
pending/running/done/failed중 하나입니다. - created_at: 생성 시각(Unix timestamp). 오래된 작업부터 처리하는 FIFO 순서 보장에 필요합니다.
- worker: 처리 중인 에이전트 식별자. 디버깅할 때 "어느 워커가 멈췄나" 추적용입니다.
- result: 처리 결과 저장 공간. 성공 시 출력, 실패 시 에러 메시지를 JSON으로 넣습니다.
- attempts: 재시도 횟수. 무한 재시도를 막으려면 3회 같은 상한이 필요합니다.
작업이 생성되면 status='pending', 에이전트가 가져가면 running, 완료되면 done으로 전이됩니다.
입력 예시: 새 작업 3건을 큐에 추가하면 → 출력: task-001, task-002, task-003 각각 status='pending', attempts=0으로 저장됩니다.
인덱스는 반드시 걸어야 합니다. CREATE INDEX idx_status_created ON tasks(status, created_at); 없이 작업 1000개가 쌓이면 조회 성능이 10배 이상 차이 납니다. pending 작업을 빠르게 찾는 데 필수예요.
상태 전이 관리 — 언제 무엇을 바꾸는가
작업 상태는 정확한 규칙에 따라 전이되어야 합니다. 제가 운영하는 시스템에서는 이런 흐름을 따릅니다.
- pending → running: 에이전트가 작업을 가져갈 때
- running → done: 성공적으로 완료했을 때
- running → pending: 재시도 가능한 실패(네트워크 오류 등)
- running → failed: 재시도 한계 초과 또는 복구 불가능한 오류
각 전이마다 조건을 명확히 해야 합니다. 우리 파이프라인에서는 attempts < 3이면 pending으로 돌리고, 3회 초과하면 failed로 보냅니다.
입력→출력 예시: task-005가 네트워크 타임아웃으로 실패 → attempts=1, status='pending'으로 전이 → 10분 후 다시 실행됨. 이번엔 성공 → status='done', result에 출력 저장.
저는 처음에 재시도 로직을 생략했다가 한밤중에 API 장애 한 번으로 전체 파이프라인이 멈췄어요. 그날 3시간을 날리고 나서 모든 작업에 재시도 카운터를 붙였습니다.
heartbeat 패턴으로 멈춘 작업 감지하기
에이전트가 크래시로 죽으면 어떻게 될까요? 작업이 running 상태로 영원히 남아버립니다.
이 문제를 풀려면 heartbeat가 필요합니다. 스키마에 last_heartbeat 컬럼을 추가하세요.
ALTER TABLE tasks ADD COLUMN last_heartbeat INTEGER;
에이전트는 작업을 처리하는 동안 주기적으로(예: 30초마다) 이 값을 갱신합니다. 감시 프로세스가 5분 이상 갱신되지 않은 running 작업을 찾아서 pending으로 되돌리면 됩니다.
UPDATE tasks
SET status = 'pending', worker = NULL
WHERE status = 'running'
AND last_heartbeat < (현재시각 - 5분);
우리 시스템에서는 이 감시 루프를 4시간마다 돌립니다. 실제로 디스크 풀 문제로 에이전트가 죽었을 때 다음 감시 사이클에서 자동 복구됐어요. 사람이 개입한 건 디스크 청소뿐이었습니다.
락과 데드락 — 어떻게 피하나요?
여러 에이전트가 동시에 돌면 락 문제를 피할 수 없습니다. 특히 파일 기반 SQLite는 쓰기 락이 배타적이라 조심해야 해요.
제가 겪은 데드락은 이랬습니다. 에이전트 A가 작업 1을 처리하면서 결과를 쓰는 동안 에이전트 B가 작업 2를 가져가려 했는데, 둘 다 같은 DB 파일을 잡고 있어서 타임아웃이 났습니다.
해결책은 두 가지입니다.
- 트랜잭션을 짧게 유지: 작업 가져오기는
BEGIN IMMEDIATE, 업데이트,COMMIT으로 1초 안에 끝냅니다. - 읽기는 WAL 모드: SQLite를 WAL(Write-Ahead Logging) 모드로 설정하면 읽기와 쓰기가 동시에 가능해요.
PRAGMA journal_mode = WAL;
이 설정 후로 3일간 연속 실행에서 락 타임아웃이 단 한 번도 안 났습니다. 이전에는 하루 평균 2~3회 발생했어요.
흔한 실수와 해결법
첫 번째 삽질은 무한 재시도 루프였습니다. LLM API가 400 에러를 내는 잘못된 프롬프트를 재시도 제한 없이 돌렸더니 3시간 동안 같은 요청을 500번 넘게 보냈어요. 청구서를 보고 경악했습니다.
해결: 재시도 전에 에러 코드를 확인하세요. 4xx는 재시도해봐야 소용없으니 바로 failed로 보냅니다. 5xx만 재시도합니다.
두 번째는 상태 불일치였습니다. 작업은 done인데 결과 파일이 없는 경우가 있었어요. result 필드를 업데이트한 후 파일 쓰기가 실패했는데 롤백을 안 했던 겁니다.
해결: 파일 쓰기를 먼저 하고, 성공하면 DB를 업데이트합니다. 순서가 중요해요. "파일 있는데 DB는 pending"은 괜찮지만 "DB는 done인데 파일 없음"은 복구가 어렵습니다.
세 번째는 메모리 누수였습니다. 루프가 돌 때마다 DB 커넥션을 새로 만들었더니 하루 만에 파일 디스크립터가 고갈됐어요.
해결: 커넥션 풀을 쓰거나 하나의 커넥션을 재사용합니다. Node.js에서는 better-sqlite3를 쓰면 간단해요.
다음 행동
작업 큐와 상태 관리만 제대로 설계하면 에이전트가 며칠씩 혼자 돌아갑니다. 중요한 건 원자적 상태 전이, heartbeat, 짧은 트랜잭션, 재시도 한계입니다.
이제 직접 만들어보세요. 작은 프로토타입부터 시작해서 하루 정도 돌려보면 어디서 막히는지 바로 보입니다. 우리 팀은 첫 일주일에 세 번 갈아엎었지만, 지금은 사람이 한 달 동안 안 봐도 알아서 돌아가요.