여러 Agent가 동시에 뛸 때, 중복 실행과 기억 뒤섞임은 어떻게 막는가
← 전체 목차 · 이전: 다중 Agent를 "역할 나눠 대화"로 조율하면 왜 무너지는가 · 다음: 여러 Agent가 같은 MCP 서버를 함께 써도 되는가
"조정기가 작업을 나눠주고, 실패하면 재시도한다"는 것은 작업 분배이지 일관성 보장이 아니다. 분배는 그 순간 중복 배정만 막을 뿐이고, 진짜 사고는 배정 이후 세 지점 — 의미가 겹치는 작업의 중복 실행, 부분 실패 후 재시도로 인한 부작용 중복, 그리고 병렬 세션 간 기억 오염 — 에서 터진다. 그리고 이 세 지점을 "덮어쓰기 vs 아님"이라는 제목으로만 보고 있으면 방향부터 틀린다: 진짜 사고는 대부분 "잘못 읽음"이지 "잘못 씀(덮어쓰기)"이 아니다.
결과 일관성: 실행 전에 막을 것인가, 실행 후에 대조할 것인가
| 실패 유형 | 원인 | 사후 대조의 비용 |
|---|
| 의미 중복 실행 | 작업 분해 입도가 너무 굵어 의미가 겹침 | 이미 두 번 실행됨 |
| 재시도 부작용 중복 | 이미 성공한 하위 작업을 실패로 오판해 재실행 | 이미 발생한 결제·발송이 중복됨 |
| 쓰기 경합 덮어쓰기 | 통신 지연으로 서로 "내가 먼저"라 착각 | 늦게 쓴 쪽이 이긴 걸 아무도 모름 |
| 조정기 다운 | 배정 상태 유실 | 재발송 또는 누락 |
| 이종 프로토콜 | 결과 포맷 불일치 | 집계 자체가 실패 |
기준선: 전역 작업 상태머신 + 하위 작업 멱등성 + 최종 일관성 보상을 한 세트로 삼는다.
분해 전 → 의미 지문으로 중복 판정 (문자열 해시가 아니라 의미 기반)
배정 전 → agent_task 표에 먼저 INSERT
삽입 성공 → 배정 실행
삽입 실패(유니크 제약 위반) → 이미 존재하는 결과를 그대로 반환
실행 중 → 상태 전이는 낙관적 락으로: 현재 상태 기준 갱신
동시 두 번째 요청 → 영향 행수 0 → 충돌로 판정, 재조회
실행 결과 → 모든 하위 작업은 멱등 실행 지원 (재시도가 안전해지는 전제)
이종 Agent → 프로토콜 어댑터가 멱등 인터페이스와 결과 포맷을 통일, 원생 로직엔 무침투
핵심은 판정 지점을 "분배 전"으로 당기는 것이다. 의미 지문에 DB 유니크 제약을 걸면 "중복인가"라는 질문이 삽입 성공/실패라는 원자적 신호로 바뀐다 — 실행 후 사람이 대조하는 것보다 훨씬 싸다. agent_task 표는 global_task_id / sub_task_id / agent_id / task_status / self_execute_result / compensate_status 여섯 필드로 이 판정과 보상을 함께 지탱한다.
보상 ≠ 롤백이다. 이미 대외적으로 효과를 낸 동작(결제, 발송)은 되돌릴 수 없으므로 compensate_status를 task_status와 별도 컬럼으로 두어 "실행 실패 + 보상 중"과 "실행 실패 + 보상도 실패"를 구분해야 보상 자체도 멱등하게 재시도할 수 있다.
기억 격리: "덮어쓰기"가 아니라 "잘못 읽는 것"이 진짜 사고다
다중 Agent가 병렬로 뛸 때 "기억이 서로 덮어써지지 않는가"라는 질문 자체가 함정이다. Agent는 기억 격리 능력을 선천적으로 타고나지 않는다 — 격리는 세 가지 설정의 산물이다.
| 판정 요소 | 기본 모드 | 위험 모드 |
|---|
| 회화 식별 | 전용 sessionId + 프레임워크 회화 색인표로 조회·강검증 | 저장 자원을 아끼려고 여러 경량 Agent가 하나의 저장 인스턴스를 풀링 공유 |
| 결과 | 사설 기억은 sessionId로 격리됨 | sessionId 전달 오류 또는 색인 강검증 누락 시 다른 Agent의 기억을 잘못 읽음(오염된 읽기) |
| 발현 조건 | 단일 회화·저부하 | 동시성이 올라가야 재현됨 — 로컬 Demo에서 절대 안 보임 |
사설 기억: sessionId로 격리, 읽기/쓰기 모두 허용
전역 기억: 공용 지식·전역 상태, 읽기만 허용 — 전역 측이 사설 기억을 되쓰지 않음
전역 공유 기억은 읽기 전용으로만 열면 그 자체로는 위험하지 않다. 진짜 위험은 "저장 자원을 아끼려고 여러 회화가 같은 저장 인스턴스를 공유"하는 최적화이며, 이때 sessionId 강검증이 빠지면 격리가 조용히 사라진다.
핵심 통찰
- 작업 분배는 "누가 하는가"만 풀고, 일관성은 "다 하고 나서 맞는가"를 푼다 — 조정기가 중복 배정을 막는 것은 배정 순간뿐이다. 진짜 중복 실행은 의미 중복, 실패 재시도, 쓰기 경합이라는 배정 이후 세 지점에서 발생한다. 조정기를 일관성 방안으로 착각하는 것은 라우터의 능력으로 트랜잭션의 책임을 대신하려는 것과 같다.
- 판정 지점을 앞당길수록 보상 범위가 줄어든다 — 의미 지문 + DB 유니크 제약은 "중복인가"를 실행 후 인간 대조가 아니라 삽입 한 번의 성공/실패라는 원자적 판정으로 바꾼다. 이 설계 원칙은 다른 중복 방지 문제에도 그대로 이식된다.
- 멱등성은 재시도가 성립하기 위한 전제이지 부가 기능이 아니다 — 멱등하지 않은 시스템에서 "재시도 붙이면 되지"는 위험한 말이다. 이미 대외적으로 효과를 낸 쓰기·결제를 한 번 더 실행하게 만든다. 먼저 멱등성이 있어야 재시도가 열린다.
- 상태머신 + 낙관적 락은 "상태 비동기화"를 "갱신 실패"로 퇴화시킨다 — 매 변경이 현재 상태 기준이어야 하고, 두 번째 동시 요청은 영향 행수 0을 받는다. 관측 불가능한 분산 비일관성이 재조회·재계산·경보가 가능한 로컬 신호로 바뀐다.
- "덮어쓰기"가 아니라 "잘못 읽음"이 기억 사고의 본질이다 — 저장 공간을 공유하는 풀링이 원인이지, 두 Agent가 서로의 기억을 망가뜨리며 쓰는 게 아니다. 이 구분을 못 하면 방어 설계가 쓰기 잠금 쪽으로 잘못 향한다.
- 격리성은 컴포넌트의 타고난 속성이 아니라 설정과 검증이 함께 만든 결과다 — sessionId, 회화 색인표, 프레임워크 강검증 이 셋이 함께 작동해야 "격리됨"이 성립한다. 저장 자원을 아끼려고 풀링을 켜는 순간 이 중 하나가 빠지면 결론이 뒤집힌다.
- 동시성은 격리 결함의 현상액이다 — 두 사고(중복 실행, 기억 오염) 모두 동시성이 올라가야 재현된다. 로컬 Demo가 저부하·단일 세션으로 도는 한 "안 터졌다"는 것은 반증이 아니라 조건 미충족일 뿐이다.
- 자원 최적화는 암묵적으로 격리성과 맞바꾼다 — "여러 경량 논리 개체가 하나의 물리 인스턴스를 공유한다" 형태의 모든 최적화(저장 풀링, KV Cache 공유 등)는 격리 리스크 지점으로 재검토돼야 한다. 수익은 즉시 보이고 손실은 부하가 오른 뒤에야 드러난다.
엔지니어링 실전 Tips
- 중복 판정 지문은 "요청" 대신 "의미"에 묶을 것. 문자열 해시로 지문을 만들면 같은 뜻을 다르게 표현한 하위 작업이 중복 판정을 피해간다.
- 유니크 제약이 가장 값싼 분산 락이다. 자체 분산 락보다 컴포넌트 하나 적고, 트랜잭션 롤백에 자연히 딸려 원자성을 얻는다.
- 낙관적 락 실패는 예외가 아니라 신호로 처리할 것. 영향 행수 0이면 누군가 이미 상태를 진행시켰다는 뜻 — 그대로 재시도하지 말고 상태를 다시 읽고 계속 실행할지부터 판단한다.
compensate_status는 task_status와 별도 컬럼으로 둘 것. 그래야 "실패 + 보상 중"과 "실패 + 보상도 실패"라는 서로 다른 종단 상태를 표현하고, 보상 자체도 멱등하게 재시도할 수 있다.
sessionId는 선택 인자가 아니라 읽기/쓰기 강제 필수 인자로 만들 것. 모든 사고가 "전달 오류 또는 누락"에서 시작된다.
- 회화 색인표는 사후 반증 자료로도 쓸 것. "이 기억이 실제로 누구 것인가"를 되짚을 수 있는 유일한 물증이므로 쓰기 시점뿐 아니라 사고 조사 시점에도 조회 가능해야 한다.
- 격리성 검증은 반드시 부하 테스트에 묶을 것. 기능 테스트만으로는 이 계열 결함이 전혀 드러나지 않는다. sessionId가 호출 체인에서 오류 없이 전달되는지를 동시성을 올린 상태에서 확인해야 한다.
- 저장 풀링·공유 최적화를 도입할 때마다 격리 위험 체크리스트를 강제할 것. "물리 자원 하나를 논리 개체 여럿이 공유하는가"라는 질문에 예가 나오면, 그 지점에 강검증이 있는지부터 확인한다.