Akka.NET으로 AgentAI 오케스트레이션 만들기 — 클러스터 확장편 (Part 2)

🎯
Part 1에서는 LlmAgentActor 한 그루(Idle → GatheringContext → Reasoning → Acting 4-Behavior FSM)와 SessionMemoryActor (PersistentActor), AgentRouter 까지 단일 노드 안에서 Akka.io Agent SDK와 동등한 추상화를 만들었다.
이번 편의 질문은 단 하나다 — 이 구조를 한 머신에서 N대 클러스터로 확장할 때 코드는 얼마나 바뀌어야 하는가.
대상 독자는 분산 시스템과 액터모델의 기초 개념을 알고 있는 .NET 코어 영역 개발자다.

1. 왜 클러스터인가 — 단일 노드의 진짜 한계

🎯
3줄 요약
  • 단일 노드 한계는 CPU/GPU 부족 이 아니라 세션 수 증가에 따른 메모리·SLA 한계재기동 시 콜드 스타트다.
  • 액터모델의 위치 투명성(Location Transparency)은 마케팅 문구가 아니라 이 두 문제를 동시에 푸는 설계다.
  • 클러스터로 가면 Agent 액터가 어느 노드에 있는지 가 더 이상 의미 없어진다.
Part 1의 AgentRouter는 SessionId마다 자식 LlmAgentActor를 하나씩 띄우고 _bySession Dictionary로 라우팅했다. 단일 머신에서 이 패턴이 깨지는 지점은 보통 셋이다.
  1. 메모리 한계. 세션 1개당 LlmAgentActor 1 + SessionMemoryActor 1 = 액터 2개. 활성 사용자 10만 명이면 액터 20만 개. 각 액터의 평균 점유가 4 KB면 800 MB. 메일박스/세션 히스토리까지 합치면 단일 .NET 프로세스의 GC 압력이 빠르게 비정상 영역으로 진입한다.
  1. 재기동 콜드 스타트. 프로세스 한 번 재시작하면 모든 세션 히스토리를 PersistentActor에서 replay 해야 한다. 사용자 N명이 동시에 다시 접속하면 동시 replay 폭주가 일어난다.
  1. SLA — 단일 장애점. Agent 노드가 죽으면 그 위에 살던 모든 대화가 끊긴다. 운영 환경에서 이건 협상 불가능한 결격 사유다.
위 셋 모두 분산 시스템의 표준 답안 이 이미 있다. 샤딩으로 액터를 N대에 분산하고, 영속화로 콜드 스타트를 줄이고, 감독 계층 + Split-brain Resolver로 장애를 격리한다. 액터모델의 위치 투명성은 이 답을 코드 변경 거의 없이 적용할 수 있게 해준다.
graph LR subgraph "Part 1 — 단일 노드" Router1[AgentRouter] --> A1[LlmAgentActor: alice] Router1 --> A2[LlmAgentActor: bob] Router1 --> A3[LlmAgentActor: carol] A1 --> M1[SessionMemoryActor] A2 --> M2[SessionMemoryActor] A3 --> M3[SessionMemoryActor] end subgraph "Part 2 — N대 클러스터" Client[Client] --> SR[ShardRegion<br/>각 노드에 1개] SR --> N1[Node A<br/>shard 0,3,6] SR --> N2[Node B<br/>shard 1,4,7] SR --> N3[Node C<br/>shard 2,5,8] N1 -. Persistence .-> DB[(EventStore<br/>PostgreSQL/Mongo)] N2 -. Persistence .-> DB N3 -. Persistence .-> DB end A1 -.->|"코드 변경 거의 0"| N1
좌측 단일 노드 구조에서 우측 클러스터 구조로 갈 때 비즈니스 로직 코드는 거의 그대로다. 바뀌는 건 액터를 생성하는 방식과 영속화 백엔드뿐이다.

2. Akka.NET 클러스터 — 5분 기초

🎯
3줄 요약
  • 클러스터는 노드들의 합의된 멤버십 집합 이다. Gossip 프로토콜로 멤버 상태를 전파한다.
  • 같은 ActorSystem name + seed-nodes 합의가 되면 ClusterEvent.MemberUp 부터 시작.
  • 분산 액터 배포 도구 3종: Cluster Sharding, Cluster Singleton, Distributed PubSub.

HOCON 최소 설정

akka { actor.provider = cluster remote.dot-netty.tcp { hostname = "0.0.0.0" port = 0 # 0 = 동적 포트 (운영에서는 고정) } cluster { seed-nodes = [ "akka.tcp://agent-cluster@node-a:2552", "akka.tcp://agent-cluster@node-b:2552" ] roles = ["agent"] sharding { role = "agent" remember-entities = on # 노드 다운 시 엔티티 재활성화 } downing-provider-class = "Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster" split-brain-resolver { active-strategy = keep-majority } } persistence { journal.plugin = "akka.persistence.journal.postgresql" snapshot-store.plugin = "akka.persistence.snapshot-store.postgresql" } }
요점만 짚자.
  • actor.provider = cluster — 이 한 줄이 ActorSystem을 클러스터 모드로 전환.
  • seed-nodes — 클러스터 합류 시 처음 접촉하는 노드들. 보통 2~3개.
  • roles — 노드에 역할을 부여(예: agent / gateway / persistence-writer). 샤드는 같은 role 노드들 사이에서만 분배.
  • split-brain-resolver — 네트워크 분할 시 어느 파티션을 유지할지 결정. 이 설정 없으면 split-brain 발생 시 두 파티션이 양쪽에서 별개 클러스터로 진화한다. 운영 필수.

3대 분산 도구

graph TB subgraph "Cluster Sharding" direction LR SC[ShardCoordinator<br/>Singleton] --> SR1[ShardRegion @ Node A] SC --> SR2[ShardRegion @ Node B] SC --> SR3[ShardRegion @ Node C] SR1 --> E11[Entity 1] SR1 --> E12[Entity 4] SR2 --> E21[Entity 2] SR2 --> E22[Entity 5] SR3 --> E31[Entity 3] SR3 --> E32[Entity 6] end subgraph "Cluster Singleton" SM[SingletonManager<br/>각 노드] --> SP[Singleton Proxy] --> S[Singleton Actor<br/>oldest 노드에 1개만] end subgraph "Distributed PubSub" P1[Publisher @ Node A] --> Med1[Mediator @ Node A] Med1 -. gossip .- Med2[Mediator @ Node B] Med2 --> Sub1[Subscriber @ Node B] Med2 --> Sub2[Subscriber @ Node B] end
  • Cluster Sharding = "객체 1개당 액터 1개"가 N대에 자동 분산. ShardCoordinator(Singleton)이 어느 shard가 어느 노드에 살지 결정한다. 핸드오프(rebalance)는 자동.
  • Cluster Singleton = "클러스터 전체에서 단 하나만 존재해야 하는 액터" — 보통 최고령(oldest) 노드에 산다. Rate limiter, scheduler, central coordinator 같은 역할.
  • Distributed PubSub = 액터 위치를 모르고도 토픽 단위로 메시지 발행/구독. Multi-agent 협업의 backbone이 된다.
Part 2의 핵심 변환은 — Part 1의 AgentRouter → Cluster Sharding의 ShardRegion으로 교체 다.

3. 클러스터 확장의 5가지 진짜 문제

코어 아키텍처 관점에서 단일 노드 → 클러스터 전환은 5가지 진짜 문제와 마주친다.
문제
단일 노드에서는
클러스터에서는
Akka.NET 해법
1. Agent 위치
Dictionary lookup
어느 노드인가?
Cluster Sharding
2. Session affinity
같은 메모리 객체
같은 SessionId → 같은 액터 보장
ShardId = hash(SessionId) % N
3. Persistence 백엔드
로컬 SQLite로 충분
모든 노드 공통 접근
PostgreSQL/Mongo Persistence Plugin
4. 장애 격리
SupervisorStrategy
• 네트워크 분할 처리
Split-Brain Resolver + remember-entities
5. 관측
로그 1개 파일
분산 트레이스
OpenTelemetry + Phobos 또는 Petabridge.Cmd
이 다섯 문제를 하나씩 풀어보자.

4. Agent 액터를 Cluster Sharding으로 분산

🎯
3줄 요약
  • AgentRouter(Part 1) → ClusterSharding.Start(...) 로 교체하면 N대 노드에 SessionId 단위로 액터가 분산된다.
  • Entity ID는 SessionId, Shard ID는 SessionId의 해시 % shardCount.
  • 노드 추가/제거 시 ShardCoordinator가 자동 핸드오프(rebalance)한다. 비즈니스 코드는 그대로.

4.1 Sharding 설정

public sealed class AgentMessageExtractor : HashCodeMessageExtractor { public AgentMessageExtractor(int maxShards = 100) : base(maxShards) { } public override string EntityId(object message) => message switch { AgentRequest req => req.SessionId, ShardEnvelope env => env.EntityId, _ => null! }; public override object EntityMessage(object message) => message switch { ShardEnvelope env => env.Message, _ => message }; } // 클러스터 부팅 시 1회 var sharding = ClusterSharding.Get(system); var agentShardRegion = sharding.Start( typeName: "agent", entityProps: HelloAgentActor.Props(llm), // Part 1의 액터 그대로 settings: ClusterShardingSettings.Create(system).WithRole("agent"), messageExtractor: new AgentMessageExtractor(maxShards: 100) ); // 호출 측은 ShardRegion에 메시지를 보낸다 — 라우팅은 자동 var reply = await agentShardRegion.Ask<AgentResponse>( new AgentRequest("alice", "Hi, I'm Alice"), TimeSpan.FromSeconds(30));

4.2 ShardRegion이 자동으로 푸는 4가지

sequenceDiagram autonumber participant Client participant SR_A as ShardRegion @ Node A participant SC as ShardCoordinator<br/>(Singleton) participant SR_C as ShardRegion @ Node C participant E as Entity "alice"<br/>(Node C에 살아있음) Client->>SR_A: AgentRequest "alice" Note over SR_A: EntityId=alice<br/>ShardId=hash(alice) % 100 = 23 SR_A->>SR_A: shard 23이 어디 사는지 모름 SR_A->>SC: GetShardHome(23) SC-->>SR_A: shard 23 → Node C SR_A->>SR_C: Forward(AgentRequest "alice") Note over SR_C: shard 23 안에 entity "alice" 있는가? SR_C->>E: AgentRequest E-->>SR_C: AgentResponse SR_C-->>SR_A: AgentResponse SR_A-->>Client: AgentResponse Note over SR_A,SR_C: 다음 호출부터 Node A의 ShardRegion은<br/>shard 23 → Node C를 캐시
요점:
  • 클라이언트는 Node A/B/C 중 아무 ShardRegion 에 메시지를 보내도 된다. 라우팅은 ShardRegion이 책임진다.
  • Entity 액터 alice클러스터 전체에서 단 하나만 존재한다 — Cluster Sharding이 보장.
  • 첫 호출은 ShardCoordinator를 한 번 거치지만, 이후엔 각 ShardRegion이 shard → 노드 매핑을 캐시한다.

4.3 노드 추가/제거 시 자동 리밸런싱

graph TB subgraph "T=0 3노드 균등 분산" direction LR N1A[Node A<br/>shard 0,3,6,9...] N2A[Node B<br/>shard 1,4,7,10...] N3A[Node C<br/>shard 2,5,8,11...] end subgraph "T=1 Node D 합류" direction LR N1B[Node A<br/>shard 0,4,8...] N2B[Node B<br/>shard 1,5,9...] N3B[Node C<br/>shard 2,6,10...] N4B[Node D<br/>shard 3,7,11...] end subgraph "T=2 Node B 다운" direction LR N1C[Node A<br/>shard 0,1,4,5,8,9...] N3C[Node C<br/>shard 2,3,6,7,10,11...] N4C[Node D<br/>shard 3,7,11...] end N1A -. rebalance .-> N1B N1B -. node down +<br/>remember-entities .-> N1C
remember-entities = on 설정이 있으면 Node B에 살던 active entity들은 다른 노드에서 자동으로 재활성화된다. SessionMemoryActor의 PersistentActor가 이벤트 로그에서 상태를 복원한다.

5. Coordinator는 Cluster Singleton — 워크플로우 & Rate Limiter

🎯
3줄 요약
  • Multi-Agent Planner의 "오케스트레이터" 같은 클러스터 전체에 1개 만 있어야 하는 액터는 Cluster Singleton.
  • LLM Rate Limiter, Tool Registry, Workflow Coordinator가 전형적 패턴.
  • 호출 측은 SingletonProxy를 통해 위치 추적 없이 사용 가능.
Part 1의 §9-1에 등장한 Multi-Agent Planner WorkflowWeatherAgentActivityAgent를 순차 호출한다. 클러스터로 가면 이 Workflow를 어디서 실행할지 결정해야 한다.
옵션 둘:
  1. Workflow도 Sharded Entity — Workflow Instance 1개당 Entity 1개. SagaPattern과 잘 어울림.
  1. Coordinator는 Cluster Singleton체크포인트는 PersistenceJournal 에 두고, 논리적 결정 만 단일 액터가.
LLM Rate Limiter는 (2)가 거의 항상 정답이다. 전체 클러스터의 호출 카운트 를 한 곳에서 봐야 하므로.
// Singleton 등록 (모든 노드에서 호출 — Akka가 oldest 노드에서만 활성화) var singletonManager = system.ActorOf( ClusterSingletonManager.Props( singletonProps: Props.Create<LlmRateLimiterActor>(), terminationMessage: PoisonPill.Instance, settings: ClusterSingletonManagerSettings.Create(system) .WithRole("rate-limiter")), name: "rate-limiter"); // 호출 측 — 위치 추적 없이 SingletonProxy 사용 var proxy = system.ActorOf( ClusterSingletonProxy.Props( singletonManagerPath: "/user/rate-limiter", settings: ClusterSingletonProxySettings.Create(system) .WithRole("rate-limiter")), name: "rate-limiter-proxy"); // LlmAgentActor 내부에서 var token = await proxy.Ask<RateLimitGrant>( new AcquireToken(estimatedTokens: 1500), TimeSpan.FromSeconds(5));
sequenceDiagram autonumber participant A1 as LlmAgentActor @ Node A participant P1 as SingletonProxy @ Node A participant SM as SingletonManager @ Node B (oldest) participant S as LlmRateLimiterActor<br/>(클러스터 1개) participant A2 as LlmAgentActor @ Node C participant P2 as SingletonProxy @ Node C A1->>P1: AcquireToken(1500) P1->>SM: forward (위치 인식) SM->>S: AcquireToken(1500) S-->>SM: Grant SM-->>P1: Grant P1-->>A1: Grant A2->>P2: AcquireToken(2000) P2->>SM: forward SM->>S: AcquireToken(2000) Note over S: 이번 분 잔여 토큰 4000<br/>→ Grant S-->>P2: Grant via SM P2-->>A2: Grant Note over A1,A2: Node A, C 양쪽 Agent가<br/>같은 Rate Limiter 인스턴스를 공유
Singleton의 본질은 분산 환경에서의 단일 책임 + 결정성 보장 이다. 잘못 쓰면 병목이 되니, 결정만 하고 무거운 작업은 위임 하는 원칙을 지킨다.

6. Persistence — Event Sourcing + Snapshot 전략

🎯
3줄 요약
  • SessionMemoryActor가 클러스터에서 의미가 있으려면 모든 노드가 접근 가능한 백엔드(PostgreSQL/Mongo/Cassandra) 필요.
  • 재기동 폭주(replay storm)를 피하려면 Snapshot 주기와 lazy recovery 필수.
  • LlmAgentActor 자체는 stateless하게 두고, PersistentActor는 SessionMemoryActor에만 있도록 책임 분리.

6.1 Event 흐름

sequenceDiagram autonumber participant Client participant Agent as LlmAgentActor<br/>(Sharded Entity) participant Mem as SessionMemoryActor<br/>(PersistentActor) participant Journal as EventJournal<br/>(PostgreSQL) participant LLM Client->>Agent: AgentRequest("alice", "Hi") Agent->>Mem: LoadSessionMessages Mem->>Journal: SELECT events WHERE persistence_id='session-alice' Journal-->>Mem: [MessageAppended..., MessageAppended...] Note over Mem: Recover<MessageAppended>(Apply)<br/>history 복원 Mem-->>Agent: SessionMessages(history) Agent->>LLM: complete(prompt + history) LLM-->>Agent: reply Agent->>Mem: AppendMessages(user+assistant) Mem->>Journal: Persist(MessageAppended) Journal-->>Mem: ack Note over Mem: 50번째마다 SaveSnapshot Agent-->>Client: AgentResponse

6.2 Snapshot 주기 — replay storm 방지

SessionMemoryActor는 50개 메시지마다 Snapshot을 저장하도록 Part 1에 이미 짜여 있었다. 클러스터에서는 이 주기가 replay 시간 을 결정한다.
public sealed class SessionMemoryActor : ReceivePersistentActor { public override string PersistenceId => $"session-memory-{_sessionId}"; private readonly LinkedList<ChatMessage> _history = new(); private const int MaxHistory = 20; private const int SnapshotInterval = 50; public SessionMemoryActor(string sessionId) { _sessionId = sessionId; // 1. 가장 최근 Snapshot 부터 복원 Recover<SnapshotOffer>(offer => { if (offer.Snapshot is List<ChatMessage> snap) foreach (var m in snap) _history.AddLast(m); }); // 2. Snapshot 이후의 이벤트만 replay Recover<MessageAppended>(evt => Apply(evt)); Command<LoadSessionMessages>(_ => Sender.Tell(new SessionMessages(_history.ToList()))); Command<AppendMessages>(cmd => Persist(new MessageAppended(cmd.Message), evt => { Apply(evt); // 운영 팁: SaveSnapshotAsync는 ack 받아 sequence 추적 if (SnapshotSequenceNr % SnapshotInterval == 0) SaveSnapshot(_history.ToList()); })); // 3. Snapshot 저장 결과 처리 — 이전 스냅샷 삭제로 저장소 부담 절감 Command<SaveSnapshotSuccess>(success => { DeleteSnapshots(new SnapshotSelectionCriteria( maxSequenceNr: success.Metadata.SequenceNr - 1)); DeleteMessages(success.Metadata.SequenceNr - SnapshotInterval); }); } private void Apply(MessageAppended evt) { _history.AddLast(evt.Message); while (_history.Count > MaxHistory) _history.RemoveFirst(); } }
핵심:
  • Snapshot 주기 = max_replay_time / event_apply_time. 메시지 1개 apply가 0.1ms고 SLA가 100ms replay라면 1000개 events마다 snapshot이 적정선.
  • 이벤트/스냅샷 GC. DeleteSnapshots + DeleteMessages 로 오래된 데이터 정리. 이 두 줄 빠뜨리면 PostgreSQL 테이블이 무한히 자란다.
  • DB connection pool 은 노드 단위가 아니라 클러스터 단위 로 계산해야 한다. N대 × 노드당 pool size = 동시 connection.

6.3 책임 분리 — Sharded vs Persistent

graph TB subgraph "Node A" SR_A[ShardRegion 'agent'] E_A1[LlmAgentActor<br/>SessionId=alice<br/>STATELESS] E_A2[LlmAgentActor<br/>SessionId=bob<br/>STATELESS] M_A1[SessionMemoryActor<br/>persistence_id=alice<br/>PERSISTENT] M_A2[SessionMemoryActor<br/>persistence_id=bob<br/>PERSISTENT] SR_A --> E_A1 SR_A --> E_A2 E_A1 -. child .-> M_A1 E_A2 -. child .-> M_A2 end M_A1 --> J[(EventJournal<br/>PostgreSQL)] M_A2 --> J style E_A1 fill:#e3f2fd style E_A2 fill:#e3f2fd style M_A1 fill:#fce4ec style M_A2 fill:#fce4ec
원칙: LlmAgentActor는 stateless하게, SessionMemoryActor만 PersistentActor. 이유는 단순하다 — LLM 호출은 결정성이 없으니 LlmAgentActor의 행동 결과를 replay 하는 게 무의미하다. 영속화할 가치가 있는 건 대화 히스토리(이벤트)뿐이다.

7. 멀티 에이전트 협업 토폴로지 3종

🎯
3줄 요약
  • 협업 패턴 3종: Conversation(직접 메시지), Topic(PubSub), Workflow(중앙 Saga).
  • 어느 패턴을 쓸지는 상호작용의 동기성상태 공유 범위 가 결정한다.
  • Akka.NET은 셋 다 일급 지원 — DistributedPubSub, Cluster Sharding, Cluster Singleton.

7.1 Conversation 패턴 — Agent ↔ Agent 직접 호출

sequenceDiagram autonumber participant W as WeatherAgent<br/>(Sharded) participant A as ActivityAgent<br/>(Sharded) participant SR as ShardRegion Note over W: LlmAgentActor가 다른 Agent를 도구처럼 W->>SR: AgentRequest(SessionId="alice-activity", "Madrid") SR->>A: forward (EntityId=alice-activity) A-->>W: AgentResponse("Indoor museums...") Note over W: tool_result로 wrapping해서<br/>다음 LLM 호출에 주입
가장 단순한 패턴. Tool 호출을 다른 Agent로 위임하는 형태. 단점은 호출 사슬이 깊어지면 디버깅이 어렵다.

7.2 Topic 패턴 — DistributedPubSub 기반 브로드캐스트

graph LR subgraph "Topic Mesh" P1[NewsCrawlerAgent<br/>publishes] P2[StockTickerAgent<br/>publishes] Med1[Mediator @ A] Med2[Mediator @ B] Med3[Mediator @ C] S1[SentimentAgent<br/>subscribes 'market.*'] S2[PortfolioAgent<br/>subscribes 'market.stock'] S3[AlertAgent<br/>subscribes 'market.alert'] P1 --> Med1 P2 --> Med1 Med1 -. gossip .- Med2 Med1 -. gossip .- Med3 Med2 --> S1 Med3 --> S2 Med3 --> S3 end
// Publisher var mediator = DistributedPubSub.Get(system).Mediator; mediator.Tell(new Publish("market.stock", new StockPriceUpdate("MSFT", 510.32))); // Subscriber (Agent 시작 시) mediator.Tell(new Subscribe("market.stock", Self)); Receive<StockPriceUpdate>(update => /* trigger LLM analysis */);
이벤트 스트림 기반 다중 Agent 시나리오에서 유리하다. 단점은 전달 보장이 at-most-once — 운영에서는 Kafka/Pulsar 같은 외부 브로커와 조합하는 게 일반적.

7.3 Workflow 패턴 — Saga / 중앙 오케스트레이션

sequenceDiagram autonumber participant Client participant W as TripPlannerWorkflow<br/>(Sharded Entity) participant Mem as Workflow EventStore participant WA as WeatherAgent<br/>(Sharded) participant AA as ActivityAgent<br/>(Sharded) participant PE as PreferencesEntity<br/>(Sharded) Client->>W: StartWorkflow("plan-trip", "alice", "Madrid") Note over W: state=GetWeather, PersistEvent W->>Mem: Persist(StateTransitioned) W->>WA: forward WA-->>W: AgentResponse("rainy 18C") Note over W: state=GetPreferences, PersistEvent W->>Mem: Persist W->>PE: GetPreferences("alice") PE-->>W: ["museum","cafe"] Note over W: state=SuggestActivity, PersistEvent W->>Mem: Persist W->>AA: forward AA-->>W: AgentResponse("Prado Museum") Note over W: state=Done, PersistEvent W->>Mem: Persist W-->>Client: WorkflowResult
Akka.ioWorkflow 컴포넌트와 거의 1:1이다. Workflow 자체를 Sharded Entity로 만들고, 각 step transition을 PersistentActor 이벤트로 기록하면 중간 단계에서 노드가 죽어도 정확히 그 단계부터 재개 한다.
패턴
적합한 시나리오
결정성
디버깅 난이도
Conversation
짧은 동기 도구 호출
호출 사슬 길어지면 떨어짐
낮음 (분명한 호출 그래프)
Topic
비동기 이벤트 fan-out
at-most-once
중간 (브로드캐스트 추적)
Workflow
다단계 + 부작용
이벤트 로그로 재현 가능
높음 (이벤트 로그 필수)

8. Split-Brain — 운영에서 가장 위험한 함정

🎯
3줄 요약
  • 네트워크 분할이 일어나면 클러스터가 두 파티션으로 갈라져 각각 별도 클러스터처럼 동작 한다.
  • 양쪽이 같은 Singleton을 만들거나, 같은 Sharded Entity 인스턴스를 두 노드에 띄우면 데이터 일관성이 깨진다.
  • Split-Brain Resolver(SBR)의 전략 4종 중 keep-majority가 가장 보편적.
graph TB subgraph "정상 상태 5노드 클러스터" N1[Node A] --- N2[Node B] N2 --- N3[Node C] N3 --- N4[Node D] N4 --- N5[Node E] N1 --- N5 end subgraph "네트워크 분할 발생" direction LR P1[Partition X<br/>Node A, B] -. 통신 단절 .- P2[Partition Y<br/>Node C, D, E] P1 -. SBR keep-majority .-> X1[ ❌ SHUTDOWN<br/>2 노드는 소수] P2 -.-> X2[ ✅ SURVIVE<br/>3 노드 다수] end style X1 fill:#ffcdd2 style X2 fill:#c8e6c9

Akka.NET SBR 전략 4종

전략
동작
권장 사용처
keep-majority
노드 수 다수 파티션 유지
가장 일반적. 홀수 노드 클러스터
static-quorum
사전 설정 quorum-size 이상 유지
클러스터 크기 가변일 때
keep-oldest
최고령 노드 포함 파티션 유지
Singleton 보존 우선일 때
keep-referee
특정 referee 노드 포함 파티션 유지
의도적 마스터/슬레이브 설계
akka.cluster.split-brain-resolver { active-strategy = keep-majority stable-after = 20s # 네트워크 fluctuation 무시 대기 down-all-when-unstable = on # 불안정 지속 시 전체 다운 }

운영 체크리스트

  • 항상 홀수 노드. 짝수 노드 + keep-majority5:5 동수 분할 시 양쪽 모두 다운된다.
  • stable-after는 5s 이상. 짧게 두면 일시적 GC pause/네트워크 흔들림에 과민반응.
  • 모니터링. Cluster.MemberStatus 변화 이벤트를 외부 모니터링에 노출. SBR이 다운시킨 노드는 자동 재참여 안 됨 — 자동화된 재기동 필요.

9. 운영 — 롤링 업그레이드와 노드 교체

🎯
3줄 요약
  • 롤링 업그레이드는 한 번에 최대 (N-1)/2 노드 만 down 시킬 것 — keep-majority 유지.
  • remember-entities = on 이면 다운된 노드의 active Entity는 다른 노드에서 자동 재활성화.
  • LLM 호출 진행 중이던 Agent도 PersistentActor의 이벤트 로그에서 안전하게 재개.

9.1 롤링 업그레이드 시퀀스

sequenceDiagram autonumber participant Ops as 운영자 participant LB as Load Balancer participant NA as Node A (v1.0) participant NB as Node B (v1.0) participant NC as Node C (v1.0) participant Coord as ShardCoordinator Note over NA,NC: T=0 3노드 v1.0 클러스터, shards [0..99] 분산 Ops->>LB: drain Node A (트래픽 차단) Ops->>NA: Cluster.Leave NA->>Coord: leaving 알림 Coord->>NB: rebalance shards 0,3,6... Coord->>NC: rebalance shards 0,3,6... Note over NB,NC: remember-entities=on<br/>active entities 재활성화 NA->>NA: graceful shutdown Ops->>Ops: deploy v1.1 binary Ops->>NA: start v1.1 NA->>Coord: join cluster Coord->>NA: assign shards 0,3,6... Note over NA: state recovery from EventJournal Ops->>LB: re-add Node A Note over Ops,Coord: Node B, C 순으로 반복<br/>전체 클러스터 v1.1 완성

9.2 안티패턴

안티패턴
결과
올바른 방법
Cluster.Leave 없이 SIGKILL
shard timeout 후 rebalance, 5~30초 응답 지연
반드시 graceful leave
2노드 동시 다운 (3노드 클러스터)
1노드 잔존 = majority 아님 → SBR이 전체 다운 시킬 수 있음
한 번에 1노드만
Persistence 마이그레이션과 코드 배포 동시 진행
rollback 불가능
DB 마이그레이션 → 코드 배포 → 검증 분리
LlmAgentActor 자체에 in-memory 상태 추가
노드 다운 시 손실, 재시작 시 암묵적 replay 불가
모든 상태는 SessionMemoryActor 또는 외부 store에

9.3 관측성 — 분산 트레이스 필수

단일 노드에서는 로그 파일 하나에 모든 흐름이 다 들어왔다. 클러스터에서는 하나의 사용자 요청여러 노드를 거치며 처리된다.
// Petabridge.Cmd 또는 OpenTelemetry 미들웨어 Receive<AgentRequest>(req => { using var activity = AgentTelemetry.Source.StartActivity("agent.request"); activity?.SetTag("session_id", req.SessionId); activity?.SetTag("node", Cluster.Get(Context.System).SelfAddress.Host); activity?.SetTag("shard_id", _shardId); // ... 기존 처리 });
최소 트레이스 컨텍스트:
  • session_id — 사용자 추적 (PII 주의)
  • shard_id / node — 라우팅 추적
  • llm_call_id — LLM round-trip 식별
  • tool_name — 도구 호출 식별
OpenTelemetry collector + Jaeger/Tempo로 보내면, Conversation 패턴의 호출 사슬 이나 Workflow 패턴의 단계별 지속 시간 이 시각화된다.

10. 마치며 — 단일 → 분산 이식 비용은 거의 0

Part 1에서 LlmAgentActor 추상화를 만들 때, 클러스터를 염두에 둔 결정 들이 몇 가지 있었다.
  • LlmAgentActor 를 stateless하게 두고, 상태는 자식 SessionMemoryActor 에만 모은 점.
  • 메시지를 모두 record(immutable)로 정의한 점.
  • AgentRouter 가 SessionId 기반 라우팅을 책임진 점.
이 세 결정이 Part 2의 클러스터 확장을 코드 한 줄도 거의 안 바꾸고 가능하게 했다. 바뀐 건 셋뿐이다.
  1. AgentRouterClusterSharding.Start(...) 로 교체.
  1. Persistence Plugin 을 SQLite → PostgreSQL/Mongo 로 교체.
  1. HOCON에 cluster, sharding, split-brain-resolver 블록 추가.
비즈니스 로직 — Idle/GatheringContext/Reasoning/Acting 의 4-Behavior FSM, IAgentTool 추상화, SessionMemoryActor 의 이벤트 소싱 — 은 그대로다.
이게 액터모델 위치 투명성의 진짜 의미다. 마케팅 문구가 아니라, 단일 노드 → 클러스터 전환의 이식 비용을 거의 0으로 줄이는 설계 원칙. 이전 글 "에이전틱에 액터모델, 놀랍지 않다" 에서 다룬 Google AX, Akka, Orleans, Ray, Temporal 의 4축 (격리/메시징/영속/위치 투명성) 중 마지막 축이 가장 늦게 그 가치를 드러내는데, 그 시점이 바로 클러스터로 확장할 때다.
다음 Part 3 후보는 — Akka.NET 클러스터 위에서 MCP 서버 통합과 멀티 테넌트 격리 다. Singleton의 한계(테넌트별 Rate Limiter는 Singleton이면 안 됨)와 Cluster Sharding의 multi-key entity 패턴을 다룰 예정.

참고 자료

자매 글 / 직계 선행 글

Akka.NET 클러스터 공식 문서

메모라이저

외부 참고

  • Tyler Jewell, Agentic AI: Why Experience Matters More Than Hype, Akka, 2025