들어가며
5월 트렌드 안전망 시리즈의 세 번째입니다. 비용 자가 관리·영구 메모리가 깔린 다음, 이제 진짜 위험이 큰 영역 — 장기 자율 실행 — 으로 들어갑니다. 5월 트렌드 정리에서 "인간이 잘 시간에 사고를 칠 수 있다"고 짚었던 그 영역이죠.
장기 실행 에이전트의 가장 흔한 사고 시나리오는 이렇게 흘러갑니다. "12시간짜리 마이그레이션 작업이 8시간 지점에서 LLM 5xx로 죽었다. 처음부터 다시 시작하면 또 8시간. 중간 결과는 어디 있는지 모름. 일부는 이미 외부 API에 보내서 되돌릴 수도 없음."
이 글은 그런 사고를 막는 세 가지 코드 패턴을 다룹니다.
- Checkpoint: 작업 단위마다 진행 상태를 영구화 → 어디서 깨졌는지 정확히 안다
- Resume: 마지막 성공 지점부터 재개 → 처음부터 다시 안 한다
- 실패 복구: 멱등성 + 부작용 추적 + 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 프로토콜의 실전 보안 모델을 다룹니다. 사내 에이전트가 외부 회사의 에이전트와 직접 협상할 때, 책임 소재·권한 위임·사기 방지를 어떻게 풀 것인지의 패턴입니다.