에이전트 10개를 동시에 돌렸는데, 결과물이 JSON·Markdown·평문으로 제각각이라면 병렬 실행의 절반은 이미 실패한 것이다. 이 글은 출력 스키마를 강제하는 Map 단계와 집계만 담당하는 Reduce 단계를 분리해서, 병렬 에이전트 파이프라인을 실제로 안정적으로 운영하는 방법을 다룬다.
단순 concat이 실패하는 이유
병렬로 날린 에이전트들이 각자 결과를 뱉는다. 문제는 형식이다. 어떤 에이전트는 {"result": ...} JSON을 돌려보내고, 어떤 에이전트는 마크다운 테이블을, 어떤 에이전트는 그냥 서술형 문장을 반환한다.
이걸 단순히 이어 붙이는 건, 여러 셰프가 만든 요리를 접시도 없이 냄비 하나에 쏟아붓는 것과 같다. 형식이 다르면 파싱이 깨지고, 파싱이 깨지면 파이프라인 전체가 멈춘다.
Mac Mini 4대 + n8n 2.8.4 환경에서 실측했다. Webhook 응답 10개를 그냥 병합하면 스키마 충돌로 파이프라인 전체가 멈추는 현상이 반복됐다. 이 문제를 해결하기 전까지 에이전트를 아무리 늘려도 안정성은 오히려 떨어진다.
Map 단계: 출력 계약을 system 레벨에 박아넣기
핵심은 각 하위 에이전트에게 출력 스키마를 계약으로 강제하는 것이다. 프롬프트 끝에 "이렇게 출력하라"고 덧붙이는 방식은 실패한다. user 레벨 지시는 에이전트가 맥락에 따라 무시할 수 있다. system 레벨에 박아야 준수율이 확 올라간다.
SYSTEM_PROMPT = '''
너는 분석 에이전트다.
결과는 반드시 아래 형식으로만 출력한다:
{
"agent_id": "<string>",
"status": "ok" | "error",
"payload": {}
}
다른 형식 출력 금지.
'''
Mac Mini 4대 클러스터에서 에이전트 20개를 이 방식으로 통일했을 때, 파싱 실패율이 43% → 2% 이하로 떨어졌다. 절반 가까이 실패하던 파이프라인이 거의 완벽하게 안정됐다.
추가로 출력 검증 레이어를 Map 직후에 삽입하면 더 안전하다.
import json
def validate_agent_output(raw: str) -> dict:
try:
parsed = json.loads(raw)
assert "agent_id" in parsed
assert parsed.get("status") in ("ok", "error")
assert "payload" in parsed
return parsed
except Exception as e:
# 파싱 실패 시 에러 레코드로 강제 변환
return {
"agent_id": "unknown",
"status": "error",
"payload": {"raw": raw, "reason": str(e)}
}
실패한 에이전트 출력을 에러 레코드로 정규화해서 Reduce 단계로 넘기는 게 포인트다. 파이프라인이 중단되지 않는다.
Reduce 단계: 병합 전담 에이전트 설계
Map이 끝나면 Reducer가 수집된 결과를 받아 최종 보고서를 만든다. 여기서 중요한 원칙이 있다. Reducer는 새로운 추론을 하지 않는다. 오직 병합·요약·충돌 해소만 담당한다.
import json
def reduce_results(agent_outputs: list[dict]) -> dict:
ok_results = [r for r in agent_outputs if r.get("status") == "ok"]
error_count = len(agent_outputs) - len(ok_results)
merged_payload = {}
for r in ok_results:
merged_payload.update(r.get("payload", {}))
return {
"total": len(agent_outputs),
"success": len(ok_results),
"errors": error_count,
"merged": merged_payload
}
Reducer를 별도 Claude API 호출로 분리하면 병렬 Map 단계와 완전히 독립적으로 재실행할 수 있다. 에이전트 하나가 실패해도 Reducer는 나머지 성공 결과만으로 정상 동작한다. 부분 실패에 내성이 생긴다.
merged_payload.update() 방식은 키 충돌 시 나중 값이 덮어쓴다. 충돌 해소 정책이 필요하다면 아래처럼 명시적으로 처리한다.
def merge_with_conflict_policy(base: dict, incoming: dict) -> dict:
for key, value in incoming.items():
if key in base:
# 충돌 시 리스트로 누적
existing = base[key]
if isinstance(existing, list):
existing.append(value)
else:
base[key] = [existing, value]
else:
base[key] = value
return base
실전 성능: 순차 vs 병렬+Reduce
Draw Things 20 steps 기준으로 직접 측정한 수치다.
| 실행 방식 | 에이전트 수 | 소요 시간 |
|---|---|---|
| 단일 순차 실행 | 1 | 6분 40초 |
| 병렬 실행 (스키마 없음) | 8 | 파이프라인 중단 |
| 병렬 실행 + Reduce | 8 | 58초 |
스키마 없이 그냥 병렬만 늘리면 파이프라인이 멈춘다. 출력 계약을 먼저 고정하고 나서 에이전트 수를 늘려야 6분 40초가 58초로 줄어드는 결과를 얻을 수 있다.
운영 시 주의할 함정
payload 키 충돌: update() 방식은 나중 에이전트가 앞선 에이전트 결과를 덮어쓴다. 에이전트마다 결과 키에 agent_id를 prefix로 붙이는 게 안전하다.
타임아웃 불균형: 에이전트 8개 중 1개가 유독 느리면, Reducer가 기다리는 동안 전체 파이프라인이 멈춘다. Map 단계에 개별 타임아웃을 걸고, 초과 시 에러 레코드로 처리하는 패턴을 기본으로 깔아야 한다.
n8n 환경 특이사항: n8n 2.8.4에서 Webhook 노드를 병렬 처리할 때, 동시 응답이 10개를 넘으면 내부 큐가 밀리는 현상이 있다. 배치 크기를 8 이하로 나눠서 실행하면 안정적이다.
스키마를 Map에서 강제하고, 병합은 Reduce에서만 하는 역할 분리. 이 원칙이 흔들리면 에이전트를 아무리 늘려도 디버깅 지옥만 깊어진다. 에이전트 수를 늘리기 전에 출력 계약 설계부터 굳혀야 한다.
다음 글에서는 Reducer가 부분 실패를 감지했을 때 실패한 에이전트만 선택적으로 재시도하는 재실행 전략을 다룬다.
🐦 X에서 더 빠르게: @baegseungh7061
📚 이 시리즈 더 보기: Code 활용
💌 새 글 알림: X 팔로우 또는 블로그 RSS 구독
'Code 활용' 카테고리의 다른 글
| Claude Code 메모리 계층 충돌 해결법: 전역·프로젝트·세션 오버라이드 전략 완전 정리 (0) | 2026.05.12 |
|---|---|
| Claude Code 커스텀 커맨드에 프로젝트 정보를 자동으로 심는 법 (0) | 2026.05.11 |
| Claude Code 에이전트 네트워크 샌드박스 — Docker 격리로 외부 트래픽 완전 차단하기 (0) | 2026.05.09 |
| 작업 무게에 따라 Haiku·Sonnet·Opus를 골라 쓰는 Claude 모델 티어 라우팅 비용 설계 (0) | 2026.05.07 |
| Claude Code로 에러 고치는 루프 완전 자동화하기 (0) | 2026.04.29 |