AgentWin 실사례로 풀어보는 Akka.NET 메시지 패턴 — 사회자 LLM과 3개의 Claude 터미널이 한 자리에 앉기까지. 작성일: 2026-04-13. 대상: Akka를 처음 보는 .NET / 서버 개발자.
요즘 데스크톱에 띄워두는 CLI는 더 이상 "사람이 명령을 치는 검은 창"이 아니다. claude, codex, gemini, copilot 같은 AI 에이전트 CLI들이 그 안에 들어 앉고, 사용자는 이 CLI들과 자연어로 대화한다.
문제는 한 단계 더 가고 싶을 때다. "여러 개의 AI 터미널을 동시에 띄우고, 그들 사이를 사회자 LLM이 중계하면 어떨까?"
AgentWin(개발 중인 데스크톱 도구)에서 이 시나리오를 구현하다 보니, "AI ↔ AI 사이의 통신 복잡도"가 일반 GUI 콜백 패턴으로는 감당이 안 된다는 결론에 이르렀고, 결국 Akka.NET(액터 모델)을 도입하게 됐다. 이 글은 그 도입 여정을 설계 → 구현 → 개선 → 테스트 주도 평가 순서로 풀어 본다.
한 줄 요약: Akka는 "메일박스가 달린 작은 객체(액터)들"이 메시지를 주고받는 동시성 프레임워크다.
Akka는 원래 Scala/Java 진영의 액터 모델 구현이고, Akka.NET은 그것의 .NET 포팅이다. .NET 진영에서는 Petabridge가 상업 지원과 부트캠프, OSS 거버넌스를 사실상 이끌고 있다.
액터 모델의 핵심 약속은 단순하다.
| 약속 | 의미 |
|---|---|
| 메일박스 + FIFO | 액터는 메시지를 큐에 받고 한 번에 하나씩 처리한다. → 액터 내부 상태는 자동으로 thread-safe. |
| 공유 메모리 금지 | 액터끼리 직접 변수를 만지지 않고 메시지로만 대화한다. → 락이 사라진다. |
| 위치 투명성 | 같은 프로세스 안의 액터든 다른 머신의 액터든 호출 코드는 똑같다. → 분산으로 자연스럽게 확장된다. |
| 감독자 트리 | 부모 액터가 자식의 장애를 책임진다 (Restart / Resume / Stop / Escalate). → "let it crash" 철학. |
처음 보면 "그냥 메시지 큐 아닌가?" 싶은데, 한 번 익히면 상태 + 동시성 + 장애 격리 + 라우팅을 한 가지 모델로 통일할 수 있다는 점에서 강력하다.
이 글 뒤에서 등장할 Akka 키워드 사전. 모르는 채로 읽어도 사례에서 자연스럽게 익혀진다.
AgentWin의 초기 구조는 흔한 WPF 코드비하인드였다. MainWindow(여러 터미널을 띄우는 워크스페이스)와 AgentBotWindow(사용자가 대화하는 챗봇 창)가 콜백 함수 4개로 손을 잡고 있었다.
MainWindow ──(콜백 4개)──→ AgentBotWindow _getActiveSession() _getSessionName() _getActiveDirectory() _getGroups() |
이 구조에서 다음 5가지가 한꺼번에 부딪혔다.
| 문제 | 콜백 구조에서의 한계 |
|---|---|
| 터미널 AI → 봇 통신 불가 | 콜백은 단방향. 터미널 안에서 돌고 있는 Claude가 봇에게 "방금 사용자 승인 받아주세요"를 보낼 경로가 없다. |
| 상태 모델 부재 | 터미널이 평범한 셸인지 AI 에이전트가 점유한 상태인지 구분할 자리가 없다. |
| 워크스페이스 격리 | 모든 터미널이 한 리스트에 평탄하게 들어 있어 그룹별 독립 제어가 어렵다. |
| 장애 전파 | 한 터미널이 죽으면 같은 UI 스레드를 공유해 다른 모든 것에 영향을 준다. |
| 시스템 전체 조회 | "지금 모든 워크스페이스/터미널 상태를 한 번에 알려줘"가 콜백 조합으로는 답이 나오지 않는다. |
여러 후보를 둘러봤지만, 위 다섯 문제를 하나의 모델로 동시에 풀어 줄 수 있는 건 액터 모델이었다.
| 요구 | Akka에서의 답 |
|---|---|
| 양방향 통신 | 두 액터가 서로의 IActorRef를 알면 끝. 굳이 콜백을 끼워 넣을 필요가 없다. |
| 상태 모델 | Become(PlainCli) ↔ Become(AiAgent)로 같은 액터가 다른 동작을 갖는다. |
| 워크스페이스 격리 | WorkspaceActor 자식 트리를 별도로 둔다. 그룹마다 독립 서브트리. |
| 장애 격리 | OneForOneStrategy로 자식 죽음이 부모/형제로 번지지 않는다. |
| 시스템 전체 조회 | Stage.Ask<StageStatusResponse>(QueryStageStatus) 한 줄. |
| 향후 분산 | 같은 메시지 프로토콜로 Akka.Remote/Cluster까지 자연스럽게 이어진다. |
여기서 또 한 가지 의도가 있었다. AgentWin은 결국 "AI 에이전트들을 설계 가능한 시스템 부품처럼 다루고 싶다"는 야심을 갖고 있었고, 그 부품들이 주고받는 채널은 처음부터 메시지 기반이어야 한다는 직관이 있었다. Akka는 그 직관을 언어적으로 강제해 준다.
Phase 1에서는 로직 없는 골격부터 그렸다. 아래 4개의 액터로 충분했다.
ActorSystem("AgentZero")
└── /user/stage (StageActor) — 최상위 감독자 + 메시지 브로커
├── /bot (AgentBotActor) — 사용자 대화 봇 (Become: Chat/Key/Ai)
├── /ws-proj1 (WorkspaceActor) — 워크스페이스
│ ├── /term-0 (TerminalActor) — Become: PlainCli/AiAgent
│ └── /term-1 (TerminalActor)
└── /ws-proj2 (WorkspaceActor)
└── /term-0 (TerminalActor) |
| 액터 | 한 줄 책임 |
|---|---|
| StageActor | 워크스페이스/봇 자식의 생명주기를 관리하고, 터미널 ↔ 봇 양방향 메시지를 라우팅한다. |
| AgentBotActor | 사용자가 대화하는 봇. Become으로 채팅/키 입력/AI 모드를 갈아탄다. |
| WorkspaceActor | 한 워크스페이스(폴더 한 개)에 속한 터미널들의 부모. 자식 생성/정리와 ID 기반 라우팅을 맡는다. |
| TerminalActor | ConPTY 세션 하나를 감싸는 액터. 평범한 셸일 땐 PlainCli, AI 프롬프트가 감지되면 AiAgent로 바뀐다. |
public sealed record SendToTerminal(string WorkspaceName, string TerminalId, string Text); public sealed record BotToTerminalMessage(string WorkspaceName, string TerminalId, string Text, BotMessageType Type); public sealed record QueryStageStatus; public sealed record StageStatusResponse(IReadOnlyList<WorkspaceInfo> Workspaces, bool BotRegistered); |
C#의 sealed record는 액터 메시지에 거의 완벽한 짝이다. 불변이라 액터 경계를 넘어 전달돼도 안전하고, 패턴 매칭이 자연스러우며(Receive<SendToTerminal>(msg => ...)), 메시지 이름만으로 방향과 의도를 짐작할 수 있다. 총 26개의 메시지 타입이 6개 카테고리(Stage / Bot / Workspace / Terminal / 양방향 / 생명주기)로 정리되었다. 이 메시지 카탈로그가 곧 AgentWin의 통신 명세서가 된다.
터미널은 두 모드 중 하나에 머문다. 핵심은 같은 메시지(BotToTerminalMessage)가 모드에 따라 다르게 처리된다는 것이다.
| 동작 | PlainCli | AiAgent |
|---|---|---|
| WriteToTerminal | 텍스트 전달 | 텍스트 전달 |
| BotToTerminalMessage | 무시 (대화 불가) | 처리 (대화 가능) |
| TerminalOutput | 단순 로그 | AI 패턴 분석 + 봇으로 전달 |
Become은 if-else로 흩어질 수밖에 없는 모드 분기 로직을 핸들러 한 묶음씩 통째로 교체할 수 있게 해 준다. AgentBotActor도 같은 패턴으로 Chat / Key / Ai 세 모드를 갖는다.
이게 콜백으로는 풀리지 않던 그 문제다.
터미널 AI → 봇 (예: Claude가 사용자에게 승인을 요청하는 패턴을 출력함)
TerminalActor(AiAgent) → WorkspaceActor → StageActor → AgentBotActor TerminalToBotMessage Forward Forward UI 콜백 → AgentBotWindow |
봇 → 터미널 AI (예: 봇이 AI 모드에서 펑션콜로 특정 터미널에 명령 전달)
AgentBotActor → StageActor → WorkspaceActor → TerminalActor(AiAgent) BotToTerminalMessage Forward Forward 세션에 Write |
여기서 Forward가 핵심이다. Tell은 새 메시지를 만드는 느낌이라면 Forward는 기존 메시지를 발신자 정보까지 보존하면서 다음 노드로 그대로 흘려보낸다. 라우팅이 아무리 깊어도 응답을 받을 사람이 누군지 잃지 않는다.
// Phase 1 (모든 예외 → Restart) ← 단순했지만 위험
localOnlyDecider: ex => Directive.Restart
// Phase 2 (예외별 세분화)
localOnlyDecider: ex => ex switch
{
ObjectDisposedException => Directive.Stop, // 파이프 해제됨 — 재시작 불가
IOException => Directive.Stop, // I/O 실패
_ => Directive.Restart // 그 외는 재시작
} |
ConPTY 파이프가 닫힌 상태에서 액터를 Restart해도 같은 예외만 반복된다. "복구 가능한 예외"와 "그 자체로 종료가 답인 예외"를 구분하는 것이 액터 시스템의 안정성을 크게 좌우한다. 이 한 줄 차이가 Phase 1 평가에서 받았던 가장 큰 지적이었다.
설계만 멋지면 곤란하다. 기존 콜백 코드가 멀쩡히 돌고 있는 와중에 액터 시스템을 끼워 넣어야 했다. 그래서 Phase 2는 7개의 작은 단계로 쪼갰고, 각 단계마다 빌드와 테스트를 다시 통과시켰다.
| Sub-Phase | 한 일 | 통과한 테스트 |
|---|---|---|
| 2-1 | ActorSystemManager를 App.xaml.cs에 통합 (시작/종료) | 47 |
| 2-2 | MainWindow에서 터미널 생성/종료 시 메시지 발행 | 47 |
| 2-3 | TerminalActor ↔ ITerminalSession 실제 바인딩 | 47 |
| 2-4 | AI 프롬프트 패턴 감지 → 자동 모드 전환 | 50 (+3) |
| 2-5 | AgentBotWindow ↔ AgentBotActor 브릿지 | 50 |
| 2-6 | 봇 AI의 펑션콜에서 Stage API 호출 (stage_status, stage_send) | 50 |
| 2-7 | 터미널 AI ↔ 봇 양방향 통신 E2E 검증 | 53 (+3) |
원칙은 두 가지였다. (1) 병행 운영: 새 메시지 경로는 기존 콜백과 동시에 살아 있는다. 액터가 아직 초기화되지 않았으면 메시지는 조용히 무시된다. 사용자 경험은 끊기지 않는다. (2) 빌드/테스트 연속성: 매 단계마다 dotnet build 에러 0, dotnet test 100% 통과를 확인하지 않으면 다음 단계로 가지 않는다.
AI 모드의 봇은 LLM에게 27개의 펑션콜 도구를 노출한다. 그중 두 개가 액터 시스템과 직접 대화한다.
// stage_status — LLM이 "지금 무슨 터미널들이 있냐?"를 묻는다
ReceiveAsync<QueryStageStatus>(async msg =>
{
var sender = Sender; // ⚠️ await 전에 Sender 캡처 (소실 방지)
var aggregated = new List<WorkspaceInfo>();
foreach (var ws in _workspaces)
{
try
{
var resp = await ws.Value.Ask<TerminalsResponse>(
new QueryTerminals(), TimeSpan.FromSeconds(3));
aggregated.Add(new WorkspaceInfo(ws.Key, resp.Terminals));
}
catch
{
// 한 워크스페이스 실패가 전체를 망가뜨리지 않게
aggregated.Add(new WorkspaceInfo(ws.Key, Array.Empty<TerminalInfo>()));
}
}
sender.Tell(new StageStatusResponse(aggregated, _botActor is not null));
}); |
이 짧은 코드에 액터 모델의 안전 패턴 두 개가 들어 있다. Sender 캡처 — await 너머로 Sender를 그대로 쓰면 그땐 다른 메시지의 sender로 바뀌어 있을 수 있다. 로컬 변수에 먼저 받아 둬야 한다. 개별 실패 격리 — 한 워크스페이스의 Ask가 타임아웃 나도 나머지는 정상 응답한다. 액터 하나의 사고가 시스템 전체 응답을 막지 않는다.
여기서부터가 사실 이 글에서 가장 재미있는 부분이다. 액터 시스템을 깔았다고 끝이 아니다. 진짜 전쟁은 그 위에서 돌아가는 LLM(특히 온디바이스 LLM)이 시스템을 어떻게 오용하느냐를 보고 다시 짜는 일이다.
개선 사이클은 모두 이렇게 돌아갔다.
앱 로그에서 이상 패턴 발견 → 원인 분석 → 코드 수정 + 다층 방어
→ 단위/E2E 테스트로 잠금 → 재실행 후 로그 재검증 → 테크 문서화 |
가장 먼저 한 일은 AI 모드 LLM 호출의 모든 단계를 태그가 있는 로그로 박은 것이다.
[AI-IN] user input len=78, mdFiles=0, totalContent=78, historyMsgs=1
[AI-REQ] mode=FnCall, model=gemma-4-26b-a4b, msgs=1, maxTokens=4096
[AI-FnCall] round=1, toolCalls=1, textLen=0
[AI-TOOL] >> meeting_create({"topic":"세계평화","participants":"Claude1, Claude2"})
[AI-TOOL] << meeting_create result_len=87, first80=Meeting created: C:\...
[AI-FnCall] round=2, toolCalls=2, textLen=0
[AI-TOOL] >> term_send({"group_index":0,"tab_index":0,"text":"/agent-zero ..."})
[AI-TOOL] << term_send result_len=52, first80=OK: sent to session frontend/CMD-1
[AI-RESP] final text, len=156, rounds=4 |
이렇게 하니까 "이전에는 보이지 않던 사고"가 한순간에 보였다. 뒤에 나오는 모든 개선은 이 로그가 없었다면 시작도 못 했다.
코드 한 줄 수정이 아니라, 테스트 철학을 바꾼 사건이다.
E2E 미팅 진행 중 봇이 stage_send({"terminal":"Claude1","text":"..."})을 호출했고, 로그에는 Sent to AgentWin/Claude1: ... 성공 메시지까지 찍혔는데, 실제 터미널에는 아무것도 안 들어갔다.
원인을 따라 들어가니 3곳에 TODO가 사슬처럼 걸려 있었다.
AgentToolbox.StageSendAsync — Tell 직후 "Sent to..."를 즉시 반환하는 거짓 성공.StageActor.HandleSendToTerminal — TerminalId를 떨어뜨리고 WriteToTerminal만 워크스페이스에 보냄.WorkspaceActor — 받은 메시지를 로그만 찍고 자식 터미널에 전달 안 함.53개 테스트는 전부 "메시지가 도착했는가"까지만 검증하고 있었다. 자식 액터가 받은 메시지를 정말 세션의 WriteAndSubmit까지 흘려 보냈는지를 확인하는 테스트가 한 개도 없었다. 이 빈틈이 액터 E2E 테스트의 전형적인 함정이다.
수정은 두 가지였다.
(1) 코드는 Forward로 — TerminalId를 잃지 않게
private void HandleSendToTerminal(SendToTerminal msg)
{
if (_workspaces.TryGetValue(msg.WorkspaceName, out var workspace))
workspace.Forward(msg); // 메시지 전체를 그대로 전달
else
_log.Warning("SendToTerminal: workspace not found: {0}", msg.WorkspaceName);
} |
(2) 테스트는 MockTerminalSession으로 — 사이드 이펙트까지 검증
public sealed class MockTerminalSession : ITerminalSession
{
public List<string> WriteAndSubmitCalls { get; } = new();
public void WriteAndSubmit(string text) => WriteAndSubmitCalls.Add(text);
// ...
}
[Fact]
public void SendToTerminal_should_reach_specific_terminal_and_invoke_session_write()
{
stage.Tell(new RegisterWorkspace("proj1", "/path"));
stage.Tell(new CreateTerminalInWorkspace("proj1", "Claude1", "s1"));
var mock1 = new MockTerminalSession("s1");
stage.Tell(new BindSessionInWorkspace("proj1", "Claude1", mock1));
stage.Tell(new SendToTerminal("proj1", "Claude1", "hello claude1"));
AwaitAssert(() =>
{
Assert.Contains("hello claude1", mock1.WriteAndSubmitCalls);
}, TimeSpan.FromSeconds(3));
} |
이 테스트는 모든 액터가 실제로 동작하면서, 끝단의 ITerminalSession.WriteAndSubmit이 호출되는지를 본다. 액터 E2E의 정공법이고, 한 번 이렇게 잠그면 라우팅 회귀를 즉시 잡을 수 있다. 이후 라우팅 관련 테스트는 4개 추가되어 53 → 57로 늘었다.
다음 사고는 더 미묘했다. 사회자 봇이 다중 AI 미팅을 진행하다가 갑자기 이런 호출을 했다.
term_send(tab_index=0, text="meeting_say(\"C:\\...\", \"Claude1\", \"...\")") |
번역하면 "Claude1 터미널에 meeting_say(...)라는 함수 호출 코드를 텍스트로 타이핑해라"이다. 이건 LLM이 도구의 경계를 혼동한 결과다. 사회자에게는 meeting_say가 있지만, 터미널 안의 Claude는 그 도구를 호출할 능력이 없다(텍스트 입력만 받는다). 그래서 Claude는 그 문자열을 입력창에 붙여 넣고 멍해진다. 사회자는 응답이 안 오니 같은 호출을 25회 반복한다.
3중 방어로 막았다. (1) 정규식으로 함수 호출 문법 감지 → 즉시 거부, (2) 도구 설명에 "NATURAL LANGUAGE, not function call" 명시 + 부정 예시, (3) 다중 라인/장문이면 150ms 후 명시적 Enter 추가 전송.
핵심은 에러 메시지가 LLM에게 교정 가이드 역할을 하도록 쓰는 것이었다.
Error: text must be natural language, not a function call. Detected 'meeting_say(...)' pattern. The terminal AI reads text as raw input — it cannot execute your tools. If you want to record to meeting log, use meeting_say() directly (it's YOUR tool). To instruct the terminal AI, write plain sentences like '의견을 말씀해주세요'. |
또 한 번은 한 라운드에 도구 호출 79개가 쏟아지는 사건이 있었다. 분석해 보니 LLM이 term_read(tab_index=0, 1, 2, 3, 4, ..., 67)까지 순차로 시도한 것이었다. 유효한 인덱스는 0~2뿐이었는데도. 여기서 깨달았다. 온디바이스 LLM은 에러를 학습하지 못한다. 같은 에러를 100번 받아도 다음에 같은 시도를 한다. 그러니 코드가 대신 학습해 줘야 한다.
가드 1 — 에러 메시지 안에 유효 목록과 STOP 명령
Error: invalid tab_index 3 in group 0 ('AgentWin').
Valid tab_indexes: [0='Claude1', 1='Claude2', 2='Claude3'].
STOP incrementing tab_index — these are the ONLY terminals.
Call term_list once and remember the result. |
가드 2 — 동일 호출 카운터 ((funcName + args) 조합이 3회 초과면 차단)
var callKey = $"{funcName}:{funcArgs}";
callCounts.TryGetValue(callKey, out var count);
if (count + 1 > MaxSameCallRepeats)
{
result = $"Error: This exact call was already made {count + 1} times. " +
$"DO NOT repeat. The result will be the same. Try a different approach.";
} |
가드 3 — 라운드당 호출 상한 (한 라운드에 30개 초과면 즉시 종료 + 사용자에게 경고)
이 세 개의 가드가 깔린 뒤로는 라운드당 평균 호출 수가 50~80에서 1~4로 약 20배 줄었다.
가드만으로는 부족했다. 더 근본적인 문제는 온디바이스 LLM이 "지금 작업의 어디까지 했는지"를 추적하지 못한다는 것이었다. 히스토리가 200개를 넘어가면 사실상 매 라운드를 처음 본 것처럼 행동했다.
답은 봇 액터 안에 작은 메모리를 두는 것이었다.
// AgentBotActor 내부
private const int MaxMemoryEntries = 30;
private readonly List<string> _sessionMemory = new();
Receive<RecordAction>(msg =>
{
_sessionMemory.Add($"[{DateTime.Now:HH:mm:ss}] {msg.Summary}");
while (_sessionMemory.Count > MaxMemoryEntries)
_sessionMemory.RemoveAt(0);
}); |
자동 기록과 명시적 기록을 둘 다 둔다. 자동: 사용자 입력, 도구 호출과 결과, 최종 응답을 줄 단위로 자동 누적. 명시: LLM에게 memory_note(note)라는 펑션콜 도구를 줘서, "지금 내가 어디까지 했는지"를 본인이 직접 적게 한다. 매 LLM 요청 직전, 봇 액터에서 메모리를 Ask로 가져와 시스템 메시지 맨 앞에 한 번만(첫 라운드만) 첨부한다.
왜 이게 액터의 책임이어야 하는가는 분명하다. 봇 액터의 생명주기 = 메모리의 생명주기. 봇이 죽으면 메모리도 같이 사라진다. UI 코드비하인드에 두면 액터 철학이 깨지고, 별도 메모리 액터를 두면 통신만 늘어난다. 상태는 그것을 가장 자주 쓰는 액터 안에 있어야 한다는 액터 모델의 일반 원칙이 그대로 적용된다.
이 모든 개선 끝에 다음 시나리오가 처음으로 깔끔하게 돌아갔다.
사용자: "AI를 이용한 개발생산성 3인 미팅 시작해"
| 시각 | 단계 | 도구 호출 | 결과 |
|---|---|---|---|
| 05:52:35 | 사용자 지시 | [AI-IN] len=43 | 요청 수신 |
| 05:52:43 | 미팅 생성 | meeting_create | 회의록 파일 생성 |
| 05:52:55 | 3인 초대 | stage_send × 3 | Claude1/2/3에 동시 공지 |
| 05:54:30 | 진행 규칙 명시 | memory_note + stage_send × 3 | 계획 기록 + 규칙 전달 |
| 05:54:39 | 응답 수집 | term_read × 3 | 각 의견 획득 |
| 05:56:36 | 회의록 기록 | meeting_say × 4 | 3인 의견 요약 + 종료 |
| 05:56:45 | 최종 응답 | [AI-RESP] len=655 | 사용자에게 요약 전달 |
핵심 지표 변화는 다음과 같다.
| 지표 | 이전 (v2.0.3) | 현재 (v2.0.9) |
|---|---|---|
| 라운드당 도구 호출 평균 | 50~80 | 1~4 |
| 멤돔 루프 | 빈번 | 0건 |
| stage_send 도달률 | 거짓 성공 (0%) | 100% |
| 미팅 완결성 | 불가 | 3인 의견 수집 → 회의록 → 요약 |
테스트도 47 → 50 → 53 → 57로 늘었고, 그동안 빌드는 한 번도 깨지지 않았다.
이 모든 패턴(액터 정의, 메시지 record, 감독 전략, TestKit 사용법)은 그 자체로 재사용 가능하다. 그래서 별도 저장소(skill-actor-model)에 언어 + 액터 플랫폼 조합별로 스킬을 정리해 두었다.
skill-maker/docs/actor/ ├── 00-actor-model-overview.md ├── 01-java-akka-classic ├── 02-kotlin-pekko-typed ├── 03-dotnet-akka-net ← AgentWin이 쓰는 그것 ├── 04-memorizer-ai-agent ← AI 에이전트 특화 패턴 (별도 구분) └── 05-cross-platform-comparison.md |
언어/플랫폼별로 같은 액터 모델이 어떻게 다르게 표현되는지 비교하는 것 자체가 액터 모델을 깊게 이해하는 좋은 길이다. 더 중요한 건, 한 번 정리해 두면 새 프로젝트에서 같은 패턴을 다시 발명하지 않게 된다는 것이다.
AgentWin은 카카시 하네스라는 자체 평가 도구를 끼고 있다. 변경된 코드를 도메인별 가상의 엔지니어(conpty-engineer, llm-engineer, tamer)에게 던져서 점검을 시키는 구조다. Phase 1과 Phase 2 모두 이 하네스를 거쳤다.
Phase 1 → Phase 2 사이에서 9개 평가 축 중 7개가 향상됐고, 가장 결정적인 향상은 conpty 코드 안전성이 B → A로 올라간 부분이었다. 이건 사람이 놓치기 쉬운 액터별 권고(ObjectDisposedException → Stop 분기, PostStop에서 Dispose 금지 등)를 자동으로 챙겨 준 결과다.
여기서 중요한 패러다임 하나.
복잡성을 다룬다는 것은 두 가지 능력을 동시에 요구한다. 하나는 그 도메인의 복잡성을 이해하는 능력, 다른 하나는 복잡성 자체를 추상화로 다루는 능력.
Akka는 첫 번째(동시성/상태/장애 격리/라우팅)를 한 모델로 묶어 줬고, 하네스는 두 번째(도메인별 평가 기준을 자동으로 적용)를 도구로 만들어 줬다. 둘 다 학습 곡선이 가파른데, 그 곡선을 대신 등반해 주는 도구가 있어야 사람이 본질에 집중할 수 있다. AgentWin의 시도는 결국 "복잡성 자체를 단순화하려는" 시도이고, 이게 액터 모델과 하네스를 같이 쓰게 된 가장 큰 이유다.
Phase 2 종료 시점에서 보이는 다음 숙제는 다음과 같다.
| # | 항목 | 왜 필요한가 |
|---|---|---|
| 1 | ANSI 버퍼 깨짐 개선 | term_read가 가끔 ANSI 제어 문자만 들고 오는 케이스 |
| 2 | Akka 로그 → AppLogger 브릿지 | 액터 내부 경고가 앱 로그에 안 찍혀 디버깅이 어렵다 |
| 3 | term_read 동기 대기 | "AI 응답 끝까지 대기" 옵션 (프롬프트 패턴 감지로) |
| 4 | LLM Provider Akka.DI 주입 | 지금은 정적 ActorSelection. DI로 정리 |
| 5 | 자동 미팅 진행 FSM | memory_note로 수동 계획 중. invite→collect→record→summarize FSM 내장 |
| 6 | 하네스에 actor-engineer 신설 | 액터 도메인 전담 가상 엔지니어가 아직 없다 |
그리고 마지막으로 — 이 글의 진짜 질문.
Akka 스택은 "설계 가능한 AI 에이전트"에 도움이 될 것인가?
지금까지의 경험으로 보면 답은 조건부 yes다. 액터 모델이 주는 것은 동시성/상태/장애의 통일된 모델이고, 이것은 AI 에이전트 시스템처럼 여러 비결정적 컴포넌트가 비동기로 협력하는 환경에서 분명한 가치가 있다. 콜백 기반 GUI 코드로는 풀리지 않던 문제(터미널 AI ↔ 봇 양방향, 시스템 전체 조회, 워크스페이스 격리)가 액터 메시지 한 묶음으로 풀린 것은 작은 사건이 아니었다.
그러나 액터 모델 자체가 정답인 건 아니다. 학습 곡선이 있고, 잘못 쓰면 메시지 폭주와 거짓 성공 같은 새로운 종류의 버그가 생긴다. 그리고 이번 여정에서 가장 많은 시간이 들어간 건 액터 자체가 아니라 그 위에 얹은 LLM이 액터 시스템을 어떻게 오용하는지를 풀어내는 일이었다. 가드, 메모리, 진단 로그, 거짓 성공 차단 — 결국 사람-LLM-액터 사이의 경계 모델을 새로 짜야 했다.
그러니 더 정확한 질문은 이렇게 바뀐다.
"우리가 다루는 복잡성을 줄이지 않은 채로 그대로 다루는 도구"가 필요할 때, Akka는 그 후보 중 하나다. 그리고 그 도구를 쓸 때조차, 복잡성 자체를 다루는 추상화 능력은 여전히 사람이(또는 사람을 돕는 메타 도구가) 해야 한다.
이 두 능력이 만나는 자리에서 다음 Phase가 시작된다. AgentWin의 다음 숙제는 위 6개 항목이지만, 그보다 더 큰 숙제는 이 패턴을 다른 프로젝트에서도 빠르게 다시 세울 수 있는 부품으로 다듬는 일이다. 액터 스킬과 하네스가 그 다듬는 작업의 양 날개다.
이 글의 사례는 모두 AgentWin(개발 중)의 실제 코드/로그/평가 보고서에서 나왔다. 다이어그램 안의 텍스트는 모두 영문으로 되어 있다 — 모델은 언어와 무관해야 하고, 개발자라면 언어와 무관하게 읽혀야 한다는 의도다.