DEV Community

TreeSoop
TreeSoop

Posted on

멀티 에이전트 동시성 예외를 디버깅하고 동기화하는 구조 설계

팀 전원이 동일한 AI 개발 환경을 쓰면 특이한 일이 생긴다. 동시성 버그를 재현하고 추적하는 맥락이 개발자마다 달라지지 않는다. 이 글은 멀티 에이전트 파이프라인에서 흔히 터지는 동시성 예외 상황을 어떻게 구조적으로 잡을 수 있는지, 그 메커니즘과 설계 결정 기준을 다룬다.


멀티 에이전트 오케스트레이션에서 발생하는 비동기 데이터 동시성 이슈

멀티 에이전트 시스템은 단일 LLM 호출과 근본적으로 다른 실패 패턴을 만든다. 에이전트 A가 데이터를 읽는 동안 에이전트 B가 같은 레코드를 수정하면, 결과가 예측 불가능해진다. 이걸 경쟁 조건(race condition) 이라 부르는 건 누구나 알지만, LLM 기반 에이전트에서는 재현 자체가 어렵다는 게 진짜 문제다.

LLM 에이전트는 호출 간 지연이 불규칙하다. 토큰 생성 속도, API 응답 레이턴시, 외부 툴 호출 시간이 매번 달라진다. 그래서 전통적인 동시성 테스트처럼 "동일한 타이밍에 두 스레드를 실행"하는 방식이 통하지 않는다.

흔히 나타나는 이슈를 유형별로 정리하면:

  • Stale read: 에이전트가 캐시된 상태를 읽고 이미 변경된 데이터에 기반해 액션을 취함
  • Double write: 두 에이전트가 거의 동시에 같은 필드에 서로 다른 값을 씀
  • Deadlock-like hang: 에이전트 A가 B의 응답을 기다리고, B는 A의 상태 업데이트를 기다리는 순환 대기
  • Partial commit: 트랜잭션 중간에 에이전트가 실패해서 상태가 반쯤 업데이트된 채로 남음

이 중 Partial commit이 가장 조용하고 위험하다. 에러 로그가 안 남는 경우도 있고, 며칠 뒤 다운스트림 에이전트가 오염된 상태를 읽고 나서야 문제가 드러난다.


클로드 코드 맥스 환경을 활용한 동시성 예외 검증과 추적 최적화

동시성 버그를 잡는 첫 번째 조건은 재현 가능한 환경이다. TreeSoop에서는 팀 전원이 동일한 AI 개발 환경(Claude Code Max)을 표준으로 쓴다. 이게 단순한 도구 통일 이상의 의미를 갖는 이유는, 동시성 디버깅 세션에서 "내 로컬에서는 안 터졌는데"라는 말이 사라지기 때문이다.

Claude Code의 서브에이전트 실행 구조를 이용하면 동시성 시나리오를 코드로 명세하고 즉시 실행할 수 있다. 핵심은 각 에이전트 호출을 명시적인 타임스탬프와 상태 스냅샷과 함께 로깅하는 것이다.

import asyncio
import time
from dataclasses import dataclass, field
from typing import Any

@dataclass
class AgentTrace:
    agent_id: str
    action: str
    state_before: dict
    state_after: dict
    timestamp: float = field(default_factory=time.time)

async def traced_agent_call(agent_id: str, shared_state: dict, action_fn) -> AgentTrace:
    state_before = shared_state.copy()
    await action_fn(shared_state)
    return AgentTrace(
        agent_id=agent_id,
        action=action_fn.__name__,
        state_before=state_before,
        state_after=shared_state.copy(),
        timestamp=time.time()
    )
Enter fullscreen mode Exit fullscreen mode

이 패턴의 목적은 사후 분석이다. 에이전트가 어떤 순서로 실행됐고, 각 시점의 상태가 무엇이었는지를 재구성할 수 있어야 한다. 분산 시스템 추적 표준인 OpenTelemetry의 스팬(span) 개념을 에이전트 단위에 적용하면 기존 APM 툴과의 연동도 자연스럽다.


실시간 트래픽 대응을 위한 비동기 큐 동기화 프로토콜은 어떻게 설계할까?

단순한 asyncio.Lock으로는 에이전트 수가 늘어날수록 병목이 생긴다. 실시간 트래픽이 들어오는 파이프라인이라면 큐 기반 동기화가 더 현실적이다.

설계 결정의 핵심은 세 가지 선택지 중에서 트레이드오프를 명확히 하는 것이다.

동기화 방식 처리량 순서 보장 구현 복잡도 적합한 상황
asyncio.Lock 낮음 강함 낮음 에이전트 수 ≤ 3, 단순 상태
메시지 큐 (Redis Streams) 높음 중간 중간 에이전트 수 ≥ 5, 이벤트 기반
분산 락 (Redlock) 중간 강함 높음 크리티컬 섹션, 멀티 노드
CRDT 기반 상태 병합 높음 약함 높음 충돌 허용 가능한 비동기 집계

대부분의 업무 자동화 파이프라인에서는 Redis Streams 조합이 균형점이다. 각 에이전트가 이벤트를 소비하는 컨슈머 그룹으로 동작하고, 상태 변경은 스트림에 기록한다.

import redis.asyncio as aioredis

async def agent_consumer(agent_id: str, stream_name: str, group_name: str):
    r = aioredis.Redis()

    # 컨슈머 그룹이 없으면 생성
    try:
        await r.xgroup_create(stream_name, group_name, id="0", mkstream=True)
    except Exception:
        pass  # 이미 존재하면 무시

    while True:
        messages = await r.xreadgroup(
            group_name, agent_id, {stream_name: ">"}, count=1, block=5000
        )
        if not messages:
            continue

        for _, msg_list in messages:
            for msg_id, data in msg_list:
                await process_event(data)
                await r.xack(stream_name, group_name, msg_id)
Enter fullscreen mode Exit fullscreen mode

xack를 명시적으로 호출하는 게 중요하다. 에이전트가 처리 도중 실패하면 메시지가 PEL(Pending Entry List) 에 남아 있다가 다른 에이전트가 재처리할 수 있다. 이게 Partial commit 문제를 방어하는 첫 번째 라인이다.


CTO와 기술진이 검토해야 할 자동화 파이프라인의 에러 핸들링

에러 핸들링 설계에서 자주 빠지는 함정이 있다. 에러를 잡는 것에러에서 복구하는 것을 같은 문제로 취급하는 것이다. 멀티 에이전트 파이프라인에서는 이 둘을 명확히 분리해야 한다.

검토해야 할 에러 분류 기준:

  • 일시적 오류 (Transient): API 타임아웃, 네트워크 단절 → 지수 백오프(exponential backoff)로 재시도
  • 논리적 오류 (Logical): 잘못된 입력 형식, 스키마 불일치 → 재시도가 아닌 데드레터 큐(DLQ)로 격리
  • 상태 오염 (State corruption): Partial commit 이후의 불일치 → 체크포인트에서 상태 롤백
  • 에이전트 충돌 (Agent conflict): 두 에이전트가 같은 리소스를 동시 변경 → 충돌 감지 후 하나를 재시작

상태 롤백을 구현할 때 이벤트 소싱(Event Sourcing) 패턴이 유용하다. 현재 상태를 직접 저장하는 대신, 상태를 변경하는 이벤트를 순서대로 기록하면 어느 시점으로든 되돌아갈 수 있다.

from enum import Enum
from dataclasses import dataclass
from typing import List

class EventType(Enum):
    ORDER_CREATED = "order_created"
    STATUS_UPDATED = "status_updated"
    AGENT_ASSIGNED = "agent_assigned"

@dataclass
class DomainEvent:
    event_type: EventType
    payload: dict
    sequence: int
    agent_id: str

def replay_state(events: List[DomainEvent], until_sequence: int) -> dict:
    state = {}
    for event in sorted(events, key=lambda e: e.sequence):
        if event.sequence > until_sequence:
            break
        apply_event(state, event)
    return state
Enter fullscreen mode Exit fullscreen mode

until_sequence 파라미터 하나로 특정 시점의 상태를 재구성할 수 있다. 동시성 버그가 발생했을 때 "에이전트 B가 개입하기 직전 상태"를 정확히 재현하는 데 쓴다.

업무 자동화 파이프라인 전체 설계 원칙이 궁금하다면 업무 자동화 시스템 설계 원칙 가이드에서 구조적 맥락을 함께 확인하면 좋다.


자주 묻는 질문

에이전트가 몇 개부터 동시성 제어를 본격적으로 고려해야 할까?

단일 공유 상태를 두 에이전트 이상이 읽고 쓰는 순간부터다. 숫자보다 공유 리소스의 존재가 기준이다. 에이전트 2개라도 같은 DB 레코드를 동시에 수정할 수 있다면 락이나 큐 설계가 필요하다.

asyncio.Lock과 Redis 기반 분산 락은 어떤 기준으로 선택할까?

단일 프로세스 내에서 에이전트가 실행된다면 asyncio.Lock으로 충분하다. 에이전트가 여러 서버나 컨테이너에 분산되어 있다면 Redis 기반 분산 락(Redlock)이나 큐 방식이 필요하다. 온프레미스 배포 환경에서는 이 선택이 특히 중요해진다.

Partial commit 이후 상태를 어떻게 복구할까?

이벤트 소싱 패턴을 적용했다면 마지막으로 일관된 시퀀스 번호 이후의 이벤트를 무효화하고 재처리한다. 적용하지 않았다면 트랜잭션 로그나 백업에서 해당 레코드만 선택적으로 복원한다. 이게 어렵다면 이벤트 소싱 도입을 먼저 검토하는 게 낫다.

동시성 버그를 로컬 환경에서 재현하기 어려운 이유는?

LLM API 레이턴시가 비결정적이기 때문이다. 프로덕션에서 타이밍이 맞아 터진 버그가 로컬에서는 API 응답 속도 차이만으로 재현되지 않을 수 있다. asyncio.sleep으로 인위적인 지연을 삽입하거나, 에이전트 호출을 모킹해 타이밍을 고정하는 방식이 효과적이다.

에러 핸들링에서 재시도 횟수를 어떻게 정할까?

일시적 오류라면 최대 3회, 지수 백오프 간격(1초 → 2초 → 4초)이 일반적인 시작점이다. 단, 멱등성(idempotency)이 보장되지 않는 액션은 재시도 자체가 새로운 문제를 만들 수 있다. 재시도 전에 해당 액션이 멱등한지 먼저 확인해야 한다.


팀 전원이 같은 AI 개발 환경을 쓴다는 건 단순한 도구 통일이 아니다. 동시성 버그처럼 타이밍에 민감한 문제에서 "내 환경에서는"이라는 말이 사라지고, 디버깅 맥락이 팀 전체에서 공유된다. 멀티 에이전트 파이프라인을 검토 중이거나 현재 파이프라인의 동시성 구조를 점검하고 싶다면 TreeSoop AX 컨설팅에서 기술 상담을 받을 수 있다. 오픈소스 에이전트 관련 자산은 TreeSoop GitHub에서 확인 가능하다.


더 보기: treesoop.com

Top comments (0)