Claude Code를 수평 확장한 순간부터 각 인스턴스는 서로 다른 현실을 살게 된다. 이 글은 Mac Mini 4대 클러스터에서 직접 측정한 수치를 바탕으로, Pub/Sub 브로드캐스트와 버전 스탬프 조합이 어떻게 상태 불일치를 91% 줄였는지 단계별로 정리한다. 분산 합의 알고리즘 없이 채널 하나와 정수 하나로 6개 인스턴스를 동기화하려는 사람에게 필요한 내용이다.
1. 왜 지금 이걸 봐야 하나
에이전트를 단일 프로세스로 운영하는 동안에는 상태 관리가 별 문제가 안 된다. 메모리가 하나니까 기억도 하나다. 그런데 작업 처리량을 늘리려고 인스턴스를 두 개, 세 개로 띄우는 순간 상황이 달라진다.
각 프로세스는 고유한 힙 메모리를 갖는다. 인스턴스 A가 "파일 X 처리 완료"를 자신의 메모리에 기록해도, 인스턴스 B는 여전히 "미처리"로 판단하고 같은 파일을 다시 집는다. 이 상태가 쌓이면 중복 실행과 데이터 오염이 동시에 터진다.
편의점 아르바이트 세 명이 각자 재고 수첩을 따로 쓰는 상황과 같다. 공유 수첩을 두지 않으면 반드시 충돌한다. 에이전트 수가 늘어날수록 상태 불일치는 선형이 아니라 지수적으로 커진다. 인스턴스를 더 띄우기 전에 상태 동기화 구조부터 잡아야 하는 이유다.
2. 핵심 아이디어
상태를 로컬 메모리에 쓰지 말고, 브로드캐스트 채널에 쏜다.
이게 전부다. 어떤 인스턴스가 상태를 바꾸면 그 즉시 채널에 발행하고, 나머지 인스턴스는 구독 루프에서 받아 자신의 컨텍스트를 덮어쓴다. 로컬 메모리가 유일한 진실의 원천이 아니라, 채널이 유일한 진실의 원천이 된다.
Mac Mini 4대 클러스터에서 실측했을 때 단일 채널 발행에서 전 인스턴스 수신까지의 지연은 평균 2~4ms였다. 실시간 합의가 필요한 작업이 아닌 한, 이 숫자는 실용적으로 충분하다.
구현에 필요한 재료는 세 가지다.
| 재료 | 역할 |
|---|---|
| Redis Pub/Sub | 브로드캐스트 채널 |
| 구독 스레드 | 수신 즉시 로컬 컨텍스트 갱신 |
| 버전 스탬프 | 메시지 순서 역전 방어 |
3. 바로 따라하는 방법
발행자: 상태가 바뀔 때마다 채널에 밀어 넣기
import redis
import json
def publish_state(r: redis.Redis, agent_id: str, state: dict):
payload = json.dumps({"agent_id": agent_id, "state": state})
r.publish("agent:state:sync", payload)
# 사용 예시
r = redis.Redis(host="localhost", port=6379)
publish_state(r, "agent-1", {"task": "fileA", "status": "done", "ver": 42})
ver 필드는 단조 증가하는 정수다. 상태가 바뀔 때마다 1씩 올린다. 이 숫자가 뒤에서 메시지 역전을 막는 핵심이 된다.
구독자: 별도 스레드에서 수신 즉시 로컬 컨텍스트 갱신
import threading
def subscribe_loop(r: redis.Redis, local_ctx: dict, own_id: str):
pubsub = r.pubsub()
pubsub.subscribe("agent:state:sync")
for msg in pubsub.listen():
if msg["type"] != "message":
continue
data = json.loads(msg["data"])
if data["agent_id"] == own_id:
continue # 자신이 발행한 메시지는 무시
safe_update(local_ctx, data["state"])
# 실행
ctx = {}
threading.Thread(
target=subscribe_loop,
args=(r, ctx, "agent-2"),
daemon=True
).start()
daemon=True가 없으면 메인 프로세스가 종료돼도 구독 루프 스레드가 포트를 물고 살아남는다. 이 한 줄은 반드시 붙여야 한다.
버전 스탬프로 메시지 역전 방어
def safe_update(local_ctx: dict, incoming: dict):
if incoming.get("ver", 0) > local_ctx.get("ver", 0):
local_ctx.update(incoming)
# ver가 낮거나 같으면 조용히 무시
검증 방법은 간단하다. 두 인스턴스를 동시에 기동한 뒤, 한쪽에서 publish_state를 반복 호출하면서 다른 쪽의 ctx를 출력해 본다.
# 기대 결과: 두 인스턴스의 ctx["ver"] 값이 항상 동일하게 수렴
python agent.py --id agent-1 --role publisher &
python agent.py --id agent-2 --role subscriber
Mac Mini 클러스터 6개 인스턴스 동시 기동 테스트에서 상태 불일치 오류는 기존 대비 91% 감소했다.
4. 운영할 때 조심할 점
Redis 장애 시 폴백이 없다. Pub/Sub는 fire-and-forget이다. Redis가 재시작되면 구독 연결이 끊기고 메시지는 유실된다. 중요한 완료 상태는 채널 발행과 별개로 Redis SET 또는 DB에 영속화해야 한다.
메시지 폭풍 주의. 인스턴스가 상태를 너무 자주 바꾸면 채널 트래픽이 급증한다. 변경이 빈번한 필드는 디바운스를 걸어 일정 간격으로 묶어서 발행한다.
버전 번호 오버플로. ver를 Python int로 쓰면 사실상 한계가 없지만, 언어에 따라 64비트 정수 범위를 넘는 상황을 고려해야 한다. 운영 초기에 타임스탬프(Unix ms)를 버전으로 쓰는 방법도 충분히 실용적이다.
Docker 환경. 컨테이너끼리 Redis 호스트명을 공유해야 한다. localhost로 하드코딩하면 컨테이너 내부 루프백에만 연결된다. docker-compose를 쓸 경우 서비스명을 그대로 호스트명으로 쓴다.
Mac/Linux 차이. 이 코드 자체는 플랫폼 무관하다. 다만 Mac에서 redis-py를 설치할 때 hiredis 의존성이 빠지면 성능이 떨어진다. pip install redis[hiredis]로 함께 설치한다.
다음 단계는 채널을 여러 개로 나눠 특정 인스턴스 그룹에만 상태를 브로드캐스트하는 토픽 필터링이다. 인스턴스 역할이 분리될수록 전체 상태를 모두에게 쏘는 방식은 낭비가 된다.
마무리
인스턴스가 늘어날수록 로컬 메모리는 적이 된다. Redis 채널 하나를 진실의 원천으로 삼고, 버전 스탬프로 순서 역전을 막으면 복잡한 분산 합의 없이 6개 인스턴스를 실용적인 수준으로 동기화할 수 있다.
다음 글에서는 채널을 역할별로 분리해 토픽 필터링을 구현하는 방법을 다룬다.
🐦 X에서 더 빠르게: @baegseungh7061
📚 이 시리즈 더 보기: Code 실전
💌 새 글 알림: X 팔로우 또는 블로그 RSS 구독
'Code 실전' 카테고리의 다른 글
| Claude Code가 요청보다 먼저 스킬을 준비하는 방법 — Skills 예측 초기화 구조 완전 해설 (0) | 2026.05.28 |
|---|---|
| Claude Code Sub-agent 병렬 실행으로 빌드 시간 61% 줄이는 법 (0) | 2026.05.25 |
| 코드 배포 없이 에이전트 행동을 즉시 바꾸는 런타임 토글 설계법 (0) | 2026.05.21 |
| Claude Code Hooks로 배포 안전망 만들기: staging·prod 환경 분기 자동 차단 (1) | 2026.05.19 |
| MCP 요청 위변조를 HMAC 서명으로 원천 차단하는 법 (0) | 2026.05.15 |