Page History
| Table of Contents | ||
|---|---|---|
|
AgentWin AgentZero 실사례로 풀어보는 Akka.NET 메시지 패턴 — 사회자 LLM과 3개의 Claude 터미널이 한 자리에 앉기까지. 작성일: 2026-04-1314. 대상: Akka를 처음 보는 .NET / 서버 개발자. 이번 편은 여러 AI 터미널이 왜 질서 있는 메시지 구조를 필요로 하는지부터 설명한다.
0. 들어가며
요즘 데스크톱에 띄워두는 CLI는 더 이상 "사람이 명령을 치는 검은 창"이 아니다. claude, codex, gemini, copilot 같은 AI 에이전트 CLI들이 그 안에 들어 앉고, 사용자는 이 CLI들과 자연어로 대화한다.
문제는 한 단계 더 가고 싶을 때다. "여러 개의 AI 터미널을 동시에 띄우고, 그들 사이를 사회자 LLM이 중계하면 어떨까?"
AgentWin(개발 중인 데스크톱 도구)에서 이 시나리오를 구현하다 보니, "AI ↔ AI 사이의 통신 복잡도"가 일반 GUI 콜백 패턴으로는 감당이 안 된다는 결론에 이르렀고, 결국 Akka.NET(액터 모델)을 도입하게 됐다. 이 글은 그 도입 여정을 설계 → 구현 → 개선 → 테스트 주도 평가 순서로 풀어 본다.
1. Akka가 뭔가 — 1분 요약
한 줄 요약: Akka는 "메일박스가 달린 작은 객체(액터)들"이 메시지를 주고받는 동시성 프레임워크다.
Akka는 원래 Scala/Java 진영의 액터 모델 구현이고, Akka.NET은 그것의 .NET 포팅이다. .NET 진영에서는 Petabridge가 상업 지원과 부트캠프, OSS 거버넌스를 사실상 이끌고 있다.
액터 모델의 핵심 약속은 단순하다.
| 약속 | 의미 |
|---|---|
| 메일박스 + FIFO | 액터는 메시지를 큐에 받고 한 번에 하나씩 처리한다. → 액터 내부 상태는 자동으로 thread-safe. |
| 공유 메모리 금지 | 액터끼리 직접 변수를 만지지 않고 메시지로만 대화한다. → 락이 사라진다. |
| 위치 투명성 | 같은 프로세스 안의 액터든 다른 머신의 액터든 호출 코드는 똑같다. → 분산으로 자연스럽게 확장된다. |
| 감독자 트리 | 부모 액터가 자식의 장애를 책임진다 (Restart / Resume / Stop / Escalate). → "let it crash" 철학. |
처음 보면 "그냥 메시지 큐 아닌가?" 싶은데, 한 번 익히면 상태 + 동시성 + 장애 격리 + 라우팅을 한 가지 모델로 통일할 수 있다는 점에서 강력하다.
이 글 뒤에서 등장할 Akka 키워드 사전. 모르는 채로 읽어도 사례에서 자연스럽게 익혀진다.
- ActorSystem — 최상위 컨테이너. 앱당 보통 1개.
- IActorRef — 액터의 핸들. "객체 참조"가 아니라 "주소" 개념.
- Tell — fire-and-forget 메시지 전송.
- Ask — 응답을 기다리는 메시지 전송 (Task로 반환).
- Become — 같은 액터가 상태에 따라 핸들러를 통째로 바꾸는 패턴 (Petabridge가 "behavior switching"이라 부르는 패턴).
- Forward — 받은 메시지를 그대로(발신자 정보 보존하며) 다음 액터로 넘기기.
- OneForOneStrategy — 자식 하나 죽었을 때 그 자식만 처리하는 감독 전략.
2. AgentWin이 풀려던 통신 문제
2.1 출발점 — 콜백 4개로 묶여 있던 두 창
AgentWin의 초기 구조는 흔한 WPF 코드비하인드였다. MainWindow(여러 터미널을 띄우는 워크스페이스)와 AgentBotWindow(사용자가 대화하는 챗봇 창)가 콜백 함수 4개로 손을 잡고 있었다.
| Code Block | ||
|---|---|---|
| ||
MainWindow ──(콜백 4개)──→ AgentBotWindow
_getActiveSession()
_getSessionName()
_getActiveDirectory()
_getGroups() |
이 구조에서 다음 5가지가 한꺼번에 부딪혔다.
| 문제 | 콜백 구조에서의 한계 |
|---|---|
| 터미널 AI → 봇 통신 불가 | 콜백은 단방향. 터미널 안에서 돌고 있는 Claude가 봇에게 "방금 사용자 승인 받아주세요"를 보낼 경로가 없다. |
| 상태 모델 부재 | 터미널이 평범한 셸인지 AI 에이전트가 점유한 상태인지 구분할 자리가 없다. |
| 워크스페이스 격리 | 모든 터미널이 한 리스트에 평탄하게 들어 있어 그룹별 독립 제어가 어렵다. |
| 장애 전파 | 한 터미널이 죽으면 같은 UI 스레드를 공유해 다른 모든 것에 영향을 준다. |
| 시스템 전체 조회 | "지금 모든 워크스페이스/터미널 상태를 한 번에 알려줘"가 콜백 조합으로는 답이 나오지 않는다. |
2.2 왜 Akka였나 — "메시지 + 트리 + 상태"가 한 모델
여러 후보를 둘러봤지만, 위 다섯 문제를 하나의 모델로 동시에 풀어 줄 수 있는 건 액터 모델이었다.
| 요구 | Akka에서의 답 |
|---|---|
| 양방향 통신 | 두 액터가 서로의 IActorRef를 알면 끝. 굳이 콜백을 끼워 넣을 필요가 없다. |
| 상태 모델 | Become(PlainCli) ↔ Become(AiAgent)로 같은 액터가 다른 동작을 갖는다. |
| 워크스페이스 격리 | WorkspaceActor 자식 트리를 별도로 둔다. 그룹마다 독립 서브트리. |
| 장애 격리 | OneForOneStrategy로 자식 죽음이 부모/형제로 번지지 않는다. |
| 시스템 전체 조회 | Stage.Ask<StageStatusResponse>(QueryStageStatus) 한 줄. |
| 향후 분산 | 같은 메시지 프로토콜로 Akka.Remote/Cluster까지 자연스럽게 이어진다. |
여기서 또 한 가지 의도가 있었다. AgentWin은 결국 "AI 에이전트들을 설계 가능한 시스템 부품처럼 다루고 싶다"는 야심을 갖고 있었고, 그 부품들이 주고받는 채널은 처음부터 메시지 기반이어야 한다는 직관이 있었다. Akka는 그 직관을 언어적으로 강제해 준다.
3. 설계 — 액터 4개로 그린 작은 도시
Phase 1에서는 로직 없는 골격부터 그렸다. 아래 4개의 액터로 충분했다.
최근 재밌는 실험을 하나 진행했다. 어느 순간 내 개발 환경은 코드 편집기라기보다, 여러 AI CLI를 제어하는 조종석이 되어 가고 있었다.
똑똑한 AI CLI만 해도 Claude Code, Codex, Gemini, 온디바이스 LLM까지 제각각이고, 이들을 쓰는 환경도 제각각이다. 어떤 건 WSL 위에서 돌고, 어떤 건 Windows Terminal 위에서 돈다. IDE도 하나로 통일되지 않는다. 인텔리J, 라이더, 웹스톰, 토이 프로젝트에선 VS Code, 조금 더 네이티브한 OS 프로그래밍으로 가면 Visual Studio까지 섞여 들어온다.
문제는 여기서 끝나지 않는다. 왼쪽 IDE에서는 코드를 보고, 아래 터미널에서는 여러 AI CLI를 돌리고, 오른쪽 보조 AI로는 Copilot이나 JetBrains AI를 붙이게 된다. 어느 순간 IDE는 디버깅 도구만이 아니라, CLI를 다중으로 제어하기 위한 거대한 리모컨이 된다.
그렇다고 이 툴들을 버릴 수도 없다. 집중 모드로 들어가면 도커도 관리해야 하고, 메모리 릭 도구도 써야 하고, 코드 퀄리티 검사도 로컬에서 직접 돌려봐야 하기 때문이다. 즉 우리는 “IDE를 버리고 AI만 쓰는 시대”로 간 게 아니라, “IDE 안에서 AI와 기존 도구를 동시에 더 많이 다뤄야 하는 시대”로 들어온 셈이다.
그러다 문득 이런 상상을 하게 됐다. “이 많은 CLI를 TUI 멀티뷰로 모아두고, 명령은 만능 리모컨 하나로 제어하면 어떨까?” 그 발상에서 시작한 프로젝트가 AgentZero다.
처음엔 단순했다. 여러 AI CLI를 한 화면에서 보고, 필요한 쪽에 명령을 보내는 정도였다. 그런데 일을 벌려 놓고 보니 리모컨과 CLI들이 조금씩 더 많은 말을 하기 시작했다. 간단한 명령 전달을 넘어서, AI CLI끼리 티키타카까지 가능해졌다. 바로 그 지점부터 통신체계가 눈에 띄게 복잡해졌고, 사람이 제어하기 어려운 수준에 도달했다.
여기서 문제가 더 재밌고 더 무서워졌다. 통합 리모컨으로 TV를 켰는데 에어컨이 켜지고, 냉장고에게 “식혀줘”라고 했더니 전자레인지가 데우는 모드로 들어가는 식이다. 더 골치 아픈 건 이게 항상 틀리는 것도 아니라는 점이다. 어떤 날은 맞고, 어떤 날은 엉뚱하게 반응한다. 그래서 더 헷갈린다. 리모콘이 지능을 달았나? 장치들이 서로의 신호를 잘못 듣고 있나? 아니면 주소 체계 자체가 꼬인 건가?
이쯤 되면 이건 그냥 만능 리모컨이 아니라 슈뢰딩거의 리모콘에 가깝다. 버튼을 누르기 전까지는 어떤 장치가 반응할지 확정되지 않고, 버튼을 누른 뒤에도 확률적으로만 맞는 것 같은 이상한 시스템. 개발자 입장에서는 “가끔 되니까 더 위험한” 종류의 버그다.
결국 전체 문제를 해결하려면 버튼을 더 영리하게 누르는 것이 아니라, 누가 어떤 메시지를 받고, 어디까지 전달하고, 누가 책임지고 복구해야 하는지 설계를 다시 해야 했다.
여기서부터는 단순 채팅 앱이 아니라 작은 애니메이션 관제실이 된다. 성격 다른 조연들이 한 무대에 올라와 서로 대사를 주고받기 시작했는데, 감독이 큐 사인을 놓치면 순식간에 장면이 무너지는 구조다. 누가 누구에게 말을 걸었는지, 누가 대기 중인지, 어느 터미널이 죽었는지, 지금 전체 방이 어떤 상태인지 한 번에 알아야 한다.
AgentZero에서 이 장면을 구현하다 보니 기존 WPF 콜백 구조는 바로 한계에 부딪혔다. 그래서 선택한 것이 Akka.NET 액터 모델이다.
| Info |
|---|
이번 편의 핵심
|
1. 토이 스토리의 장난감 회의실 — Akka 1분 요약
토이 스토리의 장난감들을 떠올리면 이해가 쉽다. 장난감 하나하나는 자기 자리와 자기 역할이 있다. 누군가의 머릿속 변수를 몰래 건드리지 않고, 부르면 자기 차례에만 움직인다. Akka의 액터도 비슷하다.
Akka는 “자기 우편함을 가진 작은 일꾼들”이 메시지로만 대화하는 동시성 모델이다.
핵심 약속은 네 가지다.
약속 | 쉬운 설명 | 개발 관점의 의미 |
|---|---|---|
메일박스 + FIFO | 액터는 자기 편지함에 온 메시지를 순서대로 본다 | 내부 상태를 한 번에 하나씩 만져서 thread-safe |
공유 메모리 금지 | 남의 주머니에 손 넣지 않고 말로만 부탁한다 | lock 지옥이 줄어든다 |
위치 투명성 | 옆방 액터든 다른 머신 액터든 주소로 부른다 | 로컬/분산 코드가 비슷해진다 |
감독자 트리 | 부모가 자식 사고를 책임진다 | 장애 복구 전략을 구조로 설계한다 |
여기서 중요한 포인트는 “메시지 큐가 있다”가 아니다. Akka는 상태, 동시성, 라우팅, 장애 격리를 한 모델로 묶는다. 그래서 동시에 여러 AI가 떠드는 장면처럼 상태가 자주 바뀌고, 순서와 복구가 중요한 시스템에서 특히 강하다.
2. 어벤져스 작전실 — 콜백 4개 구조가 왜 막혔나
AgentZero의 초기 구조는 전형적인 WPF 코드비하인드였다. MainWindow와 AgentBotWindow가 콜백 4개로 연결되어 있었다.
| Code Block | ||
|---|---|---|
| ||
MainWindow ──(콜백 4개)──→ AgentBotWindow
_getActiveSession()
_getSessionName()
_getActiveDirectory()
_getGroups() |
한두 개 창만 다룰 때는 이 방식이 편하다. 그런데 AI 터미널이 여러 개로 늘어나면 바로 어벤져스 작전실 문제가 생긴다. 닉 퓨리가 히어로 한 명씩 직접 전화 돌리는 방식으로는 중간 상태도, 장애도, 전체 상황판도 감당이 안 된다.
문제 | 콜백 구조에서 왜 막히는가 |
|---|---|
터미널 AI -> 봇 통신 | 단방향 콜백이라 터미널 안 AI가 먼저 말을 걸 통로가 없다 |
모드 전환 | 일반 셸인지 AI 점유 상태인지 표현할 모델이 없다 |
워크스페이스 격리 | 모든 터미널이 평평한 리스트에 있어 그룹 제어가 어렵다 |
장애 전파 | UI 스레드에 너무 많은 책임이 몰린다 |
전체 조회 | “지금 전부 몇 개 살아 있어?”를 조합식으로 계산해야 한다 |
즉 기존 구조는 “창 두 개가 서로 도와주는 앱”까지는 괜찮았지만, “여러 AI가 동시에 움직이는 관제실”을 감당하기에는 모델이 너무 납작했다.
3. 주토피아 교통관제실 — 액터 4개로 만든 작은 도시
Akka를 도입하면서 AgentZero는 화면 중심 구조에서 도시 구조로 바뀌었다.
| Code Block | ||
|---|---|---|
| ||
ActorSystem("AgentZero")
└── /user/stage (StageActor)
├── /bot (AgentBotActor)
├── /ws-proj1 | ||
| Code Block | ||
| ||
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) │ └── /term-01 (TerminalActor) |
| 액터 | 한 줄 책임 |
|---|---|
| StageActor | 워크스페이스/봇 자식의 생명주기를 관리하고, 터미널 ↔ 봇 양방향 메시지를 라우팅한다. |
| AgentBotActor | 사용자가 대화하는 봇. Become으로 채팅/키 입력/AI 모드를 갈아탄다. |
| WorkspaceActor | 한 워크스페이스(폴더 한 개)에 속한 터미널들의 부모. 자식 생성/정리와 ID 기반 라우팅을 맡는다. |
| TerminalActor | ConPTY 세션 하나를 감싸는 액터. 평범한 셸일 땐 PlainCli, AI 프롬프트가 감지되면 AiAgent로 바뀐다. |
3.1 핵심 설계 원칙 1 — 메시지는 불변 record로
| Code Block | ||
|---|---|---|
| ||
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의 통신 명세서가 된다.
3.2 핵심 설계 원칙 2 — Become으로 두 세계를 가른다
터미널은 두 모드 중 하나에 머문다. 핵심은 같은 메시지(BotToTerminalMessage)가 모드에 따라 다르게 처리된다는 것이다.
| 동작 | PlainCli | AiAgent |
|---|---|---|
| WriteToTerminal | 텍스트 전달 | 텍스트 전달 |
| BotToTerminalMessage | 무시 (대화 불가) | 처리 (대화 가능) |
| TerminalOutput | 단순 로그 | AI 패턴 분석 + 봇으로 전달 |
Become은 if-else로 흩어질 수밖에 없는 모드 분기 로직을 핸들러 한 묶음씩 통째로 교체할 수 있게 해 준다. AgentBotActor도 같은 패턴으로 Chat / Key / Ai 세 모드를 갖는다.
3.3 핵심 설계 원칙 3 — 양방향 통신은 Stage가 중계한다
이게 콜백으로는 풀리지 않던 그 문제다.
터미널 AI → 봇 (예: Claude가 사용자에게 승인을 요청하는 패턴을 출력함)
└── /ws-proj2 (WorkspaceActor)
└── /term-0 (TerminalActor) |
이걸 주토피아 교통관제실처럼 보면 쉽다.
액터 | 비유 | 실제 책임 |
|---|---|---|
| 중앙 관제실 | 전체 자식 생명주기 관리, 메시지 브로커 |
| 사회자 | 사용자와 대화하고 요청을 정리 |
| 구역 관리자 | 워크스페이스별 터미널 묶음 관리 |
| 현장 요원 | 실제 ConPTY 세션 1개를 감싼 실행 단위 |
이 구조가 좋은 이유는 “사람이 이해하는 경계”와 “코드가 실행되는 경계”가 거의 같아지기 때문이다. 워크스페이스는 정말 워크스페이스 액터가 되고, 터미널 하나는 정말 액터 하나가 되고, 사회자는 정말 별도 액터가 된다. 즉 설계도가 런타임 구조로 그대로 살아난다.
4. 스파이더버스의 멀티버스 스위치 — Become()이 왜 중요한가
터미널은 항상 같은 존재가 아니다. 평범한 셸일 때도 있고, AI가 점유한 에이전트 모드일 때도 있다. 여기서 Become()이 등장한다.
| Code Block | ||
|---|---|---|
| ||
[PlainCli] -- AI 프롬프트 감지 --> [AiAgent]
^ |
└-------- 모드 전환 ------------┘ |
같은 TerminalActor라도 상태가 바뀌면 같은 메시지를 다르게 처리한다.
메시지 | PlainCli | AiAgent |
|---|---|---|
| 텍스트 전달 | 텍스트 전달 |
| 무시 | 처리 |
| 로그 출력 | AI 패턴 분석 + 봇 전달 |
일반적인 if-else 구조에서는 “지금 어떤 상태인지”를 매 메시지마다 확인해야 한다. 상태가 늘수록 코드가 길게 퍼진다. 반면 Become()은 아예 핸들러 묶음을 갈아끼운다. 스파이더버스에서 같은 인물이 세계를 넘어갈 때 규칙이 달라지는 것처럼, 액터도 상태에 따라 다른 세계의 규칙으로 움직인다.
5. 닥터 스트레인지의 포털 — Forward가 라우팅을 살린다
여러 단계의 액터를 지나 메시지가 흘러갈 때 가장 무서운 일은 “누가 원래 보낸 말이었는지”를 잃는 것이다. 그래서 핵심 라우팅에는 Forward를 쓴다.
5.1 터미널 AI가 봇에게 말 걸기
| Code Block | ||
|---|---|---|
| ||
TerminalActor(AiAgent)
-> WorkspaceActor
-> StageActor
-> AgentBotActor |
5.2 봇이 특정 터미널에게 말 걸기
| Code Block | ||
|---|---|---|
| ||
AgentBotActor
-> StageActor
-> WorkspaceActor
-> TerminalActor(AiAgent) |
Tell이 새 택배를 다시 포장해서 보내는 느낌이라면, Forward는 송장까지 그대로 살려서 다음 허브로 넘기는 느낌이다. 이 차이가 중요하다. 라우팅이 깊어질수록 응답 경로가 꼬이기 쉽기 때문이다.
6. 인크레더블의 안전 매뉴얼 — 감독 전략은 예외별로 다르게
액터 모델의 또 다른 핵심은 장애 처리다. 처음엔 모든 예외를 Restart로 몰아도 될 것처럼 보인다.
| Code Block | ||
|---|---|---|
| ||
localOnlyDecider: ex => Directive.Restart |
하지만 현실은 그렇지 않았다. ConPTY 파이프가 이미 닫힌 상태라면 재시작해도 다시 같은 예외만 난다. 이럴 때는 Restart가 아니라 Stop이 맞다.
| Code Block | ||
|---|---|---|
| ||
localOnlyDecider: ex => ex switch
{
ObjectDisposedException => Directive.Stop,
IOException | ||
| Code Block | ||
TerminalActor(AiAgent) → WorkspaceActor → StageActor → AgentBotActor TerminalToBotMessage Forward => Directive.Stop, Forward_ UI 콜백 → AgentBotWindow |
봇 → 터미널 AI (예: 봇이 AI 모드에서 펑션콜로 특정 터미널에 명령 전달)
| Code Block |
|---|
AgentBotActor → StageActor → WorkspaceActor → TerminalActor(AiAgent) BotToTerminalMessage Forward Forward 세션에 Write |
여기서 Forward가 핵심이다. Tell은 새 메시지를 만드는 느낌이라면 Forward는 기존 메시지를 발신자 정보까지 보존하면서 다음 노드로 그대로 흘려보낸다. 라우팅이 아무리 깊어도 응답을 받을 사람이 누군지 잃지 않는다.
3.4 핵심 설계 원칙 4 — 감독 전략은 예외별로 갈라쓴다
| Code Block | ||
|---|---|---|
| ||
// 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 평가에서 받았던 가장 큰 지적이었다.
4. 구현 — 7단계로 점진 통합
설계만 멋지면 곤란하다. 기존 콜백 코드가 멀쩡히 돌고 있는 와중에 액터 시스템을 끼워 넣어야 했다. 그래서 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% 통과를 확인하지 않으면 다음 단계로 가지 않는다.
4.1 작은 사례 — 봇이 펑션콜로 시스템 전체 상태를 본다
AI 모드의 봇은 LLM에게 27개의 펑션콜 도구를 노출한다. 그중 두 개가 액터 시스템과 직접 대화한다.
| Code Block | ||
|---|---|---|
| ||
// 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가 타임아웃 나도 나머지는 정상 응답한다. 액터 하나의 사고가 시스템 전체 응답을 막지 않는다.
4.2 패러다임 전환 — "똑똑한 제어기"를 자기 옆에 둘 수 있게 된 시대
위의 §4.1은 코드 몇 줄로 끝나는 작은 사례처럼 보이지만, 사실 이 안에 최근 1~2년 사이에 일어난 큰 패러다임 전환이 겹쳐 있다. 이 글에서 한 번은 짚고 넘어가야 하는 지점이다.
예전의 그림은 이랬다. "자연어로 시스템을 제어하는 똑똑한 컨트롤러"가 필요하면, 사실상 선택지는 OpenAI/Anthropic/Google의 클라우드 API 하나뿐이었다. 모델이 크고 정교한 펑션콜(Tool Use)을 안정적으로 해낼 수 있는 곳이 거기밖에 없었기 때문이다. 결과적으로 "고차원 추론"과 "자잘한 기능 호출" 같은 결의 업무가 둘 다 같은 비싼 API 한 곳을 거쳐야 했다. 내부 도구 하나 호출할 때마다 토큰 비용이 붙고, rate limit이 붙고, 사용자 데이터가 밖으로 나가고, 네트워크 지연이 붙었다.
2025년 말~2026년에 이 전제가 무너졌다. 그 중심에 Google이 Apache 2.0으로 오픈한 Gemma 계열 모델들이 있다.
| 모델 | 역할 | 핵심 스펙 |
|---|---|---|
| Gemma 4 (E2B / E4B) | 경량 범용 온디바이스 모델. 펑션콜·구조화 JSON·시스템 프롬프트·멀티모달까지 네이티브 지원 | Apache 2.0 · 140개 이상 언어 · 128K 컨텍스트 · E2B는 일부 장치에서 1.5GB 메모리 미만으로 구동 |
| FunctionGemma (270M) | 펑션콜 전용으로 파인튜닝된 초경량 엣지 모델. 자연어 ↔ 구조화 함수 호출 변환에 특화 | Apache 2.0 (상업적 사용 가능) · 모바일/Jetson Nano급 디바이스 실행 · "unified action and chat" |
성능도 "장난감" 수준이 아니다. Google이 공개한 LiteRT-LM 벤치마크에 따르면 라즈베리 파이 5 CPU에서도 Gemma 4 E2B가 prefill 133 tokens/s, decode 7.6 tokens/s를 찍고, Qualcomm Dragonwing IQ8 NPU에서는 prefill 3,700 tokens/s, decode 31 tokens/s까지 올라간다 (Google Developers Blog). 4,000 입력 토큰을 2개 스킬에 분배하는 에이전트 시나리오가 GPU 가속 환경에서 3초 이내로 끝난다. 로컬에서 실용적 에이전트가 돌아갈 수 있다는 말이다.
이 변화가 AgentWin 같은 시스템에 주는 의미는 분명하다.
| 측면 | 클라우드 API 시대 | 온디바이스 Gemma 시대 |
|---|---|---|
| 비용 | 토큰당 과금, rate limit, 예산 천장 | 제로 API 비용, 하드웨어가 허락하는 만큼 무한 호출 |
| 프라이버시 | 사용자 입력·터미널 출력이 외부 서버 경유 | 데이터가 네트워크를 떠나지 않음 — "own the entire stack" |
| 응답 레이턴시 | 네트워크 RTT가 본격 병목 | 로컬 추론 — ms 단위 |
| 오프라인 동작 | 불가 | 가능 — 비행기, 망분리 환경 포함 |
| 상업적 사용 | 벤더 약관에 묶임 | Apache 2.0으로 자유 (사용 제한 조항 별도 확인) |
AgentWin이 지금까지 써 온 gemma-4-26b-a4b류 모델이 바로 이 흐름 위에 서 있다. 사회자 봇의 펑션콜 루프 — stage_status로 시스템 전체 상태를 읽고, memory_note로 계획을 쓰고, stage_send로 3개 터미널에 메시지를 뿌리는 — 이 모든 것이 사용자 PC의 로컬 모델만으로 닫혀 돌아간다. 앞서 §5에서 길게 얘기한 진단 로깅, 멤돔 가드, 세션 메모리, 함수 호출 오염 차단도 바로 이 "로컬 온디바이스 컨트롤러를 쓸 만하게 길들이는" 작업의 일부였다.
또 하나 주목할 만한 포지셔닝은 FunctionGemma가 Google 스스로 "intelligent traffic controller"라고 부르는 지점이다 (InfoQ, 2026-01). 쉬운 제어·필터링은 초경량 엣지 모델이 로컬에서 처리하고, 정말 추론이 필요한 요청만 큰 원격 모델로 라우팅하는 2단 구조 — 이건 AgentWin이 지금 봇(로컬 Gemma) + 터미널 AI(더 큰 Claude Code)로 풀고 있는 구조와 거의 같다. 업계가 그 패턴에 이제 이름을 붙이고 있는 중이다.
정리: "추론"과 "자잘한 기능 제어" 중 최소한 후자는 더 이상 비싼 클라우드에 의존할 필요가 없다. 똑똑한 컨트롤러를 직접 구축해서 자기 옆에 둘 수 있는 시대가 왔다는 것 — 그리고 그 컨트롤러를 Akka.NET 같은 메시지 기반 인프라에 꽂아 두면, 액터 트리 안의 모든 부품이 자연어로 제어 가능해진다. AgentWin의 §4.1 20줄짜리 펑션콜 핸들러는 바로 이 "제어기의 자가 보유"가 실제로 가능해진 순간의 증거다.
5. 개선 — 실패 데이터로 다시 만든 도구들
여기서부터가 사실 이 글에서 가장 재미있는 부분이다. 액터 시스템을 깔았다고 끝이 아니다. 진짜 전쟁은 그 위에서 돌아가는 LLM(특히 온디바이스 LLM)이 시스템을 어떻게 오용하느냐를 보고 다시 짜는 일이다.
개선 사이클은 모두 이렇게 돌아갔다.
| Code Block |
|---|
앱 로그에서 이상 패턴 발견 → 원인 분석 → 코드 수정 + 다층 방어
→ 단위/E2E 테스트로 잠금 → 재실행 후 로그 재검증 → 테크 문서화 |
5.1 진단 로깅 — 보이지 않으면 고칠 수 없다
가장 먼저 한 일은 AI 모드 LLM 호출의 모든 단계를 태그가 있는 로그로 박은 것이다.
| Code Block |
|---|
[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 |
이렇게 하니까 "이전에는 보이지 않던 사고"가 한순간에 보였다. 뒤에 나오는 모든 개선은 이 로그가 없었다면 시작도 못 했다.
5.2 라우팅 거짓 성공 — 53개 테스트 모두 통과한 채로 깨져 있던 코드
코드 한 줄 수정이 아니라, 테스트 철학을 바꾼 사건이다.
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를 잃지 않게
| Code Block | ||
|---|---|---|
| ||
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으로 — 사이드 이펙트까지 검증
| Code Block | ||
|---|---|---|
| ||
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로 늘었다.
5.3 함수 호출 문법 오염 — LLM이 본인 도구를 텍스트로 복사하다
다음 사고는 더 미묘했다. 사회자 봇이 다중 AI 미팅을 진행하다가 갑자기 이런 호출을 했다.
| Code Block |
|---|
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에게 교정 가이드 역할을 하도록 쓰는 것이었다.
| Code Block |
|---|
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 '의견을 말씀해주세요'. |
5.4 멤돔 방지 가드 — 한 라운드 79개 도구 호출
또 한 번은 한 라운드에 도구 호출 79개가 쏟아지는 사건이 있었다. 분석해 보니 LLM이 term_read(tab_index=0, 1, 2, 3, 4, ..., 67)까지 순차로 시도한 것이었다. 유효한 인덱스는 0~2뿐이었는데도. 여기서 깨달았다. 온디바이스 LLM은 에러를 학습하지 못한다. 같은 에러를 100번 받아도 다음에 같은 시도를 한다. 그러니 코드가 대신 학습해 줘야 한다.
가드 1 — 에러 메시지 안에 유효 목록과 STOP 명령
| Code Block |
|---|
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회 초과면 차단)
| Code Block | ||
|---|---|---|
| ||
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배 줄었다.
5.5 세션 메모리 — 30개짜리 슬라이딩 윈도우
가드만으로는 부족했다. 더 근본적인 문제는 온디바이스 LLM이 "지금 작업의 어디까지 했는지"를 추적하지 못한다는 것이었다. 히스토리가 200개를 넘어가면 사실상 매 라운드를 처음 본 것처럼 행동했다.
답은 봇 액터 안에 작은 메모리를 두는 것이었다.
| Code Block | ||
|---|---|---|
| ||
// 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 코드비하인드에 두면 액터 철학이 깨지고, 별도 메모리 액터를 두면 통신만 늘어난다. 상태는 그것을 가장 자주 쓰는 액터 안에 있어야 한다는 액터 모델의 일반 원칙이 그대로 적용된다.
6. 작동의 증거 — 3인 미팅 E2E
이 모든 개선 끝에 다음 시나리오가 처음으로 깔끔하게 돌아갔다.
사용자: "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로 늘었고, 그동안 빌드는 한 번도 깨지지 않았다.
7. 곁가지 두 개 — 액터 스킬과 하네스
7.1 액터 스킬화
이 모든 패턴(액터 정의, 메시지 record, 감독 전략, TestKit 사용법)은 그 자체로 재사용 가능하다. 그래서 별도 저장소(skill-actor-model)에 언어 + 액터 플랫폼 조합별로 스킬을 정리해 두었다.
| Code Block |
|---|
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 |
언어/플랫폼별로 같은 액터 모델이 어떻게 다르게 표현되는지 비교하는 것 자체가 액터 모델을 깊게 이해하는 좋은 길이다. 더 중요한 건, 한 번 정리해 두면 새 프로젝트에서 같은 패턴을 다시 발명하지 않게 된다는 것이다.
7.2 하네스 평가
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의 시도는 결국 "복잡성 자체를 단순화하려는" 시도이고, 이게 액터 모델과 하네스를 같이 쓰게 된 가장 큰 이유다.
8. 다음 과제 — 그리고 물음표
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개 항목이지만, 그보다 더 큰 숙제는 이 패턴을 다른 프로젝트에서도 빠르게 다시 세울 수 있는 부품으로 다듬는 일이다. 액터 스킬과 하네스가 그 다듬는 작업의 양 날개다.
9. 관련 기술 — Akka Agents와 에이전틱 패턴 지도
이 글을 마무리하는 시점에서 흥미로운 사실 하나. AgentWin이 Akka.NET 기본 액터 위에서 손으로 다시 발명한 부품들 — 봇 상태, 세션 메모리, 펑션콜 도구 테이블, 감독 전략 — 이 대부분 이미 이름이 붙어 패키지된 고수준 프레임워크가 공개돼 있었다. 글의 본문이 사례 중심으로 끝났기 때문에, 마지막에 "그럼 그 업계 지도는 어떻게 생겼는가"를 짧게 덧붙인다.
9.1 Akka Agents — 고수준 프레임워크
2026년 기준 akka.io/akka-agents가 공개돼 있다. 자신을 "AI 에이전트, MCP 도구, HTTP/gRPC API를 빠르게 구축하는 프레임워크"로 소개하며, 핵심은 5개의 선언적 컴포넌트다.
| 컴포넌트 | 역할 |
|---|---|
| Agent | 목표지향적 에이전트 기본 단위 |
| Memory | 세션 간 컨텍스트 유지를 위한 내장 메모리 |
| Tools | 로컬 함수 / 외부 API / MCP 도구 호출 |
| Prompts | 런타임 수정 가능한 동적 프롬프트 템플릿 |
| Endpoints | HTTP / gRPC / MCP 자동 노출 |
여기에 "well-defined lifecycle — from gathering context to reasoning to taking action"이라는 라이프사이클이 기본으로 딸려 오고, 이 모든 것이 long-lived, distributed services that persist state and survive crashes로 동작한다. 즉 Akka 액터 시스템의 지속성·확장성·장애 허용성이 에이전트 추상화 레벨에서 그대로 상속된다.
AgentWin이 Phase 2 동안 손으로 짠 것과 거의 그대로 매핑된다.
| AgentWin이 손으로 짠 것 | Akka Agents가 이미 이름붙인 것 |
|---|---|
| AgentBotActor + Become(Chat/Key/Ai) | Agent + lifecycle |
| _sessionMemory 30개 FIFO | Memory |
| 27개 펑션콜 도구 (stage_send, memory_note, …) | Tools |
| 시스템 프롬프트 + 메모리 첨부 로직 | Prompts |
| AgentToolbox, StageActor API | Endpoints (HTTP/gRPC/MCP) |
발견한 것: AgentWin이 밟은 길은 업계가 이미 패턴화한 길이었다. 손으로 다시 발명한 것 자체가 낭비는 아니다 — 경계를 직접 부딪히며 알게 된 것이 많다 — 하지만 다음 프로젝트부터는 이런 프레임워크 위에서 출발하는 것이 합리적이다.
9.2 에이전틱 패턴 5종 — 이름 붙은 공통 설계
한편 LLM 에이전트 시스템 자체에도 이미 합의된 표준 디자인 패턴이 있다. Microsoft Azure 아키텍처 센터와 2026년 여러 가이드에서 공통으로 언급되는 핵심 5개는 다음과 같다.
| 패턴 | 한 줄 설명 | AgentWin 현재 |
|---|---|---|
| Tool Use | LLM을 "도구 호출 엔진"으로 만드는 펑션콜 기반 구조 | ✓ (27개 stage_/term_/meeting_ 도구) |
| Reflection | 에이전트가 자기 결과를 스스로 비평하고 재시도 — HumanEval 벤치마크 80→91% 상승 사례 보고 | ✗ (없음 — 다음 Phase 후보) |
| Planning | 고성능 모델이 계획을 세우고 저렴한 모델이 실행 — 비용 최대 90% 절감 사례 | △ (memory_note로 수동 계획 중) |
| Multi-Agent Orchestration | 한 지휘자가 여러 전문가 에이전트를 조율 ("puppeteer" 패턴) | ✓ (사회자 봇 ↔ 3 Claude 터미널) |
| Human-in-the-Loop | 위험한 단계에서 사람 승인을 요청 | ✓ (Approval TerminalToBotMessage) |
Gartner는 Q1 2024 → Q2 2025 사이 multi-agent 관련 문의가 1,445% 증가했다고 보고했다. AgentWin이 붙잡고 있던 문제(봇 사회자 + 3개 터미널 AI의 협업)는 우리 프로젝트만의 이상한 문제가 아니라 시장 전체가 지금 막 이름을 붙여 가는 공통 문제였다는 뜻이다.
9.3 다음 Phase 지도로 이어지는 길
위 두 자료(Akka Agents 컴포넌트 × 에이전틱 패턴 5종)를 겹쳐 놓으면 AgentWin의 다음 숙제가 조금 더 선명해진다.
- Reflection 패턴 도입 — 사회자 봇이 회의록을 한 번 스스로 검토한 뒤 사용자에게 전달. 가장 저렴한 품질 상승이 기대되는 후보.
- Planning 패턴 공식화 — 지금 memory_note로 수동 계획 중인 부분을 FSM으로 내장 (§8의 Phase 3 항목 #5와 그대로 연결).
- Akka Agents 이식성 검토 — Akka JVM 기반 Akka Agents와 Akka.NET 기반 AgentWin의 개념 매핑이 얼마나 겹치는지 / 부분 이식이 가능한지 조사.
- Endpoints 레이어 정식화 — 지금은 WPF 코드비하인드가 모든 입구 역할을 겸하지만, HTTP/gRPC/MCP로 외부에 열면 AgentWin 자체가 다른 에이전트 시스템의 "도구"가 된다.
결국 이 글의 마지막 물음표("Akka 스택은 설계 가능한 AI 에이전트에 도움이 되는가")에 조건부 yes라는 답이 붙은 이유가 한 번 더 확인된다. 액터 모델은 기반이고, 그 위에 업계가 이미 이름 붙인 에이전트 부품(Agent/Memory/Tools/Prompts/Endpoints)과 에이전틱 설계 패턴(Reflection/Planning/Multi-Agent/Tool Use/HITL)이 얹힌다. 우리가 해야 할 일은 이 지도를 보고 다음엔 어느 산부터 오를지를 정하는 것뿐이다.
참고
- Akka.NET 공식 사이트
- Petabridge — Akka.NET 부트캠프와 OSS 거버넌스
- Petabridge 부트캠프 — Why Akka.NET?
- Akka.NET Actors' Hidden Super Power: Behavior Switching
- Akka.NET GitHub
- Akka Agents — 고수준 에이전트 프레임워크 (Agent/Memory/Tools/Prompts/Endpoints)
- The Definitive Guide to Agentic Design Patterns in 2026 — Sitepoint
- AI Agent Orchestration Patterns — Microsoft Azure Architecture Center
- Gemma 4 — Google Blog (Apache 2.0, 온디바이스 에이전틱)
- Bring state-of-the-art agentic skills to the edge with Gemma 4 — Google Developers Blog
- FunctionGemma model overview — Google AI for Developers
- Google Releases Gemma 3 270M Variant Optimized for Function Calling — InfoQ
...
=> Directive.Restart
} |
중요한 건 무조건 복구가 아니다. 무엇을 복구하고, 무엇은 끊어내야 하는지 경계를 그리는 것이다. 액터 시스템은 이 판단을 코드 한복판이 아니라 감독 전략이라는 구조적 자리에서 하게 해 준다.
7. 가디언즈 오브 갤럭시 영입전 — 7단계 점진 통합
새 구조가 좋다고 기존 코드를 하루아침에 다 갈아엎을 수는 없다. AgentZero는 이 이행을 7단계로 쪼갰다.
단계 | 한 일 | 테스트 |
|---|---|---|
2-1 |
| 47 |
2-2 | 터미널 생성/종료 이벤트 메시지화 | 47 |
2-3 |
| 47 |
2-4 | AI 프롬프트 감지 후 모드 전환 | 50 |
2-5 |
| 50 |
2-6 |
| 50 |
2-7 | 터미널 AI <-> 봇 양방향 E2E | 53 |
운영 원칙은 두 가지였다. 새 경로가 완성되기 전까지 기존 콜백을 완전히 끊지 않고, 각 단계마다 dotnet build, dotnet test를 통과시킨다. 가디언즈 멤버를 한 번에 다 태우지 않고 한 명씩 합류시키는 식이다.
8. 인사이드 아웃 기억구슬 — 로그, 가드, 세션 메모리가 왜 필요했나
여기서부터가 진짜 실전이다. 액터 구조를 세웠다고 끝나지 않았다. 그 위에서 LLM이 시스템을 어떻게 오용하는지 계속 드러났다.
8.1 보이지 않으면 못 고친다 — 진단 로그
[AI-REQ], [AI-FnCall], [AI-TOOL], [AI-RESP] 로그를 심고 나서야 거짓 성공, 잘못된 함수 호출 복사, 같은 도구 반복 호출, 무한 폴링 같은 이상 패턴이 보였다. 로그는 단순 기록이 아니라 액터와 LLM 사이에서 무슨 일이 벌어졌는지 보여주는 블랙박스였다.
8.2 기억이 없는 사회자는 같은 실수를 반복한다 — 세션 메모리
온디바이스 LLM은 긴 히스토리에서 “지금 어디까지 했는지”를 자주 놓친다. 그래서 AgentBotActor 안에 최근 30개 작업을 보관하는 세션 메모리를 두었다.
| Code Block | ||
|---|---|---|
| ||
private const int MaxMemoryEntries = 30;
private readonly List<string> _sessionMemory = new(); |
이 메모리는 사용자 입력, 도구 호출과 결과, 다음에 해야 할 일을 매번 시스템 메시지로 다시 정리해 준다. 인사이드 아웃의 기억구슬처럼 지금까지의 맥락을 잃지 않게 붙잡아 주는 셈이다.
8.3 과잉 행동을 막는 가드
실제로는 이런 일도 있었다.
stage_send는 성공했다고 말하지만 실제로는 터미널에 안 감meeting_say(...)같은 함수 호출 문법을 그대로 터미널에 타이핑함term_read를 수십 번 반복 호출함
그래서 메시지 전달, 에러 문구, 동일 호출 횟수, 라운드당 호출 상한 같은 다층 가드를 넣었다.
좋은 에이전트 시스템은 도구를 많이 주는 시스템이 아니라, 도구를 어디까지 쓰게 할지 경계를 잘 친 시스템이다.
9. 엔드게임 회의록 — 3인 미팅은 실제로 어떻게 돌아갔나
모든 구조와 가드를 넣고 나서야 아래 시나리오가 깨끗하게 돌아갔다.
사용자: “AI를 이용한 개발생산성 3인 미팅 시작해”
단계 | 무슨 일이 일어났나 |
|---|---|
미팅 생성 | 사회자 봇이 회의록 파일을 만든다 |
초대 전송 |
|
의견 수집 |
|
회의록 기록 |
|
최종 응답 | 사용자가 읽을 수 있는 요약을 돌려준다 |
지표도 눈에 띄게 좋아졌다.
지표 | 이전 | 이후 |
|---|---|---|
라운드당 도구 호출 평균 | 50~80 | 1~4 |
멤돔 루프 | 빈번 | 0건 |
| 0%에 가까움 | 100% |
3인 미팅 완결성 | 불가 | 가능 |
즉 Akka는 단지 동시성 프레임워크를 써 봤다에서 끝난 것이 아니라, 여러 AI가 함께 일하는 현장을 실제로 굴리는 기반이 됐다.
10. 마무리 — PART 2 예고
이번 1편은 왜 여러 AI 터미널 협업이 콜백 구조로는 쉽게 무너지는지, 왜 Akka의 액터 트리가 이 문제를 잘 정리하는지, 실제 AgentZero가 어떤 관제 구조를 세웠는지를 먼저 보여줬다.
하지만 아직 남은 질문이 있다.
여러 AI가 질서 있게 말할 수 있게 만든 것과, 일반 LLM이 스스로 “일하는 에이전트”가 되는 것은 같은 문제일까?
아니다. 그건 다음 회차의 문제다. PART 2에서는 왜 일반 LLM은 기다리지 못하는지, 왜 ReAct가 필요해졌는지, 왜 Akka Become()이 단순한 모드 전환을 넘어 상태 머신의 뼈대가 되는지를 다룬다.
| Info |
|---|
NEXT - PART 2 1편이 “대화가 엉키지 않게 도시를 세우는 편”이었다면, 2편은 “그 도시 안에서 사회자 LLM에게 기다림과 판단을 가르치는 편”이다. |
...















