PART 1에서 Akka 액터 트리로 AI 터미널의 질서를 세웠다면, 이번 편은 그 위에서 일반 LLM을 “일하는 에이전트”로 바꾸는 설계를 다룬다. 작성일: 2026-04-14. 대상: PART 1을 읽은 .NET / 서버 개발자.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-14-react-part2-hero-waiting-room.png](/download/attachments/125731546/2026-04-14-react-part2-hero-waiting-room.png?version=1&modificationDate=1776119098865&api=v2)
PART 1에서 우리는 관제실을 세웠다. StageActor, WorkspaceActor, TerminalActor, AgentBotActor가 자기 자리를 갖고, 여러 AI 터미널이 서로 안 부딪치게 만들었다. 그런데 여전히 한 가지가 부족했다. 사회자 LLM은 말은 잘하는데, 기다리지 못했다.
명령을 보내고 결과가 늦게 오면 조급하게 같은 도구를 다시 호출하고, 아직 끝나지 않은 작업을 다시 확인하고, 루프가 끝난 뒤에는 외부 완료 시그널을 아예 못 받았다. 즉 똑똑한 채터는 되었지만 아직 일하는 에이전트는 아니었다.
이번 편의 핵심
|
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-14-react-part2-sync-loop-trap.png](/download/attachments/125731546/2026-04-14-react-part2-sync-loop-trap.png?version=1&modificationDate=1776119102951&api=v2)
기존 RunFunctionCallLoopAsync는 얼핏 그럴듯해 보인다.
사용자 입력 -> LLM 호출 -> tool call 실행 -> 결과를 다시 LLM에 전달 -> 더 할 일이 없으면 return |
문제는 여기서 문이 닫힌다는 점이다. 예를 들어 사회자가 npm test를 터미널에 보냈다고 해 보자.
Action: term_send("npm test")
Observation: "Sent to term-0" |
여기까지만 보면 성공 같다. 그런데 실제 테스트는 아직 돌고 있다. 결과는 20초 뒤에 온다. 이 사이에 기존 루프는 선택지가 둘뿐이었다.
선택 | 왜 문제인가 |
|---|---|
폴링 | 같은 데이터를 반복 읽으며 컨텍스트를 낭비한다 |
종료 | 나중에 온 완료 시그널을 받을 방법이 없다 |
즉 동기 루프는 “지금 당장 답이 오는 세계”에는 맞지만, “나중에 다시 와서 알려줄게”가 많은 실제 도구 세계에는 약하다.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-14-react-part2-react-cycle.png](/download/attachments/125731546/2026-04-14-react-part2-react-cycle.png?version=1&modificationDate=1776119108247&api=v2)
ReAct는 Reasoning + Acting의 줄임말이지만, 어려운 논문 용어보다 먼저 장면으로 이해하는 편이 낫다. 인사이드 아웃 관제실을 떠올려 보자.
이게 ReAct의 기본 리듬이다.
Thought -> Action -> Observation -> Thought -> ... |
AgentWin 식으로 바꾸면 더 쉽다.
생각한다 -> 도구를 부른다 -> 즉시 결과인지, 나중 결과인지 본다 -> 기다리거나 다시 생각한다 -> 끝나면 사용자에게 보고한다 |
논문 ReAct는 주로 관찰이 곧바로 돌아오는 장면을 다룬다. 하지만 실제 앱은 다르다. 테스트는 몇 초에서 몇 분이 걸릴 수 있고, 다른 AI 터미널의 응답은 언제 끝날지 모르며, 파일 작업이나 외부 호출도 비동기다. 그래서 AgentWin은 원본 ReAct에 Waiting이라는 현실적인 중간 상태를 덧붙였다.
Become()이 루프를 상태 머신으로 바꾼다![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-14-react-part2-state-machine.png](/download/attachments/125731546/2026-04-14-react-part2-state-machine.png?version=1&modificationDate=1776119111189&api=v2)
여기서 Akka Become()이 빛난다. 같은 액터가 상태에 따라 다른 핸들러를 갖게 만들 수 있기 때문이다.
상태 | 하는 일 |
|---|---|
| 지금 뭘 해야 하는지 판단 |
| 도구 실행 |
| 비동기 결과를 기다림 |
| 최종 응답 정리 |
흐름은 단순하다.
StartReAct -> Thinking -> Acting -> Waiting -> Thinking -> Complete |
이 구조가 중요한 이유는 명확하다. 일반 루프는 return 하면 끝나지만, 액터는 Waiting 상태로 살아남는다. 외부 시그널이 오면 다시 Thinking으로 깨어난다. 즉 LLM 자체를 더 똑똑하게 만든 것이 아니라, LLM을 감싸는 실행 환경에 기다릴 줄 아는 몸을 붙여 준 것이다.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-14-react-part2-signal-awakening.png](/download/attachments/125731546/2026-04-14-react-part2-signal-awakening.png?version=1&modificationDate=1776119115192&api=v2)
term_send("npm test")
-> "sent"
-> term_read
-> term_read
-> term_read
-> ...
-> 10라운드 소진
-> 종료 |
term_send("npm test")
-> "sent"
-> Become(Waiting)
-> 완료 시그널 수신
-> Become(Thinking)
-> "이제 결과를 읽자"
-> term_read
-> Complete |
이 차이를 표로 보면 더 쉽다.
장면 | 동기 루프 | ReActActor |
|---|---|---|
명령 전송 직후 | 계속 확인하려 듦 | 기다림으로 전환 |
비동기 완료 | 받을 통로가 약함 |
|
컨텍스트 사용량 | 폴링 때문에 커짐 | 필요한 순간만 다시 생각 |
실패 복구 | 루프 끝나면 끝 | 타임아웃 후 재판단 가능 |
Waiting은 그냥 잠깐 멈춤이 아니다. 에이전트가 조급함 대신 질서를 선택하게 만드는 장치다.
상태 머신은 메시지 계약이 분명해야 돌아간다. AgentWin은 ReAct용 메시지를 따로 정의했다.
메시지 | 역할 |
|---|---|
| 세션 시작 |
| UI에 중간 상태 전달 |
| 외부 완료 시그널 전달 |
| 사용자 취소 |
액터 트리에서도 자리 배치가 중요했다.
ActorSystem("AgentZero")
└── /user/stage
├── /bot
│ └── /react <- ReActActor
└── /ws-{name}
└── /term-{id} |
왜 ReActActor를 bot의 자식으로 두었을까? 봇 세션 메모리와 가깝고, 터미널 완료 시그널을 포워딩하기 쉽고, UI와 진행 상태를 묶기 좋기 때문이다. 메시지 이름과 위치가 헷갈리면 에이전트도 금방 길을 잃는다.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-14-react-part2-memory-logging.png](/download/attachments/125731546/2026-04-14-react-part2-memory-logging.png?version=1&modificationDate=1776119118381&api=v2)
ReAct 상태 머신만 있다고 문제가 다 풀리진 않았다. 특히 온디바이스 LLM은 “지금 어디까지 했는지”를 자주 잊었다. 그래서 두 가지 보조 장치가 붙었다.
현재 상태: Waiting
완료된 작업: meeting_create, term_send("npm test")
대기 중: term-0의 테스트 결과
다음 예상: 결과 읽기 후 사용자 보고 |
이 메모리는 액터 상태에서 자동 생성되어 매 LLM 호출 앞에 붙는다. 다시 말해 에이전트는 매 턴마다 자기 수첩을 펼쳐 보고 일하는 셈이다.
로그 태그 | 의미 |
|---|---|
| 어떤 판단을 했는지 |
| 어떤 도구를 실행했는지 |
| 언제 기다리기 시작했고 무엇을 받았는지 |
| 어떤 상태로 넘어갔는지 |
메모리는 에이전트가 자기 일을 잊지 않게 하고, 로그는 개발자가 에이전트의 실수를 추적하게 한다. 하나는 에이전트를 위한 기억이고, 다른 하나는 사람을 위한 기억이다.
2025~2026년 프레임워크는 많다. LangGraph, OpenAI Agents SDK, AutoGen, CrewAI, Semantic Kernel 모두 장점이 있다. 그런데 AgentWin은 왜 굳이 액터 모델을 붙잡았을까?
관점 | 일반적인 에이전트 프레임워크 | Akka 액터 |
|---|---|---|
상태 | dict, 히스토리, 러너 내부 상태 |
|
장애 복구 | 수동 재시도 중심 | 감독 전략 내장 |
동시성 | async 코드 설계에 크게 의존 | 액터 경계로 자연 격리 |
분산 확장 | 별도 설계 필요 | Akka 계열과 자연 연결 |
타임아웃/대기 | 개별 구현 필요 |
|
정리하면 이렇다. 일반 프레임워크가 에이전트를 빨리 시작하게 해 주는 도구라면, Akka는 에이전트가 오래 살아남는 구조를 잘 준다. 여러 터미널이 동시에 움직이고, 외부 완료 시그널이 언제 올지 모르고, 실패 복구와 상태 관찰이 중요한 환경에서는 이 차이가 크게 드러났다.
![DevBegin > LLM에게 어떻게 '기다림'을 가르칠까 — ReAct 액터 플래닝 입문편 [PART 2] > 2026-04-14-react-part2-safety-harness.png](/download/attachments/125731546/2026-04-14-react-part2-safety-harness.png?version=1&modificationDate=1776119121589&api=v2)
에이전트가 스스로 움직인다는 말은, 스스로 폭주할 수도 있다는 뜻이다. 그래서 안전장치가 함께 들어갔다.
장치 | 의미 | 값 |
|---|---|---|
| 무한 루프 방지 | 10 |
| 너무 오래 기다리면 재판단 | 30초 |
동일 호출 제한 | 같은 도구 반복 차단 | 3회 |
| 한 턴 폭주 방지 | 30 |
| 사용자 즉시 중단 | 즉시 |
중요한 건 자율성을 없애는 게 아니다. 자율성이 길을 벗어날 때 다시 트랙 위로 올리는 제어권을 남겨 두는 것이다. 나중에 PART 3에서 보게 되겠지만, 이 안전장치는 결국 DONE 핸드셰이크, _pendingDone, ESC 제어, UI 카드 같은 더 구체적인 형태로 확장된다.
이번 편에서 본 것은 화려한 구현이 아니라, 그 구현이 필요했던 이유다.
Become()은 이 흐름을 상태 머신으로 붙잡아 준다이제 질문은 자연스럽게 다음 회차로 넘어간다.
그 설계를 실제 코드에 넣고 보니, 어디서 깨졌고 무엇을 더 붙여야 했을까?
PART 3에서는 바로 그 장면이 열린다. ReActActor는 코드에서 어떻게 구현됐는가, 터미널 AI와는 어떤 프로토콜로 다시 연결됐는가, 왜 DONE(...) 핸드셰이크가 필요했는가, 왜 카드 UI와 ESC 제어, 큐잉 같은 장치가 추가됐는가를 본다.
NEXT - PART 3 PART 2가 “사회자에게 기다리는 법을 가르치는 설계 편”이었다면, PART 3는 “그 사회자를 실제 무대에 올려 보니 무슨 일이 벌어졌는지 보는 구현 완성편”이다. |