Published on

AutoGPT 멀티에이전트 충돌 해결 - Redis 락과 이벤트소싱

Authors

멀티에이전트 기반 AutoGPT(혹은 유사 오케스트레이션) 시스템을 운영하다 보면, “에이전트가 똑같은 일을 두 번 했다”, “서로 다른 에이전트가 같은 리소스를 동시에 수정해서 결과가 꼬였다”, “재시도 중에 상태가 역전됐다” 같은 충돌을 빠르게 만나게 됩니다. 단일 프로세스에서 순차 실행하던 데모와 달리, 실제 운영에서는 워커 수가 늘고 큐가 분산되며, LLM 호출이 느리고, 툴 실행이 비결정적이라 충돌 확률이 급격히 상승합니다.

이 글은 멀티에이전트 충돌을 Redis 분산 락으로 “동시 수정”을 막고, 이벤트소싱(Event Sourcing) 으로 “무슨 일이 일어났는지”를 불변 로그로 남겨 재현성·복구성·감사 가능성을 얻는 방법을 다룹니다. 또한 툴콜 재시도/백오프, 중복 이벤트, 순서 보장, 사가(Saga) 보상까지 함께 연결해 운영 가능한 형태로 정리합니다.

관련해서 재시도 정책은 LLM/툴 호출 안정성과 직결되므로, 백오프 설계는 Azure OpenAI 429/503 재시도·백오프 설계 가이드도 함께 참고하면 좋습니다. 분산 트랜잭션 관점의 보상 설계는 MSA Saga 보상 트랜잭션 설계 실수 7가지와도 맞닿아 있습니다.

멀티에이전트 충돌 유형: 무엇이 깨지는가

AutoGPT 류의 시스템을 “에이전트 N개 + 작업 큐 + 공유 상태(메모리/DB) + 툴 실행(브라우저, 크롤러, DB, Git 등)”로 모델링하면 충돌은 대개 아래 범주로 떨어집니다.

1) 중복 실행(Duplicate execution)

  • 같은 taskId가 큐에 두 번 들어감(프로듀서 버그, at-least-once 전달)
  • 워커가 처리 중 죽고 재시작되며 동일 작업이 재수행됨
  • LLM 툴콜이 타임아웃 나서 재시도했는데 실제로는 서버에서 처리 완료

2) 동시 수정(Concurrent write)

  • 두 에이전트가 같은 문서/레코드/파일을 동시에 수정
  • “계획 수립 에이전트”와 “실행 에이전트”가 같은 상태 필드를 갱신
  • 체크포인트 업데이트가 경합하며 마지막 쓰기만 남는 lost update

3) 순서 역전(Out-of-order)

  • 이벤트가 늦게 도착하거나 재시도로 인해 순서가 뒤집힘
  • “승인됨” 이후에 “승인 요청됨” 이벤트가 반영되는 식의 역전

4) 부분 실패(Partial failure)와 보상 필요

  • 툴 실행은 성공했지만 상태 저장이 실패
  • 상태 저장은 성공했지만 툴 실행이 실패
  • 외부 부작용(메일 발송, 결제 요청, PR 생성 등)은 되돌리기 어려움

이 네 가지가 섞이면 디버깅이 급격히 어려워집니다. 그래서 해결 전략은 보통 두 축으로 갑니다.

  • 동시성 제어: “같은 리소스를 동시에 건드리지 못하게” 막는다(락, CAS, 버전)
  • 불변 로그: “무슨 일이 일어났는지”를 남겨 재현/복구/감사한다(이벤트소싱)

Redis 분산 락: 최소한의 동시성 제어

Redis 락은 멀티 워커/멀티 에이전트에서 가장 현실적인 1차 방어선입니다. 핵심은 다음 세 가지입니다.

  1. 락 키 설계: 무엇을 보호할지(작업 단위 vs 리소스 단위)
  2. TTL: 워커가 죽어도 락이 영구 점유되지 않게
  3. 소유권 검증 후 해제: 남의 락을 풀지 않게

어떤 단위로 락을 잡을까

  • taskId 락: 같은 작업 중복 실행 방지에 유효
  • resourceId 락: 같은 문서/레코드/파일 동시 수정 방지에 유효
  • conversationId 락: 동일 대화 흐름에서 상태 경합 방지에 유효

보통은 taskId로 중복 실행을 막고, 실제 부작용이 발생하는 부분은 resourceId로 추가 보호하는 2단 구성이 운영에서 안전합니다.

Redis 락 구현 예시(Node.js)

아래는 SET key value NX PX ttl 패턴과 Lua 스크립트로 “소유자만 해제”를 보장하는 가장 흔한 형태입니다.

import { createClient } from "redis";
import crypto from "crypto";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const UNLOCK_LUA = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end
`;

export async function withRedisLock<T>(
  key: string,
  ttlMs: number,
  fn: () => Promise<T>
): Promise<{ ok: true; value: T } | { ok: false; reason: "locked" }> {
  const token = crypto.randomUUID();

  // NX: only set if not exists, PX: ttl in ms
  const acquired = await redis.set(key, token, { NX: true, PX: ttlMs });
  if (acquired !== "OK") return { ok: false, reason: "locked" };

  try {
    const value = await fn();
    return { ok: true, value };
  } finally {
    // release only if token matches
    await redis.eval(UNLOCK_LUA, { keys: [key], arguments: [token] });
  }
}

락 TTL과 “연장(renewal)” 문제

LLM 호출과 툴 실행은 예측 불가능하게 길어질 수 있습니다. TTL을 짧게 잡으면 작업 중 락이 풀려 다른 워커가 들어오고, 길게 잡으면 장애 시 복구가 느립니다.

실무에서는 아래 중 하나를 선택합니다.

  • 짧은 TTL + 주기적 연장(heartbeat): 워커가 살아있을 때만 락 유지
  • 긴 TTL + 작업을 잘게 쪼개기: 락 점유 시간을 줄이는 방향

연장을 한다면 “연장도 소유자만 가능”해야 하며, 연장 실패 시 작업을 중단하고 안전하게 재시도하도록 설계합니다.

이벤트소싱: 충돌을 “없애기”보다 “관리 가능하게” 만들기

락만으로는 충분하지 않습니다. 이유는 단순합니다.

  • 락은 현재 시점의 동시성만 제어합니다.
  • 실제 운영 장애는 재시도, 중복 메시지, 부분 실패에서 더 자주 터집니다.

이벤트소싱은 상태를 직접 덮어쓰는 대신, “발생한 사실(Event)”을 불변으로 저장하고, 현재 상태는 이벤트를 재생(replay)해 만든다는 접근입니다.

이벤트소싱이 멀티에이전트에 특히 유리한 이유

  1. 재현성: 어떤 에이전트가 어떤 근거로 어떤 툴을 호출했는지 추적 가능
  2. 중복 내성: 이벤트에 eventId를 두고 멱등 처리하면 at-least-once 전달에 강해짐
  3. 순서 제어: version(aggregate revision)을 두면 out-of-order를 감지 가능
  4. 부분 실패 복구: “툴 실행됨/저장됨”을 이벤트로 분리해 보상/재처리 가능

추천 아키텍처: Redis 락 + Event Store + Projector

구성 요소를 역할로 나누면 설계가 깔끔해집니다.

  • Command Handler: “무언가 해라” 요청을 받음(예: RunTool, UpdatePlan)
  • Lock Manager(Redis): 동일 Aggregate에 대한 동시 커맨드 실행 제어
  • Event Store(DB): 이벤트를 append-only로 저장
  • Projector(Read Model): 이벤트를 읽어 현재 상태를 물리 테이블/캐시에 투영
  • Outbox/Dispatcher: 이벤트 기반 후속 작업(툴 실행, 알림 등)을 신뢰성 있게 전달

여기서 핵심은 “상태 업데이트”를 직접 하지 않고, 이벤트 append가 성공하면 그 이벤트를 기반으로 상태가 따라오게 만드는 것입니다.

이벤트 모델링: Aggregate, Version, Idempotency

Aggregate(집합) 정하기

멀티에이전트에서 충돌을 제어하려면 “락의 경계”와 “버전의 경계”가 필요합니다. 보통 아래 중 하나로 잡습니다.

  • TaskAggregate: 하나의 작업(task) 단위
  • ResourceAggregate: 하나의 문서/레코드/파일 단위
  • ConversationAggregate: 한 대화 흐름 단위

예를 들어 “한 작업(task)을 여러 에이전트가 분담”한다면 TaskAggregate가 자연스럽습니다.

이벤트 스키마 예시

이벤트는 최소한 다음 필드를 갖게 하는 것이 운영에 유리합니다.

  • eventId: UUID, 멱등 키
  • aggregateId: 작업 또는 리소스 ID
  • version: 해당 aggregate에서의 순번(1부터 증가)
  • type: 이벤트 타입
  • payload: JSON
  • causationId: 이 이벤트를 유발한 커맨드/이전 이벤트 ID
  • correlationId: 한 플로우를 묶는 트레이싱 ID
  • createdAt

version은 “낙관적 동시성 제어”의 핵심입니다.

Event Store 구현: PostgreSQL append-only + unique 제약

PostgreSQL을 이벤트 저장소로 쓰는 예시입니다. (다른 DB도 가능하지만, 운영 난이도와 인덱싱을 고려하면 Postgres는 무난합니다.)

CREATE TABLE event_store (
  event_id uuid PRIMARY KEY,
  aggregate_id text NOT NULL,
  version bigint NOT NULL,
  type text NOT NULL,
  payload jsonb NOT NULL,
  causation_id uuid,
  correlation_id uuid,
  created_at timestamptz NOT NULL DEFAULT now()
);

-- 같은 aggregate에서 version은 유일해야 함
CREATE UNIQUE INDEX ux_event_store_aggregate_version
ON event_store (aggregate_id, version);

-- projector가 aggregate별로 빠르게 읽기 위한 인덱스
CREATE INDEX ix_event_store_aggregate
ON event_store (aggregate_id, created_at);

여기서 ux_event_store_aggregate_version가 매우 중요합니다. 두 워커가 동시에 같은 aggregate에 같은 version을 쓰려 하면 한쪽이 실패하고, 그 실패는 곧 “동시성 충돌”의 신호가 됩니다.

커맨드 처리 흐름: 락으로 직렬화, 버전으로 안전장치

권장 흐름은 다음과 같습니다.

  1. aggregateId 기준 Redis 락 획득
  2. DB에서 해당 aggregate의 마지막 version 조회
  3. 커맨드 검증 후 새 이벤트를 version + 1로 append
  4. 트랜잭션 커밋
  5. 락 해제
  6. projector/outbox가 이벤트를 처리해 read model 갱신 및 후속 액션 실행

Node.js(의사 코드)로 표현하면 아래와 같습니다.

import { Pool } from "pg";
import { withRedisLock } from "./lock";

const pg = new Pool({ connectionString: process.env.DATABASE_URL });

async function appendEvent(client: any, evt: {
  eventId: string;
  aggregateId: string;
  version: number;
  type: string;
  payload: any;
  causationId?: string;
  correlationId?: string;
}) {
  await client.query(
    `INSERT INTO event_store(event_id, aggregate_id, version, type, payload, causation_id, correlation_id)
     VALUES ($1,$2,$3,$4,$5,$6,$7)`
    ,
    [evt.eventId, evt.aggregateId, evt.version, evt.type, evt.payload, evt.causationId ?? null, evt.correlationId ?? null]
  );
}

export async function handleCommand(aggregateId: string, command: any) {
  const lockKey = `lock:agg:${aggregateId}`;

  const locked = await withRedisLock(lockKey, 15_000, async () => {
    const client = await pg.connect();
    try {
      await client.query("BEGIN");

      const { rows } = await client.query(
        `SELECT COALESCE(MAX(version), 0) AS v FROM event_store WHERE aggregate_id = $1`,
        [aggregateId]
      );
      const currentVersion = Number(rows[0].v);

      // 도메인 규칙 검증은 여기서 수행
      const nextEvent = {
        eventId: crypto.randomUUID(),
        aggregateId,
        version: currentVersion + 1,
        type: "ToolRequested",
        payload: {
          tool: command.tool,
          input: command.input,
        },
        causationId: command.commandId,
        correlationId: command.correlationId,
      };

      await appendEvent(client, nextEvent);
      await client.query("COMMIT");

      return nextEvent;
    } catch (e) {
      await client.query("ROLLBACK");
      throw e;
    } finally {
      client.release();
    }
  });

  if (!locked.ok) return { status: "busy" };
  return { status: "accepted", event: locked.value };
}

여기서 Redis 락이 직렬화를 제공하지만, DB의 unique 제약(aggregateId, version)이 최후의 안전장치가 됩니다. 운영에서는 “락 획득 성공했는데도 충돌”이 드물게 발생할 수 있습니다(락 TTL 만료, 네트워크 파티션, 워커 GC 스톱 등). 그래서 DB 제약까지 반드시 두는 편이 좋습니다.

Projector(투영) 설계: read model은 결국 캐시다

이벤트소싱에서 “현재 상태 테이블”은 이벤트에서 파생된 결과물입니다. 따라서 projector는 다음을 만족해야 합니다.

  • 멱등성: 같은 이벤트를 두 번 처리해도 결과가 같아야 함
  • 체크포인트: 어디까지 처리했는지 저장
  • 재생 가능: 장애 시 다시 돌려도 일관성이 유지

예시로 task_read_model을 만들고, projector가 이벤트를 반영합니다.

CREATE TABLE task_read_model (
  task_id text PRIMARY KEY,
  status text NOT NULL,
  plan jsonb,
  last_version bigint NOT NULL DEFAULT 0,
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE projector_checkpoint (
  projector_name text PRIMARY KEY,
  last_event_created_at timestamptz NOT NULL,
  last_event_id uuid NOT NULL
);

projector는 event_store에서 아직 처리하지 않은 이벤트를 읽어 task_read_model.last_version을 증가시키며 반영합니다. 이벤트 순서는 aggregateId 기준으로 version을 따르는 것이 안전합니다.

Outbox 패턴: “이벤트 저장”과 “툴 실행”을 분리

멀티에이전트에서 가장 흔한 함정은 아래입니다.

  • DB에 상태 저장
  • 그 다음 툴 실행 메시지 publish

여기서 publish가 실패하면 “상태는 바뀌었는데 실행이 안 됨”이 됩니다. 반대로 publish는 됐는데 상태 저장이 실패하면 “실행은 됐는데 기록이 없음”이 됩니다.

해결책은 Outbox입니다. 이벤트 저장 트랜잭션 안에서 outbox 레코드를 함께 쓰고, 별도 디스패처가 outbox를 읽어 큐로 전달합니다.

CREATE TABLE outbox (
  id bigserial PRIMARY KEY,
  event_id uuid NOT NULL,
  topic text NOT NULL,
  payload jsonb NOT NULL,
  published_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX ux_outbox_event_topic
ON outbox (event_id, topic);

이렇게 하면 “이벤트 append 성공”이 곧 “언젠가 publish 된다”로 바뀝니다.

충돌 시나리오로 보는 동작

시나리오 A: 같은 task를 두 에이전트가 동시에 실행

  • 두 워커가 동일 aggregateId에 대해 커맨드 처리 시도
  • Redis 락으로 한쪽만 진입, 다른 쪽은 busy로 빠짐
  • busy 쪽은 큐 재시도(지수 백오프) 또는 드롭

이때 중요한 점은 재시도 폭주를 막는 것입니다. 백오프/지터 설계는 Azure OpenAI 429/503 재시도·백오프 설계 가이드의 원칙을 그대로 적용할 수 있습니다.

시나리오 B: 락 TTL 만료로 동시 처리 발생

  • 워커 A가 락을 잡고 오래 걸리는 툴 실행
  • TTL 만료로 워커 B가 락 획득
  • 둘 다 version + 1 이벤트를 append 시도
  • DB unique 제약으로 한쪽 insert 실패

실패한 쪽은 “동시성 충돌”로 판단하고 aggregate를 다시 로드한 뒤, 필요하면 커맨드를 재평가합니다(이미 원하는 상태라면 no-op 처리).

시나리오 C: 툴 실행은 성공했는데 결과 저장 이벤트가 중복

  • 툴 워커가 ToolCompleted 이벤트를 append
  • 네트워크 타임아웃으로 워커가 동일 이벤트를 재전송
  • eventId를 멱등 키로 사용하면 중복 insert가 막힘

이벤트소싱에서는 eventId를 고유하게 만들고, insert 충돌을 “이미 처리됨”으로 간주하는 방식이 흔합니다.

보상(Compensation): 에이전트 시스템에서의 Saga 현실

툴 실행이 외부 부작용을 만들면(예: 이슈 생성, PR 생성, 메일 발송) 되돌리기가 쉽지 않습니다. 이때 이벤트소싱은 “무슨 일이 일어났는지”를 기록해주지만, “되돌리기”는 별도의 정책이 필요합니다.

  • 되돌릴 수 있는 작업은 CompensationRequested 같은 이벤트로 보상 워크플로우를 시작
  • 되돌리기 어려운 작업은 “중복 실행을 막는 것”이 더 중요(멱등 키, 외부 시스템 idempotency key)

사가 관점에서 자주 하는 실수는 보상 트랜잭션을 너무 낙관적으로 보는 것입니다. 보상은 실패할 수 있고, 보상 자체도 재시도/멱등성이 필요합니다. 이 부분은 MSA Saga 보상 트랜잭션 설계 실수 7가지에서 다룬 함정들이 멀티에이전트에도 그대로 적용됩니다.

운영 체크리스트: “락만”으로는 부족한 지점

1) 락 키 네이밍과 범위

  • lock:task:${taskId}lock:resource:${resourceId}를 구분
  • 너무 넓게 잡으면 처리량이 급감, 너무 좁게 잡으면 충돌이 남음

2) 관측 가능성(Observability)

  • correlationId를 모든 이벤트/로그/트레이스에 넣기
  • “락 대기/실패 횟수”, “version 충돌 횟수”, “outbox 적체”를 메트릭화

3) 재시도 정책

  • 워커 재시도는 지수 백오프 + 지터
  • “busy(락 실패)”와 “transient error(네트워크)”를 구분

4) projector 재처리 전략

  • projector는 언제든 재실행 가능해야 함
  • read model이 깨졌을 때 이벤트를 처음부터 재생할 수 있어야 함

5) 스토리지 관리

이벤트는 계속 쌓입니다. 인덱스와 파티셔닝, 아카이빙 전략이 필요합니다. Postgres를 쓴다면 테이블/인덱스가 커질수록 유지보수 작업(VACUUM 등)이 중요해지므로, 이벤트 테이블도 운영 관점에서 관리 대상입니다.

결론: 충돌을 “없애는” 대신 “통제 가능한 비용”으로 만든다

AutoGPT 멀티에이전트 충돌은 분산 시스템의 전형적인 문제(중복, 순서, 부분 실패)와 LLM/툴 실행의 비결정성이 결합한 형태입니다. Redis 락은 동시 수정을 빠르게 줄여주지만, 재시도와 장애가 있는 현실에서는 이벤트소싱이 제공하는 불변 로그와 버전 기반 제어가 시스템을 “복구 가능한 상태”로 바꿉니다.

정리하면 다음 조합이 가장 실용적입니다.

  • Redis 락으로 aggregate 단위 직렬화
  • DB unique 제약으로 최후의 동시성 안전장치
  • 이벤트소싱으로 모든 상태 변경을 append-only로 기록
  • projector로 read model을 만들고, outbox로 후속 액션을 신뢰성 있게 전달
  • 멱등 키, 백오프/지터, 보상 워크플로우로 운영 안정성 확보

이 구조를 적용하면 “에이전트가 실수해도” 시스템은 기록을 남기고, 중복을 흡수하고, 재처리로 복구할 수 있게 됩니다. 멀티에이전트 운영에서 가장 중요한 것은 완벽한 방지가 아니라, 실패를 전제로 한 설계와 관측 가능성입니다.