#AI에이전트#오케스트레이터#자동화#작업큐#상태관리

에이전트 루프 오케스트레이터 구현 — 작업 큐와 상태 관리로 자율 실행 시스템 만들기

에이전트 루프 오케스트레이터 구현 — 작업 큐와 상태 관리로 자율 실행 시스템 만들기 핵심 개념을 담은 커버 이미지
에이전트 루프 오케스트레이터 구현 — 작업 큐와 상태 관리로 자율 실행 시스템 만들기 핵심 개념을 담은 커버 이미지

새벽 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 작업을 빠르게 찾는 데 필수예요.

상태 전이 관리 — 언제 무엇을 바꾸는가

상태 전이 관리 — 언제 무엇을 바꾸는가
상태 전이 관리 — 언제 무엇을 바꾸는가

작업 상태는 정확한 규칙에 따라 전이되어야 합니다. 제가 운영하는 시스템에서는 이런 흐름을 따릅니다.

  1. pending → running: 에이전트가 작업을 가져갈 때
  2. running → done: 성공적으로 완료했을 때
  3. running → pending: 재시도 가능한 실패(네트워크 오류 등)
  4. 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 파일을 잡고 있어서 타임아웃이 났습니다.

해결책은 두 가지입니다.

  1. 트랜잭션을 짧게 유지: 작업 가져오기는 BEGIN IMMEDIATE, 업데이트, COMMIT으로 1초 안에 끝냅니다.
  2. 읽기는 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, 짧은 트랜잭션, 재시도 한계입니다.

이제 직접 만들어보세요. 작은 프로토타입부터 시작해서 하루 정도 돌려보면 어디서 막히는지 바로 보입니다. 우리 팀은 첫 일주일에 세 번 갈아엎었지만, 지금은 사람이 한 달 동안 안 봐도 알아서 돌아가요.

이 글이 도움이 됐다면 공유해 주세요
X 공유

관련 글