Claude Agent SDK를 Railway에 배포하면서 겪은 3가지 함정
Source: Dev.to
나는 Claude Agent SDK 로 만든 Slack 봇 앱을 Railway에 배포했는데, SDK 자체와 관련된 일련의 함정을 바로 마주쳤다. 모두 “로그만 보고는 원인을 알 수 없다”는 종류였고, 특히 두 번째 함정은 내 시간을 많이 잡아먹었다. 다른 사람도 같은 지점에서 막힐 가능성이 높으니 정리해 둔다.
이 글은 @anthropic-ai/claude-agent-sdk (query()) 를 Node.js에서 사용하는 주니어‑~미드레벨 개발자를 위한 것이다.
TL;DR
Gotcha 1: 루트 컨테이너에서는 bypassPermissions 를 사용할 수 없으며, 자식 프로세스가 code 1 로 종료된다. 더 심각하게도 stderr 가 사라져 왜 그런지 알 수 없다.
Gotcha 2: stdio MCP 서버는 기본적으로 연결을 기다리지 않으므로, 첫 번째 턴에서 툴 목록이 비어 있다 — 모델이 툴 호출을 “연기”하고 결과를 직접 만들어낸다.
Gotcha 3: API 로그에 haiku 가 보이지만, 이는 모델이 저하된 것이 아니라 설계된 동작이다. 내부 작업에 사용된다.
Gotcha 1: bypassPermissions 가 루트 컨테이너에서 동작하지 않음
무슨 일이 있었나
로컬에서는 정상적으로 동작하던 코드가 Railway에 배포하자마자 code 1 로 죽었다. 에이전트는 아무 일도 하지 않고 바로 종료되었다. 전체 오류 메시지는 사실 다음과 같았다.
Error: Claude Code process exited with code 1
이 한 줄만으로는 원인을 알 수 없었다. 앱에서 나온 스택 트레이스만 있었고, 실제로 claude 바이너리가 죽기 직전에 출력한 내용은 완전한 블랙박스였다.
원인
query() 는 내부적으로 claude 바이너리를 스폰한다. 이 바이너리는 루트 혹은 sudo 로 실행될 때 --dangerously-skip-permissions (SDK에서는 permissionMode: "bypassPermissions" 로 호출)를 거부한다. 이는 모든 권한 검사를 건너뛰는 것이 너무 위험하기 때문에 마련된 안전 장치다.
Railway 같은 대부분의 컨테이너 환경은 기본적으로 루트 사용자로 실행되므로, bypassPermissions 를 설정했다면 언제든 이 문제에 부딪히게 된다. 일반 사용자로 로컬에서 실행한다면 잡히지 않는다.
로그가 없는 이유
여기가 핵심이다. options.stderr 콜백을 전달하지 않으면 SDK 가 자식 프로세스의 stderr 를 "ignore" 로 버린다. 즉, 실제 오류 메시지가 설계상 숨겨진다.
디버깅 첫 단계는 stderr 를 캡처하는 것이다:
const result = query({
prompt: "...",
options: {
stderr: (data) => {
console.error("[claude stderr]", data);
},
// ...
},
});
이 코드를 추가하자마자 “루트에서는 bypassPermissions 를 사용할 수 없다”는 메시지를 확인했고, 원인이 확정되었다. Agent SDK 로 code 1 이 발생했을 때는 먼저 stderr 콜백을 달아 보라 — 시간 절약에 큰 도움이 된다.
해결 방법
bypassPermissions 를 제거하고, 필요한 툴을 allowedTools 에 명시한다.
const result = query({
prompt: "...",
options: {
// permissionMode 를 설정하지 않는다 (기본값 유지)
allowedTools: [
"Read",
"Bash",
"mcp__myserver__get_sales",
// 필요한 툴을 나열
],
stderr: (data) => console.error("[claude stderr]", data),
},
});
allowedTools 에 나열된 툴은 자동 승인되어 기본 권한 모드에서도 실행된다. “모두 건너뛰기”에서 “사용하는 것만 허용하기”로 사고방식을 전환하는 것이 프로덕션에 더 안전한 설정이다.
Gotcha 2: stdio MCP 가 연결을 기다리지 않아 첫 턴에 툴을 만들어냄 (가장 어려운 문제)
이게 가장 골치 아팠다. 에이전트가 자신 있게 가짜 숫자를 반환하는 버그이며, 로그를 보면 정상적으로 보인다.
무슨 일이 있었나
Slack 봇 앱이 stdio 를 통해 MCP 서버에 연결해 판매량과 가격을 조회한다. 하지만 프로덕션에서는 에이전트가 MCP 툴을 호출하지도 않았는데도 그럴듯한 판매량·가격 수치를 스스로 만들어낸다.
그리고 매번 발생하는 건 아니었다 — 로컬에서 재현하기 어려운, “간헐적이고 환경 의존적인” 버그였다.
원인: 시작 레이스 패배
stdio MCP 서버는 기본적으로 논블로킹이다. 즉, SDK 가 MCP 연결이 완료될 때까지 기다리지 않는다.
이게 어떤 문제를 일으키는가? MCP 가 아직 pending (연결 중) 상태일 때 턴‑1 프롬프트가 조립되면, 툴 목록이 빈 상태로 모델에 전달된다. 모델 입장에서는 사용할 툴이 전혀 없으므로, “판매량을 찾아라” 라는 요청을 받으면 툴 호출을 텍스트 형태로 “연기”하고 스스로 그럴듯한 결과를 작성한다. 이것이 가짜 데이터를 만든 원인이다.
구조적으로는 “로컬 프로세스 스폰”과 “네트워크 API 호출” 사이의 레이스이다:
- MCP 서버 = 로컬 프로세스 스폰 (CPU 집약)
- 턴‑1 프롬프트 조립 = Anthropic API 호출
CPU 가 제한된 Railway 환경에서는 로컬 프로세스 스폰이 매번 늦어져 레이스에서 지고, 따라서 문제가 일관되게 재현된다. 반대로 내 로컬 머신에서는 CPU 부하를 인위적으로 높여 재현할 수 있었고, 이를 통해 원인을 확인했다.
어떻게 구분할까
init 이벤트의 mcp_servers 상태를 확인한다 — 즉시 확인 가능:
connected→ 툴이 존재 → 정상pending→ 툴 목록이 비어 있음 → 가짜 데이터 생성
이 상태만 확인해도 현재 응답을 신뢰할 수 있는지 판단할 수 있다. 디버깅 시 로그에 남겨 두면 좋다.
해결 방법: alwaysLoad: true
각 MCP 서버 설정에 alwaysLoad: true 를 추가한다. 이렇게 하면 연결이 완료될 때까지 (최대 5초) 시작을 차단해, 턴‑1 에 툴이 반드시 준비되도록 보장한다.
const result = query({
prompt: "...",
options: {
mcpServers: {
myserver: {
command: "node",
args: ["./mcp-server.js"],
alwaysLoad: true, // 연결될 때까지 대기
},
},
allowedTools: ["mcp__myserver__get_sales"],
},
});
에이전트 사용 사례에서는 “툴이 준비될 때까지 말하지 않기”가 “논블로킹·빠름”보다 훨씬 중요하다. 숫자를 다루는 에이전트에게 가짜 데이터는 치명적이므로, 여기서는 차단하고 확실성을 확보하는 것이 최선이다.
Gotcha 3: API 로그에 나타나는 haiku 가 모델 저하가 아님
무슨 일이 있었나
Gotcha 2 를 조사하던 중, 메인 작업에 사용한 모델(sonnet)과는 별개로 haiku 로 향하는 요청이 섞여 있는 것을 보았다. 그리고 그 요청들은 16 토큰 정도의 아주 작은 출력이었다.
“모델이 조용히 다운그레이드된 건가?” “가짜 데이터의 원인일까?” 라는 생각이 잠시 들었지만, 이는 전적으로 설계된 정상 동작이었다.
원인
Agent SDK 는 내부 작업(요약, 분류, 짧은 판단 등)을 위해 작은 haiku 모델을 사용한다. 메인 응답 생성(sonnet)과는 별개이며, 비용과 속도 최적화를 위해 의도적으로 설계된 것이다.
가짜 데이터의 원인은 툴 부재(Gotcha 2)였지, 모델 품질이 아니었다.
교훈
“출력이 이상해 → 모델이 약함 → Opus 로 올려야겠다” 라는 생각은 자연스럽지만, 여기서는 모델을 올려도 가짜 데이터를 막을 수 없다 (근본 원인인 툴 부재는 그대로다).
툴이 실제로 전달되고 있는지 먼저 확인하고, 그 다음에 모델을 교체하거나 업그레이드하는 것이 올바른 순서다.
정리
세 가지 모두 “로그가 보여주는 것”이 오해를 일으키는 버그였다.
| 증상 | 실제 원
