0. 들어가며
로컬 LLM 위임 파이프라인을 구축하던 중, CLI 도구 하나가 아무 메시지도 없이 멈추는 증상을 만났습니다.
에러도, 스택트레이스도, 타임아웃 메시지도 없었습니다. 프로세스는 살아 있고, 표준출력은 0바이트이고, 외부에서 죽이지 않으면 영원히 그 상태였습니다.
원인을 찾는 데 반나절이 걸렸습니다. 그런데 그 반나절의 절반은 문제 자체가 아니라, 제가 잘못 내린 결론을 되돌리는 데 썼습니다. 이 글은 그 과정을 미화 없이 기록한 것입니다.
결론부터 말하면, 이 버그는 아직 완전히 해결되지 않았습니다. 대신 "확률적으로 실패하는 현상을 어떻게 검증해야 하는가" 에 대한 값비싼 교훈이 남았습니다.
1. 증상: 조용히 멈추는 실행
문제의 명령은 단순했습니다.
opencode run "한 단어로만 답해라: 1+1은?"정상일 때는 19초 만에 답이 돌아옵니다. 그런데 어느 순간부터 같은 명령이 응답하지 않았습니다.
| 관측 항목 | 값 |
|---|---|
| 프로세스 상태 | 살아 있음 (CPU 0%) |
| 표준출력 | 0 바이트 |
| 에러 메시지 | 없음 |
--log-level DEBUG 출력 |
없음 |
| 종료 조건 | 외부 타임아웃뿐 |
가장 곤란한 점은 에러가 없다는 것이었습니다. 실패가 실패처럼 보이지 않으면 디버깅은 시작조차 어렵습니다.
1.1 첫 번째 함정, 종료 코드는 거짓말을 한다
처음에는 셸에서 이렇게 확인했습니다.
timeout 60 opencode run "..." | tail -5
echo "종료코드: $?"$? 가 0 으로 나왔습니다. 성공한 줄 알았습니다.
틀렸습니다. 파이프라인에서 $? 는 마지막 명령(tail)의 종료 코드입니다. timeout 이 실제로 무엇을 반환했는지와 무관합니다. 이 실수를 저는 세 번 반복했습니다.
거기에 더해, 이 CLI는 표준출력을 완료 시점까지 버퍼링합니다. 파일로 리다이렉트해두면 실행 중에는 0바이트로 보이고, 끝나야 한꺼번에 쏟아집니다. 즉:
"0바이트"는 "실패"가 아니라 "아직 안 끝났다"일 수도 있습니다.
두 함정이 겹치면서, 저는 "느린 실행"과 "멈춘 실행"을 한동안 구분하지 못했습니다.
1.2 제대로 된 판별 기준 찾기
로그를 비교해보니 정상 실행과 정지 실행의 차이가 명확했습니다.
정상 실행의 로그 시퀀스:
init → created → event → loop → shell → init → booting → stream → loop → exiting loop → disposing instance정지 실행:
timestamp=... message="creating instance" directory=/tmp/repro
timestamp=... message=bootstrapping
timestamp=... message=loading path=~/.config/opencode/opencode.jsonc
timestamp=... message="all LSPs are disabled"
timestamp=... message="all formatters are disabled"
timestamp=... message=init
<이후 아무것도 없음>init 에서 정확히 멈추고, 세션 생성(created)에 도달하지 못합니다.
그래서 판별 기준을 종료 코드가 아니라 세션 생성 여부로 바꿨습니다.
grep -c 'message=created' ~/.local/share/opencode/log/opencode.log실행 전후의 이 값을 비교하면, 버퍼링이나 파이프에 속지 않고 "진짜 진행했는지"를 알 수 있습니다. 이 판별 기준을 세운 것이 이 조사에서 처음으로 제대로 한 일이었습니다.
2. 첫 번째 결론과 그것이 무너진 과정
당시 저는 이 CLI의 권한 설정을 바꾸는 작업을 하고 있었습니다. permission.bash 값을 allow 에서 ask 로 바꾸면 어떻게 되는지 확인하던 중이었습니다.
실험 결과는 이랬습니다.
| 실행 | 설정 | 결과 |
|---|---|---|
| 1회차 | bash: "ask" |
정지 |
| 2회차 | bash: "ask" |
정지 |
| 3회차 | bash: "allow" |
성공 |
저는 여기서 결론을 냈습니다.
"
bash: ask로 두면 헤드리스 실행이 승인 프롬프트에서 무한 대기한다. 비대화형 환경이라 응답할 주체가 없으니 당연하다."
논리적으로 그럴듯했습니다. 프롬프트를 띄우는 설정 → 응답할 사람이 없음 → 영원히 대기. 인과가 자연스럽게 이어집니다.
그리고 이 결론 위에 이후 작업 전체를 쌓았습니다. 샌드박스 격리가 필요한 이유, 에이전트별 권한을 분리해야 하는 이유, 그 우선순위까지 전부 이 전제에서 나왔습니다.
2.1 무너진 지점
한참 뒤에야 로그 전체를 시간순으로 다시 뒤졌습니다. 그리고 이걸 발견했습니다.
01:41:20 정지 ← 그날 첫 실행
02:56:29 정지
03:06:02 정지
10:14:54 정지 ← "bash: ask 때문"이라고 결론 낸 실행
10:16:07 정지 ← 같음
10:33:05 정지
10:37:16 정지정지는 그날 첫 실행부터 있었습니다. 설정을 바꾸기 훨씬 전부터요.
그날 전체 성공률을 세어보니 61회 중 27회, 44% 였습니다. 즉 저는 뒷면이 56% 나오는 동전을 세 번 던져놓고 "이 설정이 원인"이라고 단정한 것이었습니다.
2번 실패 + 1번 성공은, 기저 실패율이 56%인 환경에서는 아무것도 의미하지 않습니다. 그 정도 패턴은 우연히 나올 확률이 충분히 높습니다.
첫 번째 교훈: 기저 발생률을 모르면 어떤 관측도 증거가 되지 못한다.
3. 두 번째 결론과 그것이 무너진 과정
권한 값을 deny 로 바꿔서도 테스트했습니다. 역시 멈췄습니다. "역시 권한 설정이 문제구나" 싶었습니다.
그런데 대조군을 돌려봤습니다. 권한과 무관한, bash를 전혀 쓰지 않는 작업을 같은 에이전트에 던졌습니다.
역시 멈췄습니다.
권한 문제가 아니었습니다. 그 에이전트가 쓰는 모델이 18GB짜리라 콜드 로드가 느렸을 뿐이고, 타임아웃을 600초로 늘리자 정상 완료됐습니다.
두 번째 교훈: "조건 A에서 실패했다"를 주장하려면 "조건 A가 아닐 때는 성공한다"를 같이 보여야 한다.
4. 계층별로 배제해도 되는 게 하나도 없었다
원인을 특정하지 못한 채, 의심 가는 계층을 하나씩 제거해나갔습니다. 전부 실패했습니다.
| 가설 | 검증 방법 | 결과 |
|---|---|---|
| 사용자 설정 | 3단계 롤백 후 설정 파일 자체를 삭제 | 여전히 정지 |
| 프로젝트 설정 | 있는 경우/없는 경우 비교 | 양쪽 다 정지 |
| 설치본 손상 | 클린 재설치 (최신 버전) | 여전히 정지 |
| 플러그인 | --pure 로 비활성화 |
여전히 정지 |
| 로컬 DB | *.db, -shm, -wal 전부 삭제 후 재생성 |
여전히 정지 |
| OS 리소스 누수 | 머신 재부팅 | 여전히 정지 |
| 바이너리 무결성 | Mach-O 정상, Gatekeeper 격리 속성 없음 | 무관 |
| 백엔드 서버 | 직접 호출 시 1.7초 정상 응답 | 무관 |
| 디스크 / 메모리 | 69GiB 여유, 메모리 50% 여유 | 무관 |
재설치 도중 하나 배웠습니다. npm의 allowScripts 정책이 켜져 있으면 전역 설치 시 postinstall 스크립트가 조용히 건너뛰어집니다.
npm install -g <package>@latest
# → "1 package had install scripts blocked"
# → 설치는 "성공"했지만 바이너리가 제대로 배치되지 않음해결은 명시적 허용입니다.
npm install -g --allow-scripts=<package> <package>@latest이 도구는 autoupdate: true 가 기본값입니다. 즉 자가 업데이트가 이 정책 아래에서 돌면 설치가 깨진 채로 남을 수 있습니다. 이번 정지의 원인은 아니었지만(제대로 재설치한 뒤에도 증상이 남았습니다) 별도로 보고할 가치가 있는 문제였습니다.
5. 세 번째 결론과 그것이 무너진 과정
웹 조사로 유력한 가설을 하나 얻었습니다.
이 CLI는 시작할 때마다 모델 카탈로그 서버(
models.dev)와 패키지 레지스트리에 네트워크 요청을 보내는데, 런타임의fetch에 타임아웃이 없어서 응답이 늦으면 조용히 멈춘다.
관측과 잘 맞았습니다. 읽기 전용 명령(--version, agent list)이 항상 정상이었던 것도 그 경로를 타지 않아서라고 설명됩니다. 로컬 계층을 아무리 뒤져도 안 나온 이유도 자연스럽게 설명됩니다.
제안된 우회책은 OPENCODE_OFFLINE=1 환경변수였습니다. 6회 돌렸고 6회 모두 성공했습니다.
여기서 "가설 확정"이라고 보고할 뻔했습니다. 그런데 대조군을 같이 돌렸습니다.
대조군(환경변수 없음)도 3/3 성공했습니다. 소요 시간까지 19초로 동일했습니다.
증상 자체가 그 시간대에 재현되지 않고 있었던 겁니다. 환경변수의 효과가 아니라, 그냥 잘 되는 구간이었습니다.
세 번째 교훈: 대조군 없는 성공률은 아무 의미가 없다. 6/6이라는 숫자는 매력적이지만, 비교 대상이 없으면 그건 그냥 "그때 잘 됐다"는 기록일 뿐입니다.
6. 이번엔 눈으로 직접 관측했다
성공률 비교로는 판별이 안 되니, 네트워크 연결을 직접 들여다보기로 했습니다. 성공률은 조건에 따라 흔들리지만, "요청을 보내는가"는 그 자리에서 확인할 수 있습니다.
lsof -nP -a -p <pid> -i여기서도 함정이 하나 있었습니다. lsof 는 -p 와 -i 를 기본적으로 OR로 묶습니다. -a 를 붙여야 AND가 됩니다. 이걸 빼먹으면 시스템 전체의 무관한 연결이 쏟아집니다.
6.1 정지 중인 프로세스에서 잡힌 것
COMMAND PID USER FD TYPE NODE NAME
opencode. 14462 user 15u IPv4 TCP 192.168.x.x:50476->172.67.69.147:443 (ESTABLISHED)이 IP를 확인해보니:
$ dig +short models.dev
104.26.9.108
104.26.8.108
172.67.69.147 ← 일치models.dev 로의 연결이 30초 내내 ESTABLISHED 상태로 붙잡혀 있었고, 그동안 실행은 한 발짝도 진행하지 않았습니다.
같은 시각, 같은 머신에서 그 호스트는 멀쩡했습니다.
$ curl -s -o /dev/null -w "namelookup=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} total=%{time_total}\n" https://models.dev/
namelookup=0.001497 connect=0.140799 tls=0.287761 total=1.107086 # HTTP 200DNS 1.5ms, TLS 288ms, 전체 1.1초. 서버는 정상인데 이 프로세스만 그 연결 위에서 멈춰 있었습니다.
6.2 그런데 설명되지 않는 두 번째 양상
같은 정지라도 다른 모습이 있었습니다.
t=2 PID=14286 연결=[없음]
t=4 PID=14286 연결=[없음]
...
t=30 PID=14286 연결=[없음]소켓이 하나도 없습니다. models.dev 는 물론이고, 로컬 백엔드로 가는 연결조차 없습니다.
연결을 시도 중이라면 최소한 SYN_SENT 라도 보여야 합니다. 소켓 자체가 없다는 건 소켓 생성 이전 단계에서 막혔다는 뜻인데, 시스템 리졸버는 0.00초에 응답하니 DNS 지연으로도 설명되지 않습니다.
이 두 번째 양상은 지금도 설명하지 못합니다. 정지 경로가 최소 두 개라는 뜻일 수 있습니다.
관측에서 중요한 원칙 하나: 음성 결과는 "없음"이 아니라 "검출되지 않음"입니다. 2초 간격 샘플링으로는 짧게 열렸다 닫히는 연결을 놓칩니다. 그래서 저는 이 데이터를 "연결이 있었다"는 양성 신호로만 쓰고, 0을 부재의 근거로는 쓰지 않았습니다.
7. 제대로 된 검증: 40회 대조 실험
이제야 실험다운 실험을 설계했습니다. 이전 측정들의 결함을 전부 반영했습니다.
| 설계 항목 | 값 | 이유 |
|---|---|---|
| 표본 | 각 팔 20회, 총 40회 | 3~9회로는 44% 동전을 판별할 수 없음 |
| 배치 | 교대 (A↔B) | 실패율이 시간대별로 8~77%까지 요동침. 블록 배치하면 시간 효과와 처리 효과가 섞임 |
| 실행 간격 | 45초 고정 | 직전 분석에서 간격이 성공률과 상관을 보임 → 교란변수 통제 |
| 타임아웃 | 120초 | 성공이 19초이므로 6배 여유 |
| 판정 기준 | 세션 생성 여부 | 종료 코드는 버퍼링 때문에 신뢰 불가 |
그리고 결과를 보기 전에 판정 기준을 선언했습니다. 데이터를 본 뒤 기준을 정하면 자의적이 되니까요.
7.1 결과
OFFLINE 세션생성 20/20 | 완주 20/20 | models.dev 관측 4
CONTROL 세션생성 20/20 | 완주 20/20 | models.dev 관측 4
소요시간 전 구간 18~19초 (min 18, max 19)40회 중 정지가 한 번도 나오지 않았습니다.
두 팔 모두 20/20이므로 비교할 차이가 없습니다. 선언한 기준대로 결론은 "효과 없음"이 아니라 "판별 불가" 입니다. 측정하려던 현상이 나타나지 않았으니까요.
직전 1시간에는 6회 연속 정지였는데, 그 10분 뒤부터 42분간 40회 연속 성공했습니다. 그 사이 아무 설정도 바꾸지 않았습니다.
7.2 그래도 건진 것
models.dev 연결 관측이 양쪽 각각 4회로 완전히 동일했습니다.
환경변수가 그 요청을 억제한다면 한쪽이 0에 가까워야 합니다. 같다는 건 OPENCODE_OFFLINE=1 이 이 요청을 막지 못한다는 뜻입니다. 이건 성공률과 무관하게 성립하는 결론입니다.
7.3 타이밍이 이분법이라는 사실
40회 전부 18~19초, 편차 1초 이내였습니다. 정지 시에는 900초까지도 진행이 없었습니다.
이 현상은 "느려짐"이 아니라 명확한 이분법입니다. 19초에 끝나거나, 아예 진행하지 않거나. 중간이 없습니다. 점진적 성능 저하와는 다른 종류의 문제라는 뜻입니다.
8. 최종 상태: 해결되지 않았다
정직하게 정리하면 이렇습니다.
처음부터 지금까지 계속 간헐적으로 실패해 왔으며, 실패율이 시간대에 따라 8%~77% 사이로 크게 요동칩니다. 어떤 조치로도 해소된 적이 없고, 나아진 것처럼 보였던 구간들은 조치와 무관한 우연한 호전이었습니다.
시간대별 성공률은 이랬습니다.
| 시간대 (UTC) | 세션 생성 / 시도 |
|---|---|
| 01–09시 | 10/13 (77%) |
| 10시 | 4/9 (44%) |
| 11–12시 | 1/13 (8%) |
| 13시 | 0/4 (0%) |
| 14–15시 | 12/22 (55%) |
| 합계 | 27/61 (44%) |
조치와 결과를 나란히 놓으면 인과가 없다는 게 더 분명합니다.
| 조치 | 직후 실행 결과 |
|---|---|
| 설정 롤백 3단계 | 전부 정지 |
| 클린 재설치 | 정지 |
| 플러그인 비활성화 | 정지 |
| 머신 재부팅 | 정지 |
| DB 초기화 | 정지 |
| (조치 없음) | 40회 연속 성공 |
확보한 증거는 업스트림 이슈로 등록했습니다. 재현 절차, 환경 정보, 전 계층 배제 목록, 101회 표본 데이터, 네트워크 관측 증거, 그리고 재현율이 낮다는 사실까지 포함했습니다.
원인 추정은 "가설이지 결론이 아니다"라고 명시하고, 설명하지 못하는 두 번째 정지 양상도 그대로 남겼습니다. 모르는 것을 모른다고 적는 게 이슈의 신뢰도를 높입니다.
9. 마무리: 반나절에서 남은 것
기술적으로 확정한 것은 많지 않습니다. 원인은 여전히 미확정이고, 우회책도 없습니다.
대신 방법론에서 남은 게 있습니다.
1. 기저 발생률을 먼저 구하라.
44% 확률로 실패하는 환경에서 3회 관측으로 인과를 주장하면 안 됩니다. 저는 이걸 두 번 어겼고, 그 위에 이후 작업 설계를 쌓았습니다. 되돌리는 비용이 원래 조사보다 컸습니다.
2. 대조군은 선택이 아니다.
6/6 성공을 "확정"으로 보고할 뻔한 걸 대조군이 막았습니다. 대조군 없이 관측한 성공률은 조건의 효과가 아니라 그날 운을 측정한 것일 수 있습니다.
3. 판정 기준을 데이터보다 먼저 정하라.
결과를 보고 기준을 정하면 원하는 결론이 나옵니다. 저는 40회 실험 전에 "차이가 안 나오면 '효과 없음'이 아니라 '검출되지 않음'으로 쓴다"를 선언해뒀고, 실제로 그렇게 나왔을 때 결론을 왜곡하지 않을 수 있었습니다.
4. 측정 도구부터 의심하라.$? 가 파이프 뒤에서는 다른 명령의 값이라는 것, lsof 의 -p와 -i가 OR라는 것, 출력이 버퍼링되면 진행 중과 정지가 구분되지 않는다는 것. 이 셋 때문에 저는 잘못된 데이터로 몇 시간을 판단했습니다. 도구가 틀리면 그 위의 모든 분석이 틀립니다.
5. 음성 결과를 부재로 읽지 마라.
샘플링에서 안 잡힌 것은 없는 것이 아닙니다. 양성만 증거로 쓰고 음성은 판단 보류로 두는 편이 안전합니다.
"재현되지 않는 버그를 쫓을 때 가장 위험한 것은 버그가 아니라, 몇 번의 관측으로 인과를 확신하는 자신이다."
이 조사는 아직 끝나지 않았습니다. 7분 간격으로 계속 실행하며 실패 구간을 포착하는 장기 샘플러를 돌려두었고, 데이터가 쌓이면 업스트림에 후속으로 붙일 예정입니다.