최신 트렌드

장기 자율 실행 AI 에이전트 실전 - Spring Boot로 12시간+ 작업의 checkpoint·재개·실패 복구 구축하기

백엔드 개발자 김승원 2026. 5. 6. 10:08

들어가며

5월 트렌드 안전망 시리즈의 세 번째입니다. 비용 자가 관리·영구 메모리가 깔린 다음, 이제 진짜 위험이 큰 영역 — 장기 자율 실행 — 으로 들어갑니다. 5월 트렌드 정리에서 "인간이 잘 시간에 사고를 칠 수 있다"고 짚었던 그 영역이죠.

장기 실행 에이전트의 가장 흔한 사고 시나리오는 이렇게 흘러갑니다. "12시간짜리 마이그레이션 작업이 8시간 지점에서 LLM 5xx로 죽었다. 처음부터 다시 시작하면 또 8시간. 중간 결과는 어디 있는지 모름. 일부는 이미 외부 API에 보내서 되돌릴 수도 없음."

이 글은 그런 사고를 막는 세 가지 코드 패턴을 다룹니다.

  1. Checkpoint: 작업 단위마다 진행 상태를 영구화 → 어디서 깨졌는지 정확히 안다
  2. Resume: 마지막 성공 지점부터 재개 → 처음부터 다시 안 한다
  3. 실패 복구: 멱등성 + 부작용 추적 + Saga 보상 → 외부 시스템과 일관성 유지

구현은 Spring Boot 4 + PostgreSQL + Spring State Machine 조합. CQRS & 이벤트 소싱의 아이디어를 작게 응용한 패턴입니다.

1. 왜 단순 retry로 안 되는가

"실패하면 다시 하면 되지 않나"의 한계를 먼저 짚습니다.

단순 retry의 함정 구체 사례
비용 폭증 8시간 진행분을 또 8시간 = LLM 토큰·외부 API 호출 2배
중복 부작용 이미 보낸 메일을 또 보냄, 이미 결제된 건이 또 결제됨
데이터 일관성 깨짐 외부 시스템 A는 업데이트됐는데 B는 안 됨 (부분 실패)
관찰 불가 "왜 깨졌는지" 모름 → 반복 발생
사용자 신뢰 파괴 같은 작업이 여러 번 보이거나, 이상한 상태로 멈춤

해결의 큰 그림

장기 작업을 작은 step의 시퀀스로 모델링하고, 각 step마다 (a) 입력·출력·부작용을 영구화, (b) 실패 시 정확히 그 step부터 재개, (c) 외부 부작용은 멱등 키로 중복 차단 — 이 3가지가 코드의 골격입니다.

2. 작업 모델 - Job · Step · Checkpoint

먼저 도메인 모델을 정리합니다. 모든 장기 작업은 같은 형태로 표현됩니다.

Job (전체 작업)
  ├── status: PENDING | RUNNING | PAUSED | COMPLETED | FAILED
  ├── plan: List<StepDefinition>       // 어떤 step을 어떤 순서로
  ├── current_step_index
  └── steps: List<StepExecution>
         ├── status: PENDING | RUNNING | SUCCEEDED | FAILED | COMPENSATED
         ├── input (JSONB)
         ├── output (JSONB)
         ├── side_effects (JSONB[])      // 외부 부작용 추적
         ├── idempotency_key
         ├── attempts
         └── checkpoint (JSONB)          // step 내부 진행 상태

스키마

CREATE TABLE agent_job (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id VARCHAR(64) NOT NULL,
    user_id VARCHAR(64) NOT NULL,
    name VARCHAR(128) NOT NULL,
    status VARCHAR(32) NOT NULL,
    plan JSONB NOT NULL,
    current_step_index INT DEFAULT 0,
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    last_heartbeat TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE step_execution (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    job_id UUID NOT NULL REFERENCES agent_job(id),
    step_index INT NOT NULL,
    step_name VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    input JSONB,
    output JSONB,
    checkpoint JSONB,           -- step 내부 진행 상태
    side_effects JSONB,         -- 외부 부작용 (idempotency_key 포함)
    idempotency_key VARCHAR(128) NOT NULL,
    attempts INT DEFAULT 0,
    error TEXT,
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    UNIQUE (job_id, step_index)
);

CREATE INDEX idx_job_status ON agent_job(status, last_heartbeat);
CREATE INDEX idx_step_job ON step_execution(job_id, step_index);

여기서 가장 중요한 건 UNIQUE (job_id, step_index)idempotency_key. 이 두 컬럼이 "같은 step이 두 번 실행되지 않게" 만드는 DB 레벨 안전장치입니다.

3. Step 추상화 - 모든 작업의 단위

장기 작업의 모든 단위 step은 같은 인터페이스를 따릅니다. 이게 retry·resume·compensate의 토대.

public interface AgentStep<I, O> {

    /** Step 이름 (DB 저장용) */
    String name();

    /** Step 실행 - checkpoint를 통해 중간 재개 가능 */
    StepResult<O> execute(I input, StepContext ctx);

    /** 실패 시 보상 트랜잭션 (Saga) */
    default void compensate(I input, O partialOutput, StepContext ctx) {
        // 기본은 no-op. 외부 부작용 있는 step만 오버라이드
    }

    /** 멱등 키 생성 - 같은 입력이면 같은 키 */
    String idempotencyKey(I input);
}

public record StepResult<O>(
    StepStatus status,
    O output,
    Map<String, Object> checkpoint,    // 중간 재개용
    List<SideEffect> sideEffects,
    String error
) {
    public static <O> StepResult<O> success(O output, List<SideEffect> sideEffects) {
        return new StepResult<>(StepStatus.SUCCEEDED, output, null, sideEffects, null);
    }

    public static <O> StepResult<O> partial(O partial, Map<String, Object> checkpoint) {
        return new StepResult<>(StepStatus.PARTIAL, partial, checkpoint, List.of(), null);
    }

    public static <O> StepResult<O> failure(String error, List<SideEffect> sideEffects) {
        return new StepResult<>(StepStatus.FAILED, null, null, sideEffects, error);
    }
}

public record SideEffect(
    String type,           // "http_call", "email_sent", "db_write" 등
    String targetSystem,
    String idempotencyKey, // 중복 방지
    Map<String, Object> details
) {}

StepContext - step에 주입되는 환경

public interface StepContext {
    UUID jobId();
    String tenantId();
    String userId();
    Map<String, Object> previousCheckpoint();   // 재개 시 이전 진행상태
    int attemptNumber();                         // 1=첫 시도, 2+=재시도

    /** 중간 진행상태 저장 (장기 step에서 호출) */
    void saveCheckpoint(Map<String, Object> checkpoint);

    /** 부작용 기록 (외부 호출 직후 즉시 호출) */
    void recordSideEffect(SideEffect effect);

    /** 멱등 키로 이전 부작용 존재 확인 */
    boolean wasSideEffectRecorded(String idempotencyKey);

    /** 사용자 승인 게이트 (위험도 높은 결정) */
    boolean requestUserApproval(String description, Duration timeout);
}

4. JobRunner - 메인 실행 루프

Job의 step을 순서대로 실행하면서 checkpoint·resume·compensate를 조율하는 핵심 컴포넌트.

@Service
@RequiredArgsConstructor
public class JobRunner {

    private final JobRepository jobRepo;
    private final StepRepository stepRepo;
    private final StepRegistry stepRegistry;
    private final TransactionTemplate tx;
    private final HeartbeatService heartbeat;

    public void run(UUID jobId) {
        AgentJob job = jobRepo.findByIdForUpdate(jobId)
            .orElseThrow();

        if (!Set.of("PENDING", "PAUSED", "RUNNING").contains(job.getStatus())) {
            log.info("Job {} not runnable (status={})", jobId, job.getStatus());
            return;
        }

        markRunning(job);
        heartbeat.start(jobId);  // 30초마다 last_heartbeat 갱신

        try {
            for (int i = job.getCurrentStepIndex(); i < job.getPlan().size(); i++) {
                StepDefinition def = job.getPlan().get(i);
                StepExecution exec = stepRepo
                    .findByJobAndIndex(jobId, i)
                    .orElseGet(() -> createPending(job, i, def));

                if (exec.getStatus().equals("SUCCEEDED")) {
                    continue;  // 이미 성공한 step은 스킵 (resume)
                }

                StepStatus result = runStep(job, exec, def);

                if (result == StepStatus.FAILED) {
                    handleFailure(job, exec);
                    return;
                }

                if (result == StepStatus.PAUSED) {
                    markPaused(job, i);
                    return;
                }

                job.setCurrentStepIndex(i + 1);
                jobRepo.save(job);
            }
            markCompleted(job);
        } finally {
            heartbeat.stop(jobId);
        }
    }

    private StepStatus runStep(AgentJob job, StepExecution exec, StepDefinition def) {
        AgentStep<Object, Object> step = stepRegistry.get(def.stepName());
        Object input = resolveInput(job, def, exec);
        String idemKey = step.idempotencyKey(input);

        // 같은 idempotency_key로 이미 SUCCEEDED 인 게 있으면 그대로 결과 사용
        Optional<StepExecution> existing = stepRepo.findSucceededByKey(idemKey);
        if (existing.isPresent()) {
            log.info("Step {} already succeeded (idempotent), reusing output",
                def.stepName());
            mirrorOutput(exec, existing.get());
            return StepStatus.SUCCEEDED;
        }

        exec.setStatus("RUNNING");
        exec.setAttempts(exec.getAttempts() + 1);
        exec.setStartedAt(Instant.now());
        stepRepo.save(exec);

        StepContext ctx = new DefaultStepContext(
            job.getId(),
            job.getTenantId(),
            job.getUserId(),
            exec.getCheckpoint(),  // 이전 중간 진행상태 (재개)
            exec.getAttempts(),
            stepRepo, exec
        );

        try {
            StepResult<Object> result = step.execute(input, ctx);
            exec.setOutput(result.output());
            exec.setSideEffects(result.sideEffects());
            exec.setStatus(result.status().name());
            exec.setCompletedAt(Instant.now());
            stepRepo.save(exec);
            return result.status();
        } catch (Exception e) {
            log.error("Step {} threw exception", def.stepName(), e);
            exec.setStatus("FAILED");
            exec.setError(e.getMessage());
            stepRepo.save(exec);
            return StepStatus.FAILED;
        }
    }
}

핵심 동작 정리

  • 이미 성공한 step은 스킵 (i < currentStepIndex 가 자동 처리)
  • 같은 idempotency_key가 있으면 결과 재활용 (멱등성)
  • 각 step 종료마다 currentStepIndex 갱신 + DB commit (checkpoint)
  • 예외 발생 시 즉시 FAILED 마킹 (재시도는 별도 정책으로)

5. Step 내부 checkpoint - 한 step이 너무 길 때

step 단위 재개로도 부족한 경우가 있습니다. 한 step이 "1000개 파일 처리" 같이 자체 내부 진행이 긴 경우.

@Component
@RequiredArgsConstructor
public class BulkFileProcessStep implements AgentStep<BulkInput, BulkOutput> {

    @Override
    public String name() { return "bulk_file_process"; }

    @Override
    public StepResult<BulkOutput> execute(BulkInput input, StepContext ctx) {
        Map<String, Object> cp = ctx.previousCheckpoint();
        int startFrom = cp != null
            ? ((Number) cp.getOrDefault("processedCount", 0)).intValue()
            : 0;

        List<String> results = cp != null
            ? (List<String>) cp.getOrDefault("partialResults", new ArrayList<>())
            : new ArrayList<>();

        log.info("Resuming from file {} of {}", startFrom, input.files().size());

        for (int i = startFrom; i < input.files().size(); i++) {
            String file = input.files().get(i);
            try {
                String result = processFile(file);
                results.add(result);
            } catch (Exception e) {
                log.error("Failed at file index {}: {}", i, file, e);
                // 부분 결과 + checkpoint를 저장하고 종료
                ctx.saveCheckpoint(Map.of(
                    "processedCount", i,
                    "partialResults", results,
                    "failedFile", file,
                    "errorAt", Instant.now().toString()
                ));
                return StepResult.failure("Stuck at file: " + file, List.of());
            }

            // 매 100개마다 checkpoint 저장 (중간 진행상태)
            if ((i + 1) % 100 == 0) {
                ctx.saveCheckpoint(Map.of(
                    "processedCount", i + 1,
                    "partialResults", results
                ));
                log.info("Checkpoint at file {}/{}", i + 1, input.files().size());
            }
        }

        return StepResult.success(
            new BulkOutput(results),
            List.of()
        );
    }

    @Override
    public String idempotencyKey(BulkInput input) {
        return "bulk:" + DigestUtils.md5DigestAsHex(
            String.join(",", input.files()).getBytes());
    }
}

Checkpoint 빈도 트레이드오프

빈도 장점 단점
매 항목마다 최소 손실 DB I/O 폭증
매 100개 균형 최대 99개 손실
매 1000개 I/O 적음 최대 999개 손실
시간 기반 (30초마다) 예측 가능 구현 복잡

실무 기본값: 50~200개 단위 또는 30~60초 간격. 항목당 처리 시간이 길수록 빈도는 더 자주.

6. 외부 부작용 멱등화 - 가장 큰 사고 영역

외부 시스템 호출은 "네트워크가 끊겼지만 상대 서버는 처리했다"의 회색지대가 항상 있습니다. 멱등 키 + 부작용 기록이 표준 방어선.

패턴: 호출 전후 기록 + 멱등 키 헤더

@Component
@RequiredArgsConstructor
public class ExternalEmailStep implements AgentStep<EmailInput, EmailOutput> {

    private final EmailGatewayClient gateway;

    @Override
    public String name() { return "send_email"; }

    @Override
    public StepResult<EmailOutput> execute(EmailInput input, StepContext ctx) {
        String idemKey = idempotencyKey(input);

        // 1) 같은 키로 이미 보냈는지 확인
        if (ctx.wasSideEffectRecorded(idemKey)) {
            log.info("Email {} already sent, skipping", idemKey);
            return StepResult.success(
                new EmailOutput("already_sent", idemKey),
                List.of()
            );
        }

        // 2) 호출 직전에 "의도" 기록 (recipient, subject 등)
        ctx.recordSideEffect(new SideEffect(
            "email_attempt",
            "email_gateway",
            idemKey,
            Map.of(
                "to", input.to(),
                "subject", input.subject(),
                "attemptedAt", Instant.now().toString()
            )
        ));

        // 3) Idempotency-Key 헤더와 함께 호출
        EmailGatewayResponse resp = gateway.send(
            EmailGatewayRequest.builder()
                .idempotencyKey(idemKey)   // 게이트웨이 측 중복 방지
                .to(input.to())
                .subject(input.subject())
                .body(input.body())
                .build()
        );

        // 4) 성공 기록
        ctx.recordSideEffect(new SideEffect(
            "email_sent",
            "email_gateway",
            idemKey,
            Map.of(
                "messageId", resp.messageId(),
                "sentAt", Instant.now().toString()
            )
        ));

        return StepResult.success(
            new EmailOutput("sent", resp.messageId()),
            List.of()
        );
    }

    @Override
    public String idempotencyKey(EmailInput input) {
        // 같은 (수신자, 제목, 본문 hash) → 같은 키
        String body = input.to() + "|" + input.subject() + "|"
            + DigestUtils.md5DigestAsHex(input.body().getBytes());
        return "email:" + DigestUtils.md5DigestAsHex(body.getBytes());
    }
}

3중 방어선

  • 1중: wasSideEffectRecorded 로 우리 DB에 이미 보낸 기록이 있는지 확인
  • 2중: 외부 게이트웨이에 Idempotency-Key 헤더 전송 (게이트웨이가 중복 차단)
  • 3중: 호출 전후로 부작용 기록 → 죽었을 때 "의도는 있었으나 결과는 모름" 상태를 명확히 표시

7. Saga 보상 트랜잭션 - 부분 실패 회복

Step 5까지 성공하고 Step 6에서 영구 실패한 경우, 이미 외부에 끼친 부작용을 어떻게 되돌릴지의 패턴.

@Component
@RequiredArgsConstructor
public class CompensatingExecutor {

    private final StepRepository stepRepo;
    private final StepRegistry stepRegistry;

    public void compensate(UUID jobId, int failedStepIndex) {
        // 실패 step 이전의 모든 SUCCEEDED step을 역순으로 보상
        List<StepExecution> toCompensate = stepRepo
            .findByJobAndIndexLessThan(jobId, failedStepIndex)
            .stream()
            .filter(s -> s.getStatus().equals("SUCCEEDED"))
            .sorted(Comparator.comparingInt(StepExecution::getStepIndex).reversed())
            .toList();

        for (StepExecution exec : toCompensate) {
            AgentStep<Object, Object> step = stepRegistry.get(exec.getStepName());
            try {
                step.compensate(exec.getInput(), exec.getOutput(),
                    new DefaultStepContext(/* ... */));
                exec.setStatus("COMPENSATED");
            } catch (Exception e) {
                // 보상 실패는 가장 까다로운 상태 - 사람 개입 필요
                exec.setStatus("COMPENSATION_FAILED");
                log.error("Compensation failed for step {} of job {}",
                    exec.getStepIndex(), jobId, e);
                alertHumanIntervention(jobId, exec, e);
            }
            stepRepo.save(exec);
        }
    }

    private void alertHumanIntervention(UUID jobId, StepExecution exec, Exception e) {
        // Slack/PagerDuty 알림 + 운영 대시보드 마킹
        // 보상 실패는 자동화로 못 풉니다. 사람이 봐야 합니다.
    }
}

모든 step이 보상 가능한가

아닙니다. 일부 부작용은 본질적으로 비가역. 보상 불가 step은 다음 원칙으로 다룹니다.

부작용 보상 가능? 전략
이메일 발송 "취소 안내 메일" 후속 발송으로 완화
외부 결제 ⚠️ 환불 API 호출. 게이트웨이 정책 의존
DB 업데이트 이전 값 저장 후 롤백
S3 업로드 객체 삭제
SNS 게시 삭제 시도 + 정정 게시
물리적 배송 사람 개입 워크플로우로 escalate

비가역 부작용 step 앞에는 반드시 requestUserApproval() 게이트를 걸어 사고를 사전 차단해야 합니다.

8. Heartbeat - "멈춘 작업" vs "느린 작업" 구분

장기 작업은 "진짜 일하는 중"인지 "죽었는데 안 알려진 것"인지 구분이 어렵습니다. Heartbeat가 답.

@Service
@RequiredArgsConstructor
public class HeartbeatService {

    private final JobRepository jobRepo;
    private final ScheduledExecutorService scheduler =
        Executors.newScheduledThreadPool(4);
    private final Map<UUID, ScheduledFuture<?>> running = new ConcurrentHashMap<>();

    public void start(UUID jobId) {
        ScheduledFuture<?> f = scheduler.scheduleAtFixedRate(() -> {
            try {
                jobRepo.updateHeartbeat(jobId, Instant.now());
            } catch (Exception e) {
                log.warn("Heartbeat failed for job {}", jobId, e);
            }
        }, 0, 30, TimeUnit.SECONDS);
        running.put(jobId, f);
    }

    public void stop(UUID jobId) {
        Optional.ofNullable(running.remove(jobId))
            .ifPresent(f -> f.cancel(false));
    }
}

@Component
@RequiredArgsConstructor
public class StuckJobDetector {

    private final JobRepository jobRepo;
    private final JobRunner runner;

    @Scheduled(fixedDelay = 60_000)
    public void detect() {
        // 5분 이상 heartbeat 없는 RUNNING job → stuck 으로 판정
        Instant cutoff = Instant.now().minus(Duration.ofMinutes(5));
        List<AgentJob> stuck = jobRepo.findRunningWithHeartbeatBefore(cutoff);

        for (AgentJob job : stuck) {
            log.warn("Job {} appears stuck (last heartbeat at {})",
                job.getId(), job.getLastHeartbeat());
            // 다른 인스턴스가 살아 있을 수 있으므로 안전 표시 후 다시 큐잉
            jobRepo.markPaused(job.getId(), "stuck_no_heartbeat");
            // 실제 재개는 별도 워커가 결정 (분산 락 + 정책)
        }
    }
}

9. 위험도 기반 사용자 승인 게이트

장기 자율 실행의 핵심 안전장치. 모든 결정을 자율로 두지 않고 "위험도 높은" 결정만 사람에게 물어봅니다.

@Component
public class RiskClassifier {

    public RiskLevel classify(StepDefinition def, Object input) {
        // 1) 카테고리별 정적 규칙
        if (def.tags().contains("financial")) return RiskLevel.HIGH;
        if (def.tags().contains("external_email") &&
            recipientCount(input) > 100) return RiskLevel.HIGH;
        if (def.tags().contains("data_destructive")) return RiskLevel.CRITICAL;

        // 2) 동적 임계치
        if (def.estimatedCostUsd() > 50) return RiskLevel.HIGH;
        if (def.estimatedDurationMinutes() > 120) return RiskLevel.MEDIUM;

        return RiskLevel.LOW;
    }
}

// 실행 직전 게이트
if (riskClassifier.classify(def, input) == RiskLevel.CRITICAL) {
    boolean approved = ctx.requestUserApproval(
        "위험 작업: " + def.description(),
        Duration.ofHours(2)
    );
    if (!approved) {
        return StepResult.failure("User did not approve", List.of());
    }
}

승인 채널

  • Slack interactive button (가장 빠름)
  • 이메일 + 승인 링크 (감사 추적 강함)
  • 웹 대시보드 알림 + 승인 (UI 통합)

10. 분산 환경 - 동시 실행 방지

여러 워커 인스턴스가 같은 job을 동시에 실행하면 부작용 폭주. Redis 캐시 전략의 분산 락 패턴을 그대로 활용.

@Service
@RequiredArgsConstructor
public class JobLockService {

    private final StringRedisTemplate redis;

    /** 25분 락 (heartbeat가 30초마다이므로 충분) */
    public boolean tryAcquire(UUID jobId, String workerId) {
        String key = "lock:job:" + jobId;
        Boolean ok = redis.opsForValue()
            .setIfAbsent(key, workerId, Duration.ofMinutes(25));
        return Boolean.TRUE.equals(ok);
    }

    public void renew(UUID jobId, String workerId) {
        String key = "lock:job:" + jobId;
        String holder = redis.opsForValue().get(key);
        if (workerId.equals(holder)) {
            redis.expire(key, Duration.ofMinutes(25));
        }
    }

    public void release(UUID jobId, String workerId) {
        String key = "lock:job:" + jobId;
        // Lua script로 atomic compare-and-delete
        String script = """
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            end
            return 0
            """;
        redis.execute(
            new DefaultRedisScript<>(script, Long.class),
            List.of(key),
            workerId
        );
    }
}

워커 루프 패턴

@Scheduled(fixedDelay = 5000)
public void poll() {
    String workerId = InetAddress.getLocalHost().getHostName() + ":"
        + ProcessHandle.current().pid();

    List<UUID> runnable = jobRepo.findRunnableJobs(20);

    for (UUID jobId : runnable) {
        if (lockService.tryAcquire(jobId, workerId)) {
            try {
                jobRunner.run(jobId);
            } finally {
                lockService.release(jobId, workerId);
            }
        }
    }
}

11. 관측·알림 - 운영의 눈

장기 작업은 메트릭 없이는 운영 불가. Prometheus + Grafana로 다음 핵심 지표를 노출.

메트릭 의미 알림 기준
job.duration.seconds job 전체 실행 시간 P95가 평소 2배 초과
step.failure.rate step별 실패율 특정 step 30% 초과
step.attempts.avg 평균 재시도 횟수 2회 초과 (불안정 신호)
job.stuck.count heartbeat 끊긴 job 1건 이상 즉시 알림
compensation.failure.count 보상 실패 1건 이상 즉시 알림 (사람 개입 필수)
side_effect.duplicate.count 멱등 키로 차단된 중복 참고용 (정상 동작)
checkpoint.size.bytes checkpoint 크기 10KB 초과 시 설계 재고

12. 안티패턴

안티패턴 왜 문제
step 단위 영구화 없이 메모리에서만 진행 인스턴스 죽으면 처음부터
idempotency_key 없이 외부 호출 재시도 시 중복 부작용
heartbeat 없음 죽은 작업과 느린 작업 구분 불가
모든 결정 자율 비가역 사고 발생 시 회복 불가
compensate 미구현 부분 실패 시 일관성 깨짐
분산 락 없음 여러 워커가 같은 job 중복 실행
checkpoint를 LLM 출력 통째로 크기 폭증, 직렬화 비용
실패 시 무한 재시도 비용 폭주 + 외부 서비스 부하

13. 도입 단계

단계 활성화 점검
1주차 Job/Step 모델 + 단일 워커 30분 이내 작업으로 시작
2주차 step 단위 checkpoint + resume 강제 종료 후 재개 검증
3주차 idempotency_key + 부작용 추적 중복 호출 시뮬레이션
4주차 Saga compensate 중간 실패 시나리오 테스트
5주차 heartbeat + stuck 탐지 의도적 워커 죽이기 테스트
6주차 분산 락 + 멀티 워커 경합 시나리오 부하 테스트
7주차+ 위험도 게이트 + 12시간+ 작업 점진적 시간 확대

마치며

장기 자율 실행 에이전트의 핵심을 정리합니다.

  • 모든 작업을 step 시퀀스로 모델링. "하나의 큰 함수"가 아니라 "작은 step의 체인"으로 보면 retry·resume·compensate가 자연스럽게 풀립니다. 이 모델이 없으면 12시간짜리 작업은 운영 불가.
  • idempotency_key는 외부 호출의 생명선. 게이트웨이 측 + 우리 DB 양쪽에 동일 키 사용. 둘 중 하나만 깔면 회색지대("보냈는지 모름")에서 사고가 터집니다. 3중 방어선(local DB · 게이트웨이 · 부작용 기록)이 표준.
  • 모든 부작용에 보상 함수 정의 시도. 보상 불가능한 부작용은 명확히 표시하고 사용자 승인 게이트로 보호. 자동 보상은 자동화로 풀고, 보상 실패는 무조건 사람 개입.
  • Heartbeat는 분산 환경 필수. 워커 인스턴스 자체가 죽는 시나리오는 매우 흔합니다. 30초 heartbeat + 5분 무응답 stuck 판정 + 분산 락 재할당이 안정적인 패턴.
  • 위험도 분류 후 사람에게 위임할 결정 명확화. "비가역 + 고비용" 결정은 자율 금지. 방어선 구축 글에서 다룬 "승인 게이트" 패턴이 여기서 빛납니다.

이로써 5월 트렌드의 세 안전망(비용 자가 관리 · 영구 메모리 · 장기 실행 복구)이 모두 깔렸습니다. 다음 글에서는 네 번째 트렌드인 A2A 프로토콜의 실전 보안 모델을 다룹니다. 사내 에이전트가 외부 회사의 에이전트와 직접 협상할 때, 책임 소재·권한 위임·사기 방지를 어떻게 풀 것인지의 패턴입니다.