도구 하나가 300초마다 멈췄다. 로그에는 매번 같은 문장이 찍혔다. "서비스 다운, 안전을 위해 차단." 용의자는 두 번 바뀌었다. 처음엔 명령줄 옵션(flag) 하나를 의심했고, 다음엔 특정 모델이 느리다고 판단했다. 둘 다 틀렸다. 진짜 멈춰 있던 자리는 화면 어디에도 안 보이는 하위 프로세스(subprocess)의 입력 대기열이었다.

확증 편향은 이럴 때 조용히 끼어든다. 그럴듯한 용의자 하나를 붙잡으면 그 뒤로는 그 가설에 들어맞는 증거만 눈에 들어온다. 반박하는 증거는 "예외겠지" 하며 넘긴다. 그렇게 한번 방향이 정해지면 되짚어보기가 점점 더 귀찮아진다.

이 사건은 여러 실패 유형을 정리한 글에 한 문단으로 요약돼 있다. 여기서는 그 문단을 오진이 이어진 순서대로 풀어 쓴다.

300초마다 멈추는 도구

문제가 생긴 도구는 서로 다른 회사의 AI 모델을 불러 코드나 계획을 교차 검증하는 서버였다. 한쪽이 작업물을 내놓으면 다른 쪽이 그걸 다시 확인하는 구조라, 검증 요청을 넣을 때마다 외부 명령줄 프로그램(CLI)을 하나 띄우고 그 결과를 기다린다. 정상이면 몇 초에서 십수 초 안에 응답이 돌아온다. 문제가 생긴 뒤로는 달랐다. 어떤 요청을 넣어도 정확히 300초를 채우고서야 "서비스 다운, 안전을 위해 차단(fail-closed)"이라는 메시지로 끝났다.

당장 급한 건 검증 자체가 아니라 그 뒤에 줄 서 있던 작업이었다. 검증이 안 끝나니 다음 단계로 넘어가지 못하는 작업들이 쌓였다. 급한 마음에 요청을 다시 넣어 봤지만 결과는 매번 똑같았다. 재현은 지긋지긋할 만큼 쉬웠다. 요청을 넣으면 5분 뒤 같은 실패가 돌아왔다. 재시작해도, 요청 내용을 바꿔도, 시간을 두고 다시 시도해도 결과는 한결같았다. 겉으로 보이는 건 늘 같은 실패 메시지 한 줄뿐이었다.

여러 용의자 카드를 순서대로 짚어가며 타임아웃 버그의 원인을 좁혀나가는 디버깅 과정을 표현한 일러스트
직접 생성한 오리지널 일러스트.

첫 번째 용의자: 권한 플래그

서버 코드를 훑다가 명령줄 옵션 하나가 눈에 걸렸다. --dangerously-skip-permissions. 이름부터 위험해 보였다. 마침 사용 중인 에이전트 실행 환경에는 위험한 명령을 자동으로 걸러내는 권한 분류기(permission classifier)가 있었다. 두 사실을 이어 붙이면 이야기가 술술 만들어졌다. 이 옵션이 분류기에 "위험한 에이전트 생성"으로 잡혀 하위 프로세스가 막히고, 그 막힘이 300초 뒤 타임아웃으로 나타난다는 가설이었다.

그럴듯했다. 옵션 이름이 실제로 위협적이었고 분류기도 실제로 존재했다. 옵션을 지우면 되겠다 싶어 해당 한 줄 수정을 실행 담당 레인에 맡겼다. 잠시 후 수정이 끝났다는 보고가 돌아왔다. 서버를 재시작하고 도구 연결도 다시 불러왔다. 그런데도 똑같이 300초를 채우고 죽었다. 가설이 맞다면 최소한 증상이 달라져야 했는데 아무것도 안 바뀌었다.

이상해서 별도 검증 절차로 파일을 직접 열어 봤다. 옵션은 그대로 남아 있었다. 실행 레인이 "완료했다"고 보고한 수정이 실제로는 반영되지 않은 상태였다. 보고와 실제 파일 사이에 이런 간극이 생길 수 있다는 걸 그 순간까지 당연하게 여기지 않고 있었다. 이런 실행 보고 불일치는 그 자체로 따로 다룰 만한 실패 유형이라 여러 실패 유형을 정리한 글로 넘겨 둔다. 여기서는 다른 실행 경로로 같은 수정을 다시 적용했다는 것만 적는다. 이번엔 파일을 재확인해 옵션이 실제로 빠진 걸 봤다. 도구를 다시 불렀다. 결과는 또 300초였다.

이 대목에서 잠깐 멈췄어야 했다. 가설이 한 번 부정된 게 아니라 두 번 부정된 셈이었다. 첫 재시작 때는 수정이 반영 안 된 상태에서 나온 300초라 가설이 살아 있다고 우길 여지가 있었지만, 실제로 옵션을 뺀 뒤에도 같은 결과가 나온 순간엔 여지가 없었다. 그런데도 바로 다음 용의자로 넘어가느라 "왜 이 가설을 처음부터 그렇게 신뢰했는가"는 따로 되짚지 않았다. 그 되짚기를 건너뛴 대가는 두 번째 오진에서 그대로 반복됐다.

가설은 완전히 틀렸다. 권한 분류기가 실제로 관장하는 범위를 나중에 확인해 보니, 그 옵션이 붙는 하위 프로세스 자체가 애초에 분류기의 심사 대상이 아니었다. 분류기는 에이전트 실행 환경이 직접 띄우는 명령을 감시한다. 검증 서버가 자기 안에서 파이썬 코드로 또 다른 프로세스를 띄우는 경로는 그 감시망 바깥에 있었다. 옵션이 위험해 보인다는 인상 하나로 관장 범위를 확인하지 않고 결론부터 낸 게 실수였다.

두 번째 용의자: 느린 모델

다음 단서는 에러 로그 안에 있었다. 실패한 요청의 로그마다 Gemini 3.1 Pro (High)라는 모델명이 찍혀 있었다. 이번에도 이야기는 자연스럽게 이어졌다. 이 모델 등급 자체가 느리거나 그날따라 응답이 막혀 있어서 300초를 다 써버린다는 가설이었다. 로그에 이름이 반복해서 나온다는 사실이 확신을 더 굳혔다.

이 등급만 따로 떼어 핑을 보내 봤다. 결과가 뜻밖이었다. Gemini 3.1 Pro (High)는 CLI로 직접 부르면 약 10.8초 만에 정상 응답했다. 오히려 죽어 있던 쪽은 Gemini 3.5 Flash의 Low·Medium 등급이었다. 이 등급으로 보낸 요청만 응답 대기 시간 초과로 떨어졌다. 로그에 자주 보이던 이름은 범인이 아니라 그저 가장 자주 호출된 등급이었을 뿐이다. 눈에 띄는 빈도와 실제 잘못은 별개였다.

더 까다로운 건 벤더 쪽 가용성 자체가 흔들리고 있었다는 점이다. 이전 세션에서는 Flash Low가 성공하고 Pro가 실패했는데, 다음 세션에서는 반대로 Pro가 성공하고 Flash가 실패했다. 두 관측을 나란히 놓고 보면 결론은 뻔했다. 어느 쪽도 항상 느리거나 항상 빠른 게 아니라 시점마다 상태가 뒤집히고 있었다. 한 시점 핑 하나로 "이 등급은 느리다"고 단정하면 다음 세션에 그 결론이 통째로 뒤집힐 수 있었는데, 그 흔들림을 정적인 사실처럼 취급한 게 두 번째 오진이었다.

같은 벤더 안에서도 CLI로 직접 부르는 경로와 검증 서버를 거쳐 부르는 경로는 달랐다. 두 경로를 같은 시각에 나란히 찔러 보지 않고 서버 쪽 실패만 보고 판단했으니, 실패가 모델 자체의 문제인지 서버가 그 모델을 부르는 방식의 문제인지 구분할 근거가 애초에 없었다. 로그에 남은 이름 하나로 결론을 냈던 자리에서, 정작 필요했던 건 같은 시각 두 경로를 나란히 두고 비교하는 작업이었다.

300초 타임아웃 증상에서 갈라진 권한 플래그·모델 티어·표준 입력 세 가설과 각각의 확인 방법, 실제 원인 표시를 정리한 다이어그램
직접 생성한 오리지널 다이어그램.

진짜 원인은 입력을 기다리고 있었다

두 가설이 다 무너지고 나서야 증상 자체를 다시 봤다. 300초는 답이 느려서 걸리는 시간이 아니라 어딘가에 미리 정해 둔 상한이었다. 답이 늦게라도 오는 상황이라면 이 숫자가 이렇게 매번 정확히 채워질 이유가 없었다. 뭔가가 응답을 기다리는 게 아니라 응답 자체가 생성되지 못하고 있다는 뜻이었다.

검증 서버 코드에서 하위 프로세스를 띄우는 부분을 다시 읽었다. 문제는 표준 입력(stdin)에 있었다. 하위 프로세스를 실행할 때 표준 입력을 명시적으로 막아 두지 않으면, 그 프로세스는 부모 프로세스의 표준 입력을 그대로 물려받는다. 물려받은 입력 통로에는 아무도 아무것도 넣어 주지 않았다. 프로세스는 누군가 타이핑해 주기를 기다리는 자세로 가만히 앉아 있었다. 아무도 오지 않으니 끝까지 기다렸고 300초 상한에 걸려서야 강제로 끊겼다.

비유하면 이런 상황이다. 전화를 걸었는데 상대가 안 받는 게 아니라, 상대는 전화를 받았는데 내가 먼저 말을 걸어 주길 기다리며 가만히 듣고만 있다. 나는 상대가 답을 준비하고 있다고 착각하고 기다린다. 둘 다 아무 말 없이 5분을 흘려보낸 뒤에야 통화가 끊긴다. 느려서 못 받은 게 아니라 애초에 순서가 어긋나 있었다.

고치는 코드 자체는 한 줄이었다. 하위 프로세스를 띄울 때 표준 입력을 stdin=subprocess.DEVNULL로 넘겨 "이 프로세스는 입력을 받을 일이 없다"고 못 박으면 됐다. 고친 뒤 같은 요청을 다시 넣어 봤다. 몇 초 만에 정상 응답이 돌아왔고 그 뒤로 같은 증상은 재발하지 않았다. 두 번의 오진을 거치는 동안 정작 한 번도 들여다보지 않은 자리가 바로 여기였다. 플래그도 모델 등급도 눈에 잘 띄는 용의자였지만, 프로세스가 실제로 무엇을 기다리고 있는지는 프로세스 자체를 들여다봐야만 보인다. 로그에 안 남는 대기 상태는 로그만 뒤져서는 안 잡힌다.

권한 분류기가 심사하는 계층과 검증 서버가 자체적으로 띄우는 하위 프로세스 계층을 나누어 표준 입력 대기로 멈춘 지점을 표시한 프로세스 계층도
직접 생성한 오리지널 다이어그램.

확증 편향을 끊는 법

왜 두 번이나 헛다리를 짚었을까. 눈에 익은 용의자부터 의심했기 때문이다. 위험해 보이는 이름의 옵션, 로그에 반복해서 찍히는 모델명 모두 "이거다" 싶게 만드는 힘이 있었다. 그 인상이 강할수록 반증을 찾기보다 그 가설에 맞는 설명을 더 만들고 싶어진다. 재시작해도 안 바뀌었을 때 "혹시 캐시가 남았나" 하며 가설을 지키는 쪽으로 생각이 흘렀던 순간이 실제로 있었다.

두 오진에는 공통된 결함이 하나 있었다. 증상과 원인의 층위를 구분하지 않았다. 300초 타임아웃은 원인이 아니라 결과였다. 그 결과를 만들 수 있는 경로는 여러 개였고, 눈에 띄는 용의자 하나를 그 경로 전체와 같다고 착각한 게 문제였다. 그 착각을 반복하지 않으려면 접근 순서 자체를 바꿔야 했다.

순서를 바꾸고 나니 또 하나 눈에 들어왔다. 두 오진 모두 "고치는 행동"이 "확인하는 행동"보다 먼저였다. 플래그를 지우고, 등급을 바꿔 부르고, 그 다음에야 결과를 지켜봤다. 반대 순서였어야 했다는 게 그제서야 보였다. 고치고 나서 결과로 가설을 검증하는 방식은 결과가 안 바뀌어도 "아직 부족했나 보다"며 가설을 놓지 않게 만든다.

  • 증상을 만들 수 있는 후보 경로부터 모두 나열하고, 그중 어디가 실제로 막혀 있는지 하나씩 격리해 확인한다.
  • 각 계층이 실제로 무엇을 관장하는지 먼저 확인하고, 그 확인이 가설을 뒷받침할 때만 고친다 — "고치는 행동"보다 "확인하는 행동"을 먼저 둔다.
  • 같은 시각에 두 경로를 나란히 찔러 보는 대조 핑으로, 실제로 막혀 있는 대상을 격리한다.

돌아보면 이번 사건은 코드 한 줄이 문제였는데도 거기 도달하기까지 두 번을 돌아갔다. 걸린 시간의 대부분은 코드를 고치는 시간이 아니라 잘못된 확신을 검증하고 폐기하는 시간이었다. 다음으로 할 일은 이런 대기 상태가 생길 때 프로세스 트리와 입출력 상태를 자동으로 남겨 두는 절차를 만드는 것이다. 그러면 다음 타임아웃에서는 익숙한 용의자부터 의심하는 대신 남은 기록부터 먼저 열어 보게 될 것이다.