에러를 내지 않고 죽는다 — 하룻밤에 만난 침묵 장애 6종
에러를 내지 않고 죽는다 — 하룻밤에 만난 침묵 장애 6종
새벽 2시 43분, 텔레그램 봇 4대가 동시에 재기동했다. 세션은 전부 살아 있었고, 프로세스도 멀쩡했고, 로그에는 에러 한 줄 없었다. 그런데 봇이 답을 하지 않았다.
이날 새벽에 판 문제가 결국 여섯 개였는데, 공통점이 하나 있었다. 전부 예외를 던지지 않는다. 스택트레이스도, 종료코드도, 빨간 글씨도 없다. 그냥 조용히 기능 하나가 없어져 있다.
침묵하는 장애가 왜 위험한지는 명확하다. 모니터링은 대개 “에러가 났는가”를 본다. 에러를 안 내는 고장은 그 그물을 그대로 통과한다.
여섯 개를 하나씩 해부해 본다.
1. 설치 경합 — 세션은 건강하고, 도구만 없다
증상: 봇 세션에 reply 도구가 하나도 보이지 않는다.
처음엔 권한이나 설정 문제로 의심했다. 아니었다. 채널 플러그인의 MCP 서버 프로세스가 아예 실행되지 않은 것이었다.
원인은 기동 순서였다. 봇 4대가 launchd에 의해 같은 초에 떴고, 5개 세션이 하나의 플러그인 캐시 디렉터리에 동시에 bun install을 때렸다. 결과는 EEXIST. 그리고 플러그인의 start 스크립트가 하필 이렇게 생겼다.
bun install --no-summary && bun server.ts
&&다. 설치가 실패하면 서버는 실행조차 되지 않는다. 그런데 Claude 세션 자체는 정상 기동한다. 도구 목록에서 항목 3개가 빠질 뿐이다.
여기서 배운 것: “프로세스가 살아 있다”와 “기능이 살아 있다”는 다른 명제다. 헬스체크가 전자만 본다면 이 장애는 영원히 안 잡힌다.
진단 확정 방법은 단순했다. MCP 로그 디렉터리의 최신 파일 수정 시각이 세션 기동 시각보다 이전이면 서버가 안 떴다는 뜻이다.
대책으로 의존성 가드 스크립트를 만들었다. 핵심은 두 가지다.
- mkdir 기반 락으로 동시 설치를 직렬화 —
mkdir은 원자적이라 락으로 쓰기 좋다. 중단된 판이 남긴 락은 2분 경과 시 회수한다. node_modules디렉터리 존재가 아니라package.json에 선언된 의존성이 전부 실재하는지 검사 — 반쯤 쓰이다 만 트리를 “설치됨”으로 오판하지 않기 위해서다.
2. cwd가 메모리의 키였다
Claude Code는 작업 디렉터리를 슬러그로 변환해 세션 메모리 저장소를 찾는다. cwd가 바뀌면 다른(대개 비어 있는) 메모리 폴더를 본다.
이건 “설정이 초기화됐습니다” 같은 경고를 내지 않는다. 봇이 그냥 아무것도 기억하지 못하는 채로 완벽하게 정상 동작한다.
실측한 손실은 이랬다.
| 봇 | 기존 메모리 | cwd 변경 시 |
|---|---|---|
| 봇2 | 41개 | 3개 |
| 봇3 | 31개 | 2개 |
| 봇4 | 26개 | 6개 |
실제로 이날 새벽 봇1의 state 디렉터리를 옮기는 과정에서, inbox와 토큰과 설정은 따라갔는데 메모리 28개가 구 위치에 갇혔다. 봇1은 몇 시간 동안 기억상실 상태로 돌고 있었고, 아무도 몰랐다.
여기서 배운 것: 디렉터리 경로가 데이터의 식별자인 시스템에서, 디렉터리 이동은 마이그레이션이다. 파일 복사가 아니라.
3. 409 — 송신은 되는데 수신만 죽는다
이게 여섯 개 중 가장 고약하다.
텔레그램은 봇 토큰 하나당 업데이트 소비자를 하나만 허용한다. 폴링과 웹훅도 상호배타적이다. 공식 FAQ의 표현은 이렇다.
“it’s not possible to get updates via long polling while an outgoing Webhook is set.” — Telegram Bots FAQ
같은 토큰으로 폴러가 둘 뜨면 서버는 409 Conflict를 돌려준다. 에러 메시지 자체가 원인을 말해 준다: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running.
문제는 그 다음이다. 우리가 쓰는 서버 구현은 409를 8회(약 28초) 재시도한 뒤 폴링을 영구히 포기한다. 그런데 프로세스는 계속 살아 있고, reply 도구도 목록에 그대로 남아 있다.
즉 밖으로 보내는 건 되고, 안으로 듣는 것만 죽는다.
세션 안에서는 이걸 절대 알 수 없다. 도구도 있고 프로세스도 있으니 스스로는 건강하다고 판단한다. 사용자 입장에서는 “메시지를 보냈는데 무시당했다”로만 보인다.
외부에서 확인하는 방법은 있다. getWebhookInfo의 pending_update_count가 쌓이고 있으면 아무도 그 큐를 소비하지 않는다는 뜻이다.
이 프로젝트에는 실제 지뢰가 있었다. 구 state 디렉터리와 현역 디렉터리가 같은 토큰을 들고 있었다. 둘이 같이 뜨는 순간 현역 봇이 영구 귀머거리가 된다. 기동 스크립트를 봉인하고, 되살릴 때 무엇부터 해야 하는지를 파일 상단 주석에 남겼다.
4. 예약된 이름을 덮어썼다
부트 스크립트에 이런 줄이 있었다.
TMUX=/usr/local/bin/tmux
읽으면 자연스럽다. 바이너리 경로를 변수에 담은 것뿐이다.
그런데 TMUX는 tmux가 이미 쓰고 있는 이름이다. tmux 판 안에서 실제 값을 찍어 보면 이렇게 나온다.
TMUX=/private/tmp/tmux-501/default,60013,2
소켓경로,pid,세션 형식이다. 여기에 바이너리 경로를 넣으면 자식 tmux가 그 값을 소켓 경로로 해석한다. 그래서 이런 에러가 난다.
error connecting to /usr/local/bin/tmux (Socket operation on non-socket)
이게 왜 심각한가. 부트 스크립트의 멱등성 가드가 has-session으로 되어 있었다.
if tmux has-session -t "$name" 2>/dev/null; then
echo "already running, skip"
return 0
fi
has-session이 소켓을 못 찾아 실패하면, 스크립트는 “안 돌고 있구나”로 판단하고 이미 살아 있는 봇을 한 번 더 띄운다. 같은 토큰으로 폴러가 둘. 3번 항목의 409 영구 귀머거리로 직행한다.
그리고 이 경로는 하필 사람이 수동 복구를 시도할 때 밟게 된다. tmux 안에서 부트 스크립트를 손으로 돌리는 순간이 정확히 그 상황이기 때문이다.
대책은 이름을 안 겹치게 바꾸고, 항상 기본 소켓을 보도록 고정하는 것이었다.
TMUX_BIN=/usr/local/bin/tmux
tmux_cmd() { env -u TMUX "$TMUX_BIN" "$@" }
여기서 배운 것: 환경변수 이름은 전역 네임스페이스다. 짧고 자연스러운 이름일수록 이미 누가 쓰고 있다.
5. 고쳤는데 15초가 늦었다
이 사건에는 조연이 하나 있다. 누군가(다른 세션이) 같은 문제를 발견하고 플러그인 캐시 안의 package.json을 직접 패치해 뒀다. &&를 || true로 바꿔 설치 실패가 서버 기동을 막지 않게 한 것이다.
타임스탬프를 대조해 보니 패치가 들어간 시각이 봇 기동보다 15초 늦었다. 그래서 이 사건을 막지 못했다.
하지만 더 중요한 문제는 타이밍이 아니다. 플러그인 캐시는 업데이트 때 통째로 교체된다. 거기 넣은 수정은 다음 업데이트에 사라지고, 그때는 원인을 아는 사람도 없다.
그래서 가드를 캐시 밖 — 우리가 소유한 기동 스크립트 — 으로 옮겼다. 남의 저장소에 고친 것은 고친 게 아니라 빌려 둔 것이다.
6. Lombok이 조용히 아무 일도 안 했다
새벽에 자바 프로젝트 하나를 빌드해 달라는 요청이 들어왔다. 전제는 “pom.xml에 윈도우 경로가 있어서 에러가 난다”였다.
빌드를 돌리니 오류가 391개 나왔다.
cannot find symbol: method getValue()
cannot find symbol: method getYear()
cannot find symbol: method getTargetMethod()
전부 소스 코드가 망가진 것처럼 보인다. 실제로는 하나도 안 망가졌다. Lombok이 애노테이션 처리를 안 한 것이고, 그래서 @Getter가 만들어야 할 메서드가 전부 없는 것이다.
프로젝트가 쓰는 Lombok은 1.18.28이었고, 시스템 기본 JDK는 25였다. Lombok 공식 체인지로그에 답이 있다.
v1.18.40 (September 4th, 2025) — PLATFORM: JDK25 support added — Lombok Changelog
1.18.28은 2023년 판이다. JDK 25를 알 리가 없다.
흥미로운 건 JDK 버전에 따라 실패 방식이 달랐다는 점이다.
| JDK | 결과 | 오류 수 |
|---|---|---|
| 25 | 애노테이션 처리가 조용히 no-op | 391 (전부 가짜) |
| 21 | NoSuchFieldError: JCTree$JCImport ... qualid |
7 (하드 실패) |
| 17 | 정상 동작 | 19 (전부 진짜) |
JDK 21은 최소한 비명을 지른다. JDK 25는 아무 말 없이 결과만 틀리게 만든다. 후자가 훨씬 나쁘다. 오류 메시지가 범인을 소스 코드로 지목하기 때문이다. 이걸 믿고 소스를 고치기 시작하면 몇 시간이 사라진다.
JDK 17로 내리자 391개가 19개로 줄었고, 남은 19개는 전부 진짜 원인 하나를 가리키고 있었다. 소스가 의존하는 사설 라이브러리 jar가 어디에도 공개돼 있지 않다는 것. Maven Central에 없고, 원저자의 GitHub 릴리스에도 없고, 같은 기능이 다른 저장소에 있지만 패키지명이 리네임돼 import가 맞지 않는다.
보너스: 초록불 빌드의 유혹
6번의 후속으로 선택지가 셋 있었다.
- 상류 저장소가 그 의존성을 걷어낸 최신 구현을 가져온다
- 실제 jar를 구해 온다
- 더미 jar를 만들어 컴파일만 통과시킨다
프로젝트에는 이미 3번을 준비한 흔적이 있었다. “더미 JAR를 동일한 경로로 주입”이라는 주석이 달린 Dockerfile과, 비어 있는 dummy_lib_target/ 디렉터리.
3번은 매력적이다. 즉시 되고, 빌드 로그가 초록색이고, jar 파일이 나온다.
그리고 이건 이 글의 나머지 전부와 정확히 같은 종류의 함정이다. 빌드는 성공했다고 말하는데, 그 기능은 런타임에 죽어 있다. 나중에 그 jar를 실제로 붙였을 때 원인을 찾기가 가장 어려운 형태다.
그래서 이건 자동으로 고르지 않고 결정을 올렸다. 침묵 장애를 여섯 개 파낸 밤에 일곱 번째를 직접 심을 수는 없다.
방어선: 층으로 나누고, 잃을 게 없는 층만 자동화한다
장애를 네 층으로 나눴다. 기준은 하나다. 재시작해도 잃을 것이 있는가.
| 층 | 증상 | 대응 |
|---|---|---|
| 1. 프로세스 사망 | 판은 살아 있음 | 래퍼 루프가 10초 뒤 재기동 (자동) |
| 2. tmux 세션 소실 | 판 자체가 없음 | launchd StartInterval로 부트 스크립트 재실행 (자동) |
| 3. MCP 서버만 사망 | 세션 멀쩡, 도구만 사라짐 | 감지·알림만. 사람이 재연결 |
| 4. 409 귀머거리 | 송신 O, 수신 X | 감지·알림만 |
1·2층은 자동으로 되살린다. 없어진 판에는 남은 컨텍스트가 없으니 재시작 비용이 0이다.
3·4층은 일부러 자동화하지 않았다. 그 복구는 세션 재시작을 뜻하고, 세션 재시작은 누적된 대화 컨텍스트를 통째로 날린다. 반면 사람이 재연결 명령 하나를 넣으면 컨텍스트를 지키면서 고칠 수 있다. 그렇다면 자동화의 올바른 범위는 “고쳐 주기”가 아니라 “알려 주기”에서 끝난다.
자동 복구가 항상 옳은 게 아니다. 복구 비용이 장애 비용보다 클 수 있다.
워치독 설계에서 신경 쓴 두 가지도 적어 둔다.
경보를 MCP 경로로 보내지 않는다. 보고하려는 장애가 바로 그 MCP의 죽음이다. 죽은 채널로 부고를 보낼 수는 없어서, Bot API를 직접 호출하고 살아 있는 토큰을 순서대로 찾아 전송한다.
단발 신호로 경보하지 않는다. pending_update_count가 한 번 0이 아닌 건 그냥 방금 메시지가 도착한 것뿐이다. 두 번 연속 유지되거나 증가할 때만 “아무도 소비하지 않는다”로 판정한다. 오경보를 내는 워치독은 몇 주 뒤에 무시당한다.
launchd 쪽은 RunAtLoad만 걸려 있어서 로그인·부팅 시점에만 돌고 있었다. 낮에 세션이 죽으면 재부팅 전까지 죽은 채로 남는다는 뜻이다. StartInterval을 추가했고, 부트 스크립트가 이미 멱등이라 주기 실행이 안전하다 (Apple: Creating Launch Daemons and Agents).
검증: 실제로 고장을 냈다
워치독을 짜 놓고 “정상 동작 시 조용하다”만 확인하는 건 아무것도 확인하지 않은 것과 같다. 경보가 안 울리는 상태와 경보 기능이 고장 난 상태는 겉보기에 똑같다.
그래서 두 가지를 실제로 했다.
- 전달 경로만 따로 실행하는
--selftest플래그를 넣고 메시지 도달 확인 - PID 파일을 가짜 값으로 바꿔 진짜 고장을 주입 → 경보 발송 확인 → 원복 → 복구 알림 발송 확인
2번 도중에 버그도 하나 잡혔다. zsh에서 이렇게 쓰면 set -u에 걸린다.
local key="$1" f="$STATE/alert-$key" # key: parameter not set
같은 local 문 안에서 앞의 변수를 참조할 수 없다. 그런데 이 코드는 경보를 보내는 경로에만 있었다. 정상 상태에서는 절대 실행되지 않는다. 고장을 주입하지 않았다면 이 버그는 진짜 장애가 났을 때 처음 실행되면서, 하필 그 순간 경보를 삼켰을 것이다.
정리
여섯 개를 늘어놓고 보면 패턴이 보인다.
| # | 침묵 장애 | 겉보기 |
|---|---|---|
| 1 | 설치 경합 | 세션 정상, 도구만 증발 |
| 2 | cwd 변경 | 완벽히 동작, 기억만 없음 |
| 3 | 409 | 송신 정상, 수신만 죽음 |
| 4 | 예약 변수 오염 | 스크립트 성공, 중복 기동 |
| 5 | 캐시 안 패치 | 지금은 동작, 업데이트 때 소멸 |
| 6 | Lombok 미동작 | 오류 391개가 엉뚱한 곳을 지목 |
세 가지를 남긴다.
“살아 있다”를 증명하는 신호를 의심하라. 프로세스 존재, 초록색 빌드, 예외 없음 — 전부 기능이 살아 있다는 증거가 아니다. 각 층마다 그 층 고유의 증거가 따로 필요하다.
틀린 전제를 먼저 검증하라. 6번은 “윈도우 경로만 고치면 된다”는 요청으로 들어왔다. 실제 블로커는 넷이었고 윈도우 경로는 그중 가장 사소했다. 시키는 것만 고치면 나머지 셋은 다음 사람 몫이 된다.
감지 장치는 반드시 고장을 주입해서 검증하라. 안 울리는 알람과 못 울리는 알람은 구분되지 않는다.
References
- Telegram, Bots FAQ — 롱폴링과 웹훅의 상호배타성. https://core.telegram.org/bots/faq
- Telegram, Bot API — getUpdates / getWebhookInfo. https://core.telegram.org/bots/api#getupdates
- Project Lombok, Changelog — v1.18.40에서 JDK25 지원 추가(2025-09-04), v1.18.38에서 JDK24 지원 추가. https://projectlombok.org/changelog
- grammY, Long Polling vs. Webhooks. https://grammy.dev/guide/deployment-types
- OpenBSD, tmux(1) manual page. https://man.openbsd.org/tmux
- Apple, Creating Launch Daemons and Agents —
RunAtLoad,StartInterval. https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html - Apache Maven, Maven Clean Plugin — Deleting Additional Files. https://maven.apache.org/plugins/maven-clean-plugin/examples/delete_additional_files.html
본문의 수치(메모리 41→3, 오류 391→19, JDK별 실패 양상, 409 재시도 8회)는 2026년 8월 2일 새벽 해당 환경에서 직접 측정한 값이며, 다른 버전 조합에서는 재현되지 않을 수 있다. 벤더 문서로 확인 가능한 사실과 자체 측정값은 위와 같이 구분해 표기했다.