Claude Code Agent 루프 심층 분석 (8): Ctrl-C부터 합성 tool_result까지
요약
본 글은 Claude Code Agent의 루프가 사용자의 중단 요청(Ctrl-C)에 어떻게 대응하는지 심층 분석합니다. 에이전트가 복잡한 작업 도중에도 히스토리 손상 없이 작업을 안전하게 멈추는 메커니즘을 설명하며, 단일 AbortController를 사용하여 모든 동시 작업에 취소 신호를 전달하는 방법을 다룹니다.
핵심 포인트
- Ctrl-C와 같은 중단은 복잡한 루프에서도 구조적 무결성을 유지해야 합니다.
- 하나의 공유된 AbortController가 스트리밍, 병렬 도구 호출 등 전체 루프를 아우릅니다.
- 취소 검사는 HTTP 요청, 스트리밍/도구 시작 전, 도구 배치 사이에 이루어집니다.
- 중단 시 히스토리 손상을 방지하는 것이 핵심 기술적 과제입니다.
이전 글에서는 루프가 인프라 장애로부터 어떻게 복구하는지 설명했습니다. 하지만 스스로 해결할 수 없는 한 가지 경우가 있습니다. 바로 사용자가 마음을 바꾸는 경우입니다.
어쩌면 에이전트가 관련 없는 파일을 읽고 있거나, 잘못된 경로로 가고 있거나, 단순히 사용자로부터 새로운 정보가 필요할 수도 있습니다. 사용자는 이를 중단시킬 수 있어야 합니다.
제품 상호작용은 간단해 보입니다—Ctrl-C를 누르는 것. 하지만 루프의 결과는 그렇지 않습니다. 중단되는 시점에 시스템은 LLM 응답을 스트리밍하고 있을 수 있고, 여러 도구를 병렬로 실행하고 있을 수 있으며, 또는 승인(approval) 프라미스를 기다리고 있을 수 있습니다. 이 각각의 상황은 메시지 히스토리를 구조적으로 불완전하게 만들 수 있으며, 특히 tool_use에 일치하는 tool_result가 없는 경우 더욱 그렇습니다.
본 게시물에서는 Claude Code가 히스토리를 손상시키지 않으면서 작업을 멈추는 방법과, 왜 중단(interruption)이 때로는 오류 결과를 생성하지만, '중단 후 새 메시지 전송' 액션은 그렇지 않은지를 설명합니다.
하나의 AbortController가 전체 루프를 아우른다
루프 실행마다 새로운 Node.js AbortController가 생성됩니다. 이는 두 가지 것을 제공합니다:
signal: 다운스트림 작업에 전달되어 취소(cancellation)를 관찰할 수 있게 합니다.abort(): 신호를 중단된 상태로 표시합니다.
Claude Code는 이 컨트롤러를 QueryEngine에 유지하고 활성 루프 전체에서 공유합니다:
QueryEngine
├── this.abortController = new AbortController()
├── interrupt() → this.abortController.abort()
...
UI의 Ctrl-C는 QueryEngine.interrupt()를 호출합니다. 즉시, 공유된 신호를 보유한 모든 작업이 취소를 관찰할 수 있게 됩니다.
하나의 컨트롤러를 사용하는 것이 중요한 이유는 루프가 여러 개의 동시 작업을 가질 수 있기 때문입니다: 스트리밍 API 요청, 병렬 도구 호출, 승인 프라미스, 또는 후크 서브프로세스 등. 하나의 중단 신호가 이 모든 곳에 도달합니다. 여러 컨트롤러를 사용하면 각각을 수동으로 취소해야 하므로 누락이 발생하기 쉽습니다.
세 가지 취소 검사 지점
루프는 세 가지 중요한 경계에서 signal.aborted를 확인합니다.
1. HTTP 요청
모델 요청은 신호를 fetch에 전달합니다:
fetch(url, { signal: toolUseContext.abortController.signal })
fetch는 취소 신호(abort signals)를 이해합니다. 취소가 발생하면 요청은 거부되고 스트리밍 연결은 물리적으로 중단되며, 이후의 SSE 이벤트는 도착하지 않습니다.
2. 스트리밍과 도구 실행 사이
응답이 전체 어시스턴트 메시지(tool_use 블록을 포함할 수 있음)를 생성한 후, 루프는 도구를 시작하기 전에 신호를 확인합니다. 중단된 경우, 새로운 도구 작업을 시작하는 것을 건너뛰고 정리 단계로 넘어갑니다.
3. 도구 배치 사이
병렬 도구들은 배치를 통해 실행됩니다. 한 배치가 완료되고 다음 배치가 시작되기 전에 루프는 다시 한번 확인합니다. 취소는 다음 배치가 시작되는 것을 막습니다.
이러한 위치는 협력적 취소(cooperative cancellation)를 반영합니다. fetch와 같은 신호 인식 I/O는 즉시 중단할 수 있지만, 순수 계산은 스스로 신호를 확인해야 합니다. 체크포인트는 루프가 더 많은 작업을 시작하기 직전의 모든 순간에 위치합니다.
누락된 tool_result 블록 합성하기
중단은 히스토리를 다음과 같은 상태로 남길 수 있습니다:
user message
assistant message: tool_use A, B, C
user message: tool_result A, tool_result B
...
이 히스토리는 유효하지 않습니다. 고아(orphaned) 상태의 tool_use C는 대응하는 tool_result가 없기 때문에, 이 대화 기록을 모델에 다시 전송하면 400 오류가 발생합니다.
Claude Code는 취소 후 진행 중인 도구 사용(in-flight tool uses)을 스캔하여 합성 결과(synthetic result)를 생성함으로써 이를 복구합니다:
{
type: 'tool_result',
tool_use_id: 'toolu_C',
...
}
이 합성 결과는 구조적 쌍 불변성(structural pairing invariant)을 복원합니다. 또한 유용한 의미론도 가집니다. 모델은 사용자가 작업을 중단했기 때문에 해당 작업이 완료되지 않았다는 것을 이해할 수 있습니다.
소스 코드는 이 복구를 yieldMissingToolResultBlocks라고 부릅니다. 이는 인터럽트 처리에서 시작되었으며, Part 3: From Reading Files to Parallel Scheduling에서 논의된 페어링 복구와 동일한 철학을 공유합니다. 페어링은 하드 제약 조건이므로, 어떤 중단도 반드시 복구되어야 합니다.
인터럽트의 두 가지 의미
실제 환경에서 Ctrl-C는 두 가지 다른 것을 의미할 수 있습니다.
A. 멈추고 생각하기
사용자는 현재 진행 상황을 검토하기 위해 루프가 멈추기를 원합니다. 나중에 또 다른 메시지를 보낼 수도 있고 아닐 수도 있습니다.
B. 수정 사항 제출을 위한 인터럽트
사용자가 에이전트를 중단하고, “잘못된 곳을 보고 있습니다. 대신 auth_v2.py를 검사하세요.”와 같은 수정을 즉시 제공하기를 원합니다.
두 경우 모두 동일한 활성 작업을 중단시키지만, 원하는 후속 조치가 다릅니다:
- 케이스 A의 경우, Claude Code는 합성된
Interrupted by user결과를 추가하고 루프를 종료합니다. - 케이스 B의 경우, 입력된 사용자 메시지가 인터럽트 직후 다음 루프 입력이 됩니다.
Claude Code는 이를 signal.reason을 통해 구별합니다:
- 일반적인 Ctrl-C 스타일의 이유는 첫 번째 경로를 따르고 인터럽트 결과를 합성합니다.
signal.reason === 'interrupt'는 “수정 사항 제출을 위한 인터럽트” 의미를 가지며, 중복된 인터럽트 메시지를 합성하지 않습니다.
따라서 두 번째 경로는 추가 텍스트가 필요 없습니다. 왜 이전 작업이 멈췄는지에 대한 설명은 이미 사용자의 새 메시지에 포함되어 있기 때문입니다. 즉, 하나의 취소 메커니즘이 작은 의미론적 플래그를 통해 두 가지 다른 사용자 경험을 지원합니다.
응답 스트리밍 중 인터럽트
만약 모델이 스트리밍을 완료하기 전에 Ctrl-C가 도착하면 어떻게 될까요?
AbortController.abort()는 기본 fetch를 중지시키지만, 이미 수신된 SSE 델타(delta)는 유지됩니다. Claude Code는 부분적인 어시스턴트 메시지를 히스토리에 추가합니다. 여기에는 처음 몇 개의 텍스트 조각은 포함될 수 있지만, 최종 종료 이유나 완료된 도구 사용 기록은 없습니다.
터미널의 결과는 aborted_streaming입니다. 나중에 모델은 시작했던 불완전한 응답을 볼 수 있습니다.
부분 출력을 유지하는 것은 의도적입니다. 사용자는 에이전트가 잘못된 말을 했다고 보고 수정하고 싶어서 중단하는 경우가 많습니다. 이러한 단어들을 보존하면 다음 메시지에서 실수를 참조할 수 있게 합니다. 이들을 삭제하면 대화가 더 깔끔해 보이겠지만, 유용한 맥락을 제거하게 됩니다.
MCP 정리(cleanup)는 메인 에이전트 중단에만 해당
Anthropic-internal 서비스와 같은 일부 MCP 서버(예: Chicago)는 루프가 끝날 때 연결 정리가 필요합니다. Claude Code는 인터럽트된 메인 에이전트 루프에 대해서만 이러한 정리를 수행하며, 서브 에이전트는 그렇지 않습니다.
그 이유는 공유 소유권 때문입니다. 서브 에이전트는 메인 루프와 MCP 연결을 공유할 수 있지만, 서브 에이전트를 취소한다고 해서 메인 루프가 여전히 필요로 하는 연결까지 끊어서는 안 됩니다. 이것은 if (!toolUseContext.agentId)에 의해 보호되는 메인 스레드 전용 분기 중 하나입니다.
무감독(unattended) 루핑의 두 번째 예외
Part 1: From Tool Declarations to Pre-Execution Approval에서는 승인(Approval)을 달리 무감독으로 진행되는 루프에 대한 첫 번째 예외로 설명했습니다. 즉, 루프가 일시 중지되고 사용자가 위험한 행동에 대해 결정하도록 요청하는 것입니다.
중단(Interrupt)은 두 번째 예외입니다. 사용자는 언제든지 루프 안으로 개입할 수 있습니다. 이는 Part 4: From a Finished Answer to the Seven Meanings of stop_reason의 maxTurns 회로 차단기(circuit breaker)와 함께 상호 보완적인 위험을 다룹니다.
- 승인 (Approval): 루프가 민감한 지점에서 선제적으로 결정을 기다립니다.
- 중단 (Interrupt): 사용자가 필요할 때마다 능동적으로 루프를 멈춥니다.
maxTurns: 아무도 개입하지 않아도 시스템이 자동으로 중지합니다.
이 세 가지 메커니즘은 모든 조건에서 자율 실행이 제어 가능한 상태를 유지하도록 보장합니다.
주요 내용 (Takeaways)
- 하나의
AbortController가 전체 루프를 포괄하여, 단일 취소(cancellation)로 모든 동시 작업에 도달할 수 있습니다. - 루프는 HTTP 요청 시점, 도구 실행 전, 그리고 도구 배치(tool batches) 사이에 취소를 확인합니다.
- 누락된 결과는
tool_use/tool_result불변성(invariant)을 유지하기 위해 합성(synthetic)is_error: true도구 결과를 사용하여 복구됩니다. signal.reason은 “멈추고 생각하기(stop and think)”와 “방해하고 수정 제출하기(interrupt and submit a correction)”를 구별합니다.- 스트리밍 중단(streaming interruption)은 나중에 대화 맥락을 위해 이미 수신된 부분 출력을 보존합니다.
- 메인 에이전트 전용 정리 작업(Main-agent-only cleanup)은 취소된 서브 에이전트가 공유 MCP 연결을 닫는 것을 방지합니다.
- 승인(Approval), 중단(interrupt), 그리고
maxTurns는 그렇지 않으면 관리되지 않는 루프에 대한 보완적인 제어 장치입니다.
다음 기사에서는 Claude Code의 마지막 대형 메커니즘인 서브 에이전트(sub-agents)를 검토합니다. 이들은 동일한 queryLoop 구현을 재사용하지만, ID 기반 분기(identity-based branches), 격리된 JSONL 기록(isolated JSONL transcripts), 그리고 agentId 라우팅을 통해 각자의 작업을 독립적으로 유지합니다.
참고 자료 (References)
주요 소스 위치 (v2.1.220):
src/QueryEngine.ts:abortController및interrupt().src/query.ts:yieldMissingToolResultBlocks와 인플라이트(in-flight) 도구 사용을 위한 합성 결과.src/query.ts:signal.reason === 'interrupt'에 대한 라우팅.src/hooks/useCancelRequest.ts: Ctrl-C 및 Esc 이벤트 캡처.src/hooks/useCancelRequest.ts:chat:cancel및app:interrupt우선순위.
관련 읽기 자료:
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기