stdin은 무엇인가
터미널을 쓰다 보면 stdin이라는 말을 만난다. 표준 입력이라고 옮기는데, 실체는 간단하다. 프로그램이 입력을 읽어들이는 기본 통로다.
운영체제는 프로세스를 띄울 때 번호가 붙은 통로 세 개를 기본으로 열어 준다. 이 번호를 파일 디스크립터라고 부른다. 0번이 stdin, 1번이 stdout(표준 출력), 2번이 stderr(표준 오류)다. 프로그램은 0번에서 읽고, 결과는 1번에, 오류나 로그는 2번에 쓴다.
여기서 중요한 것은 통로가 어디에 연결되어 있는지를 프로그램이 몰라도 된다는 점이다. 키보드일 수도 있고, 파일일 수도 있고, 앞선 명령의 출력일 수도 있다. 연결은 셸이 대신 해 준다. 프로그램은 그저 stdin을 읽을 뿐이다. 유닉스 파이프라인이 성립하는 이유가 정확히 이것이다.
그리고 1번과 2번이 나뉘어 있다는 사실이 이 글 후반부의 핵심이 된다. 사람에게 보여 줄 로그와 다음 프로그램에 넘길 데이터가 섞이면 안 되기 때문에 통로를 애초에 두 개로 갈라 둔 것이다.
| 이름 | 번호(FD) | 방향 | 기본 연결 | 쓰임 |
|---|---|---|---|---|
| stdin | 0 | 입력 | 키보드 | 프로그램이 읽어들이는 데이터 |
| stdout | 1 | 출력 | 화면 | 다음 프로그램에 넘길 결과 |
| stderr | 2 | 출력 | 화면 | 사람이 볼 로그·오류 |
CLI에서 stdin은 어떻게 쓰이나

쓰임새는 크게 넷이다. 하나씩 직접 쳐 보면 감이 온다.
첫째, 파이프다. 앞 명령의 stdout을 뒤 명령의 stdin에 이어 붙인다. 인자로 넘기기엔 긴 데이터를 다룰 때 쓴다. cat error.log | claude -p "이 로그 요약해줘"처럼 쓰면 로그 전문이 stdin으로 들어간다. claude -p는 stdin에 데이터가 있으면 그것을 프롬프트 앞에 붙여서 처리한다.
둘째, 리다이렉트다. 파일을 stdin에 직접 연결한다. claude -p "설명해줘" < README.md 형태다. 파이프와 결과는 비슷하지만 프로세스를 하나 덜 띄운다.
셋째, 하이픈 하나다. 많은 CLI가 파일 인자 자리에 -를 쓰면 stdin에서 읽으라는 뜻으로 약속해 두었다. kubectl apply -f -나 docker build -t img - 같은 형태다. 이건 규격이 아니라 관행이라 도구마다 지원 여부가 다르니 도움말로 확인한다.
넷째, heredoc이다. 임시 파일을 만들지 않고 여러 줄을 그대로 stdin에 밀어 넣는다. SQL이나 설정 조각을 넘길 때 편하다.
# 1) 파이프 — 앞 명령의 stdout을 뒤 명령의 stdin으로
echo hello | cat
cat error.log | claude -p "이 로그 요약해줘"
git diff | claude -p "리뷰해줘"
# 2) 리다이렉트 — 파일을 stdin에 연결
claude -p "설명해줘" < README.md
wc -l < /etc/hosts
# 3) 하이픈 하나 = stdin에서 읽어라 (관행)
curl -s https://example.com/a.json | jq . -
kubectl apply -f -
# 4) heredoc — 임시 파일 없이 여러 줄
cat <<'EOF' | sort
banana
apple
EOFisatty — CLI가 멈추는 진짜 이유

여기서 초보자가 가장 자주 걸리는 함정이 하나 나온다. cron이나 CI 스크립트에서 CLI가 아무 출력도 없이 멈춰 버리는 현상이다.
원인은 대개 stdin이다. 프로그램이 사람에게 뭔가 물어보려고 입력을 기다리는데, 그 자리에 사람이 없는 것이다. 터미널에서는 프롬프트가 떠서 상황이 보이지만 백그라운드에서는 아무것도 안 보인 채 영원히 멈춘다.
그래서 제대로 만든 CLI는 stdin이 터미널에 연결되어 있는지를 먼저 확인한다. 이 검사를 isatty라고 부른다. 터미널이면 사람이 앞에 있다는 뜻이니 물어보고, 아니면 파이프로 들어온 데이터를 그냥 읽는다. 같은 명령이 대화형으로도 스크립트로도 동작하는 것이 이 분기 덕분이다.
남의 CLI가 멈췄을 때 쓸 수 있는 대응은 둘이다. --non-interactive나 -y 같은 플래그가 있으면 그것을 붙이고, 없으면 stdin에 빈 것을 연결한다. 명령 끝에 < /dev/null을 붙이면 프로그램은 즉시 입력 끝을 만나 질문을 포기한다. cron 스크립트에 이걸 습관처럼 붙여 두면 원인 모를 정지가 크게 준다.
# 파이썬에서 직접 분기하기
import sys
if sys.stdin.isatty():
name = input("이름: ") # 사람이 터미널 앞에 있다
else:
data = sys.stdin.read() # 파이프로 들어왔다
# 셸에서 확인 — 터미널이면 0, 파이프면 1
[ -t 0 ] && echo "터미널" || echo "파이프"
# 남의 CLI가 입력을 기다리며 멈출 때
some-cli --non-interactive # 플래그가 있으면 이것부터
some-cli < /dev/null # 없으면 stdin을 비워서 연결MCP에서 stdin은 통신 채널 그 자체다
여기부터 stdin의 위상이 달라진다. 지금까지는 데이터를 나르는 통로였다면, MCP의 stdio 전송에서는 stdin과 stdout이 통신 채널 그 자체다.
MCP 규격은 전송 방식을 둘로 정의한다. 하나는 Streamable HTTP로, 원격 서버가 쓴다. 다른 하나가 stdio다. 규격의 설명 그대로, 클라이언트가 띄운 자식 프로세스의 표준 스트림 위로 줄바꿈으로 구분된 메시지를 주고받는다.
즉 로컬 MCP 서버는 특별한 무엇이 아니다. 그냥 프로세스다. Claude Code 같은 클라이언트가 그 프로세스를 자식으로 띄우고, 소켓도 포트도 열지 않은 채 파이프로 대화한다. 서버는 stdin에서 요청을 읽고 stdout으로 응답을 쓴다. 그게 전부다.
주고받는 내용은 JSON-RPC 2.0 메시지이고, 한 줄에 하나씩이다. 규격은 메시지가 줄바꿈으로 구분되며 메시지 안에 줄바꿈이 들어가서는 안 된다고 못 박아 두었다. 그래서 파서는 한 줄을 읽어 JSON으로 해석하면 되고, 그만큼 단순하다.
설정 파일이 하는 일도 정확히 이것뿐이다. Claude Code의 MCP 설정에 적는 command와 args는 어떤 명령으로 프로세스를 띄울지를 말할 뿐이고, 나머지는 그 프로세스의 stdin과 stdout에 붙는 것이 전부다. claude mcp add 도움말이 stdio 서버 등록 예시를 두 개의 하이픈 뒤에 실행 명령을 그대로 적는 형태로 보여 주는 것도 같은 이야기다.
# 두 개의 하이픈 뒤는 그대로 실행될 명령이다
claude mcp add my-server -- npx my-mcp-server
# 환경변수를 주면서
claude mcp add my-server -e API_KEY=xxx -- npx my-mcp-server
# 등록된 서버 확인
claude mcp list
claude mcp get my-server
# 참고: 원격 서버는 stdio가 아니라 HTTP로 붙는다
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp→ 서버의 stdin으로:
{"jsonrpc":"2.0","id":1,"method":"tools/list"}
← 서버의 stdout에서:
{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}그래서 stdout에 로그를 찍으면 서버가 죽는다
앞 문단을 그대로 뒤집으면 MCP 서버 개발에서 가장 자주 나오는 사고가 설명된다. stdout이 프로토콜 채널이므로, 그 채널에 프로토콜이 아닌 것을 쓰면 안 된다.
규격의 문장은 단호하다. 서버는 유효한 MCP 메시지가 아닌 것을 자신의 stdout에 써서는 안 된다. MUST NOT이다. 권고가 아니라 금지다.
그런데 이 실수는 놀랄 만큼 쉽게 난다. console.log("서버 시작됨") 한 줄이면 충분하다. 직접 쓰지 않아도 난다. 가져다 쓴 라이브러리가 초기화하면서 배너를 찍거나, npx가 패키지를 받으면서 경고를 뱉거나, 개발 중에 남겨 둔 디버그 출력이 하나 남아 있으면 그대로 JSON 스트림에 섞인다. 클라이언트 파서는 한 줄을 읽어 JSON으로 해석하려다 실패하고, 연결이 끊어진다.
MCP 서버가 연결 안 됨으로 뜰 때 가장 먼저 확인할 것이 이것이다. 코드에서 stdout으로 나가는 출력을 전부 찾아 stderr로 옮긴다. 규격도 서버가 로깅 목적으로 stderr에 UTF-8 문자열을 쓸 수 있다고 명시하고, 클라이언트는 stderr에 뭐가 찍혔다고 해서 그것을 오류로 간주해서는 안 된다고 적어 두었다. stderr가 규격이 허용한 로깅 통로다.
덧붙이면, 규격은 최근 개정에서 서버가 클라이언트에 로그 메시지를 보내던 Logging 기능을 폐기 대상으로 표시하면서 새 구현은 stderr에 로그하라고 안내한다. 방향이 같다.
// 프로토콜이 깨진다 — stdout이 채널이다
console.log("서버 시작됨");
console.log(JSON.stringify(debugState));
// 정상 — 로그는 stderr로
console.error("서버 시작됨");
process.stderr.write(`[debug] tool called: ${name}\n`);
// 파이썬도 같다
print("서버 시작됨") # 깨진다
print("서버 시작됨", file=sys.stderr) # 정상
// 실수를 원천 차단하고 싶다면 진입점에서 막아 둔다
console.log = console.error;# 서버를 그냥 띄워 보고 stdout에 무엇이 나오는지 본다
# JSON 이외의 글자가 한 줄이라도 보이면 그것이 원인이다
node my-server.js < /dev/null
# stderr를 버리고 stdout만 남겨서 보면 더 분명하다
node my-server.js < /dev/null 2>/dev/null클라이언트 없이 서버를 직접 찔러 보기
MCP 서버가 그냥 stdin과 stdout으로 말하는 프로세스라는 사실에는 실용적인 보상이 따라온다. Claude Code를 거치지 않고 서버를 직접 시험해 볼 수 있다는 것이다.
요청 JSON 한 줄을 파이프로 서버의 stdin에 밀어 넣으면 된다. 도구 목록을 묻는 tools/list가 가장 간단한 시험이다. 응답 JSON이 그대로 돌아오면 서버는 정상이다. 안 돌아오거나 이상한 글자가 섞여 나오면 stdout이 오염된 것이다.
이 방법이 유용한 이유는 문제를 좁혀 주기 때문이다. 클라이언트에서 연결이 안 될 때, 원인이 서버 코드인지 설정인지 클라이언트인지 구분이 안 된다. 직접 찔러 봐서 응답이 오면 서버는 무죄이고 설정이나 클라이언트 쪽을 보면 된다.
더 편한 도구도 있다. MCP 공식 Inspector는 서버를 띄워 놓고 도구 목록과 호출 결과를 브라우저나 터미널에서 확인하게 해 준다. 손으로 JSON을 만들지 않아도 되니 반복 작업에는 이쪽이 낫다.
Claude Code 안에서 서버의 stderr 로그를 보려면 디버그 모드를 켠다. 예전에 안내되던 --mcp-debug는 지금 도움말에 없다. 현재는 -d 또는 --debug에 카테고리 필터를 주는 형태이고, 파일로 남기려면 --debug-file에 경로를 준다. 도움말을 한 번 확인하고 쓰는 편이 안전하다.
# 도구 목록 요청을 서버의 stdin으로 직접 밀어 넣는다
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node my-server.js
# 응답이 이렇게 돌아오면 정상이다
# {"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}
# 공식 Inspector로 띄워서 확인하기
npx @modelcontextprotocol/inspector node my-server.js
# Claude Code에서 디버그 로그 보기 (--mcp-debug 아님)
claude --help | grep -i -E 'mcp|debug'
claude -d mcp
claude --debug-file /tmp/claude-debug.log정리 — 어디까지가 stdin의 이야기인가
stdin 자체는 단순하다. 프로그램이 입력을 읽는 0번 통로이고, 셸이 거기에 키보드든 파일이든 앞선 명령의 출력이든 연결해 준다. CLI에서는 파이프·리다이렉트·하이픈·heredoc 네 가지 형태로 만나고, 사람이 없는 환경에서 멈추지 않으려면 isatty 분기나 < /dev/null이 필요하다.
MCP는 그 통로를 통신 채널로 쓴다. 그 결정 하나에서 나머지가 전부 따라 나온다. 로컬 서버가 프로세스인 것도, 설정에 명령과 인자만 적는 것도, stdout에 로그를 찍으면 안 되는 것도, 로그가 stderr로 가야 하는 것도, 클라이언트 없이 echo 한 줄로 서버를 시험할 수 있는 것도 같은 사실의 다른 얼굴이다.
그래서 MCP 서버를 만들다 연결이 안 되면 순서는 이렇다. 먼저 stdout에 JSON 아닌 것이 나오는지 본다. 나오면 그것을 stderr로 옮긴다. 그래도 안 되면 echo로 직접 찔러 서버와 클라이언트 중 어느 쪽 문제인지 가른다. 이 두 단계에서 대부분 끝난다.
마지막으로 범위를 분명히 해 둔다. 지금까지의 이야기는 stdio 전송에만 해당한다. 원격 MCP 서버는 Streamable HTTP로 붙으므로 stdin과 무관하고, stdout에 무엇을 찍든 프로토콜이 깨지지 않는다. 로컬에 설치해서 쓰는 서버는 대부분 stdio다.
