sgkstudio.
엔지니어링

DeepSeek 402 오류가 스레드 새 글을 막은 이야기 — 폴백 호출에 검사가 없었다

DeepSeek 402 오류(잔액 부족) 문장이 정상 초안처럼 통과하면, 자동 글쓰기 파이프라인은 에러 하나 없이 조용히 멈춥니다. 저희 스레드 계정의 새 글이 9월 15일과 16일에 0건, 17일에 1건으로 멎었을 때 원인이 정확히 이 모양이었고, 폴백 호출에 종료 코드 검사가 없었습니다.

2026-10-03수리 커밋 설명(2026-09-17) — 원인 셋, 실측 시간 174.2초·172.8초·145.7초, 타임아웃 180초→420초, 35자 실패 83회, 이어달기 실패 84회, 폴백 오류문 통과 209회(9월 1일 이후)수리 커밋의 변경 통계 — 파일 3개, 206줄 추가·19줄 삭제(실제 수리 파일 67줄·13줄, 새 테스트 파일 145줄)수리 커밋의 검증 기록 — 새 테스트 12건 통과, 고치기 전 코드에서 9건 실패, collector 전체 438건 통과(기존 실패 2건은 master에도 있음)

스레드 새 글이 사흘 가까이 멎었다면 무엇이 문제였나

DeepSeek 402 오류(잔액 부족) 문장이 정상 초안처럼 통과하면, 자동 글쓰기 파이프라인은 에러 하나 없이 조용히 멈춥니다. 저희 스레드 계정의 새 글이 9월 15일과 16일에 0건, 17일에 1건으로 멎었을 때 원인이 정확히 이 모양이었고, 폴백 호출에 종료 코드 검사가 없었습니다.

스레드에 올릴 글을 AI로 쓰는 자동화는 겉으로 보면 단순합니다. 정해진 시각에 스크립트가 돌고, AI 모델에게 초안을 쓰게 하고, 통과한 초안을 올립니다. 이런 구조에서 가장 위험한 순간은 모델 호출이 실패했는데 스크립트가 그 사실을 모르는 때입니다. 실패가 «결과 없음»으로 끝나면 누군가 알아채지만, 실패가 «짧은 초안»의 모습으로 지나가면 아무도 알아채지 못합니다.

이번에 확인한 원인은 셋이었고, 앞의 둘은 한 줄로 이어집니다. 초안을 쓰는 데 시간이 오래 걸려 타임아웃에 걸리고, 그래서 폴백으로 넘어가고, 폴백이 돌려준 에러 문장이 초안으로 통과했습니다. 셋째는 별개입니다. 이미 올린 글에 이어서 답글을 다는 작업이 실패해도 횟수를 세지 않아서, 글 하나가 새 글을 전부 막았습니다.

  • 원인 하나: 폴백이 돌려준 API Error 402 Insufficient Balance(35자, 종료 코드 1)가 초안으로 통과했다
  • 원인 둘: 초안 한 번에 걸리는 시간이 174.2초·172.8초·145.7초인데 타임아웃이 180초였다
  • 원인 셋: 끊긴 글 이어달기가 실패해도 횟수를 세지 않아 글 하나가 48시간 동안 새 글을 막았다

처음 잰 숫자는 무엇이었나 — 새 글 0건, 0건, 1건

가장 먼저 본 숫자는 날짜별 새 글 수였습니다. 9월 15일에 0건, 16일에 0건, 17일에 1건이었습니다. 사흘 가까이 새 글이 거의 없었으니 이 숫자는 이상 신호였습니다.

날짜별 스레드 새 글 수
날짜새 글비고
9월 15일0건새 글 없음
9월 16일0건새 글 없음
9월 17일1건새 글 1건

출처: 수리 커밋 설명(2026-09-17)의 첫 문장. 날짜별 시각은 기록에 없습니다.

새 글이 없는 이유를 찾으러 로그를 열었을 때 눈에 들어온 줄은 «조각 1개 · 35자»였습니다. 여기서 조각은 스레드 글을 나눈 단위로 보입니다(추정: 로그 문구만으로 읽은 해석입니다). 그렇다면 초안이 조각 1개에 35자라는 뜻입니다. 이 줄만 보면 «짧지만 만들어진 초안»으로 읽힙니다. 같은 줄이 9월 1일 이후 209회 찍혀 있었습니다.

한 줄이 209번 찍혀 있었다는 사실이 이 사고의 핵심 증거입니다. 실패가 눈에 띄지 않은 이유는 로그가 조용해서가 아니라, 로그가 실패를 성공의 문장으로 적고 있었기 때문입니다. 로그를 보는 사람이 있어도 이 줄은 걸러내지 못합니다. 실행 횟수를 로그에서 세어 확인하는 방법은 작업이 실제로 몇 번 돌았는지 로그로 확인한 기록에서 다뤘습니다.

세 숫자 카드 — 폴백 오류문이 초안으로 통과한 횟수 209회(9월 1일 이후), 35자 실패 83회(전부 3회차), 이어달기 실패 84회(«답글 자리를 못 찾았다»)
세 원인이 남긴 흔적은 로그에서 서로 다른 숫자로 나타났다

처음에 세운 가설은 무엇이었나

이 수리 기록에는 처음 세운 가설이 따로 적혀 있지 않습니다. 기록에서 확인되는 것은 결과뿐이라, 이 칸은 기록에 있는 만큼만 씁니다. 가설을 지어서 채우면 이 글이 처음부터 정답을 알던 글처럼 읽히기 때문입니다.

기록에서 읽히는 출발점은 이렇습니다. 새 글이 멎었다면 AI 호출이 실패했을 가능성이 가장 먼저 떠오릅니다. 그런데 로그의 «조각 1개 · 35자»는 호출 실패의 모습이 아니라 짧은 초안의 모습이었습니다. 호출이 실패하면 글이 안 나온다는 전제로 보면, 글이 나왔으니 호출은 성공했다고 읽기 쉽습니다.

이렇게 갈라 보는 순서에는 이유가 있습니다. 자동화가 멎었을 때 가장 흔한 실수는 가장 눈에 띄는 원인 하나만 고치고 끝내 버리는 일입니다. 그러면 나머지 원인은 그대로 남습니다. 이번 사고는 원인이 셋이어서, 하나만 고치면 나머지 둘이 새 글을 계속 막았을 겁니다. 세 원인이 서로 다른 로그 문구로 나타났기 때문에 문구별로 따로 세어 보는 일이 길을 열어 줬습니다.

그래서 실패한 쪽이 어디인지부터 갈라야 했습니다. 모델 호출 자체가 실패했는지, 호출은 성공했는데 결과를 잘못 읽었는지, 아니면 호출 앞뒤 어딘가에서 멈췄는지입니다. 아래 세 원인은 이 순서로 하나씩 좁혀 나온 결과입니다.

가설이 틀린 지점은 어디였나 — 왜 오류 문장이 초안이 됐나

틀린 전제는 «글이 나왔으니 호출이 성공했다»였습니다. 폴백이 돌려준 것은 초안이 아니라 DeepSeek의 오류 응답이었고, 문장은 이랬습니다.

API Error: 402 Insufficient Balance

402는 결제가 필요하다는 뜻의 HTTP 오류 번호이고, Insufficient Balance는 잔액이 부족하다는 뜻입니다. DeepSeek 계정의 잔액이 바닥나자 폴백 호출이 이 문장을 돌려줬고, 스크립트는 이 문장을 사람이 쓴 짧은 초안처럼 받아들였습니다. 세어 보면 35자라서 «조각 1개 · 35자»가 로그에 찍혔습니다.

같은 저장소의 다른 스크립트가 이 사고에서 좋은 대조군이 됐습니다. 답글 초안기는 폴백 오류를 제대로 막고 있었습니다. 다만 막은 이유를 잡음 섞인 오류 출력(stderr)에서 뽑아 적었고, 그 결과 잔액 고갈이 «[claude-code:unrecognized_model]», 즉 모델 이름을 못 알아봤다는 이유로만 보였습니다. 그 표시만 믿었다면 모델 이름 설정을 의심하며 엉뚱한 곳을 뒤졌을 겁니다.

이 대조가 알려 주는 점은 두 가지입니다. 막기만 하고 이유를 틀리게 적으면 원인 추적이 길어지고, 아예 못 막으면 이번처럼 실패가 성공으로 둔갑합니다. 둘 다 같은 뿌리에서 나왔습니다. 오류 응답을 어디서, 어떤 순서로 읽는지가 정해져 있지 않았습니다.

진짜 원인 하나 — 폴백 호출에는 종료 코드 검사가 없었다

종료 코드는 프로그램이 끝날 때 성공인지 실패인지를 숫자로 남기는 값입니다. 보통 0이면 성공이고 그 밖의 숫자면 실패입니다. 이번에 폴백이 돌려준 오류 응답은 종료 코드 1이었으니, 코드를 확인했다면 그 자리에서 실패로 걸러졌습니다.

스크립트를 열어 보니 첫 번째 호출에는 종료 코드 검사가 있었고 폴백에는 없었습니다. 같은 모델 호출인데 한쪽 길에만 안전장치가 달려 있었던 셈입니다. 예비로 만든 길은 첫 길보다 덜 살펴보게 되고, 그래서 검사가 빠진 채로 남기 쉽습니다.

호출 흐름도 — 첫 번째 호출은 종료 코드 검사를 거치고, 폴백 호출은 검사 없이 35자 오류 문장이 초안으로 통과한다
검사가 있는 길과 없는 길이 갈린 곳이 사고 지점이다

정리하면 첫 호출은 실패하면 멈추고, 폴백은 실패해도 계속 갔습니다. 폴백이 존재하는 이유는 첫 호출이 실패해도 일을 계속하기 위해서인데, 이 구조에서는 그 폴백이 실패를 가리는 통로가 됐습니다. 이 때문에 9월 1일 이후 209회 동안 아무도 몰랐습니다.

진짜 원인 둘 — 타임아웃 180초는 초안 한 번보다 짧았나

폴백이 불린 이유도 따로 있었습니다. 첫 호출이 타임아웃에 걸려 폴백으로 떨어졌습니다. 초안 한 번에 걸리는 시간을 세 번 재 보니 174.2초, 172.8초, 145.7초였고, 타임아웃은 180초였습니다. 가장 오래 걸린 값이 174.2초이니 한도에 바짝 붙어 있었습니다.

막대 그래프 — 초안 한 번 실측 174.2초·172.8초·145.7초, 기존 타임아웃 180초, 새 타임아웃 420초
실측 세 값은 모두 기존 한도 가까이에 있었고 새 한도는 그 위로 올라갔다

그런데 매번 실패한 것은 아니었습니다. 35자 실패 83회는 전부 3회차에서 나왔습니다. 초안을 다시 쓸 때는 직전 지적이 붙는데, 그 지적이 붙는 3회차에서만 선을 넘어 폴백으로 떨어졌다는 뜻입니다.

여기서 지금 남아 있는 기록의 한계를 분명히 해 둡니다. 실측 세 값은 모두 180초 안쪽이어서, 선을 넘은 3회차가 실제로 몇 초 걸렸는지는 기록에 없습니다. 3회차가 오래 걸린 이유도 기록에 없습니다(추정: 직전 지적이 붙어 입력이 길어지는 것이 한 이유일 수 있으나 확인하지 못했습니다). 다만 «3회차에서만, 83회 전부»라는 쏠림이 원인이 시간 한도라는 점을 강하게 가리킵니다.

조치는 타임아웃을 180초에서 420초로 올리는 일이었습니다. 420초로 정한 계산 근거는 기록에 적혀 있지 않습니다. 실측 세 값과 비교하면 한도를 넉넉히 늘렸다는 점은 분명합니다.

진짜 원인 셋 — 이어달기 실패를 세지 않으면 어떻게 되나

셋째 원인은 시간이나 폴백과 관계가 없었습니다. 이미 올린 글에 이어서 다음 답글을 다는 작업, 저희가 «이어달기»라 부르는 작업이 실패해도 횟수를 세지 않았습니다. 같은 실패가 반복돼도 스크립트는 매번 처음부터 같은 이어달기를 시도했고, 그동안 새 글 쓰기 차례는 오지 않았습니다.

로그에는 «답글 자리를 못 찾았다»가 84회 남아 있었습니다. 글 하나의 이어달기가 계속 실패하면서 48시간 동안 새 글이 전부 막혔습니다.

고치는 방법은 단순했습니다. 이어달기가 실패하면 그 횟수를 세고, 같은 소재가 3회 연속 실패하면 그 소재를 놓아 주고 다음 새 글로 넘어갑니다. 횟수를 새로 만든 저장소에 적지 않고, 이미 있던 «막힌 소재» 목록을 재사용합니다.

이 구조는 자동화에서 흔한 함정입니다. 한 작업이 실패할 때마다 «다음에 다시 하면 되겠지»라고 넘기면, 영원히 실패하는 작업 하나가 뒤의 모든 작업을 막습니다. 재시도에는 반드시 상한이 있어야 하고, 상한에 닿으면 그 작업을 옆으로 치우고 다음 작업으로 가야 합니다. 이번 수리의 3회 연속이 바로 그 상한입니다.

세 번이라는 숫자가 왜 적당한지는 기록에 없습니다. 다만 84회 반복되던 실패를 3회에서 끊는 쪽이 48시간을 통째로 잃는 쪽보다 낫다는 점은 분명합니다.

어떻게 고쳤나 — 세 파일, 206줄

수리는 파일 3개를 건드렸습니다. 변경 통계는 206줄 추가, 19줄 삭제입니다. 글 만드는 스크립트에 67줄, 답글 초안기에 13줄, 새 테스트 파일에 145줄입니다. 수리 코드보다 테스트가 더 많다는 점이 이 수리의 성격을 보여 줍니다.

원인별 조치
원인증상조치
폴백에 종료 코드 검사 없음35자 오류 문장이 초안으로 통과(9월 1일 이후 209회)폴백 결과에도 첫 호출과 같은 종료 코드 검사를 적용
타임아웃 180초가 초안 한 번에 빠듯함3회차에서만 시간 초과, 35자 실패 83회타임아웃 180초를 420초로 상향
이어달기 실패를 세지 않음«답글 자리를 못 찾았다» 84회, 48시간 정지막힌 소재 목록을 재사용해 3회 연속 실패하면 소재를 놓음

원문 기록이 밝힌 조치를 표로 옮겼습니다. 첫 줄의 조치는 «1차 호출에는 검사가 있었고 폴백에는 없었다»는 원인 설명을 뒤집은 내용입니다.

답글 초안기에도 손을 댔습니다. 앞서 본 대로 이 스크립트는 폴백 오류를 제대로 막고 있었지만, 이유를 잡음 섞인 stderr에서 뽑았습니다. 오류 이유를 읽는 순서를 바꿔 표준 출력(stdout)을 먼저 보게 했습니다. 잔액 고갈이 모델 이름 문제로 보이던 현상을 없애려는 조치입니다.

이 수리에서 눈여겨볼 점은 고친 줄 수보다 고친 위치입니다. 폴백에 검사를 거는 일은 몇 줄이면 되지만, 그 몇 줄이 빠져 있던 동안 209번의 실패가 조용히 지나갔습니다. 반대로 타임아웃을 올리는 일은 숫자 하나를 바꾸는 일인데, 그 숫자가 83번의 폴백 호출을 불렀습니다. 작은 수정이 큰 사고를 막거나 부르는 자리는 대개 이런 경계 조건입니다.

답글 초안기 쪽은 사정이 조금 다릅니다. 이 스크립트는 막아야 할 오류를 막고 있었으니 동작은 옳았고, 틀린 곳은 사유를 적는 방식이었습니다. 사고가 났을 때 사람이 가장 먼저 읽는 줄이 사유 문구라서, 동작이 옳아도 문구가 엉뚱하면 원인 추적이 길어집니다. 그래서 동작을 건드리지 않고 사유를 읽는 순서만 고쳤습니다.

정리하면 세 원인은 서로 다른 층에 있었습니다. 호출 결과를 검사하는 층, 시간 한도를 정하는 층, 반복 실패를 세는 층입니다. 한 층을 고쳐도 나머지 둘은 그대로 남기 때문에 셋을 같은 수리 안에서 함께 고쳤습니다.

고친 뒤 무엇을 확인했나 — 재측정 결과

고친 뒤 확인은 테스트로 했습니다. 새 테스트 12건을 짜서 모두 통과시켰고, 같은 테스트를 고치기 전 코드에 돌리면 9건이 실패했습니다. 고치기 전에도 통과한 3건은 정상 동작을 확인하는 양성 표본입니다.

고치기 전 코드에서 9건이 실패한다는 사실이 이 검증의 요점입니다. 테스트가 수리 후에만 통과하면 그 테스트가 정말 문제를 잡는지 알 수 없습니다. 수리 전에 실패하고 수리 후에 통과해야 테스트가 이번 사고를 겨냥한다는 증거가 됩니다. 정상 동작을 확인하는 3건은 반대로 수리 전후 모두 통과해야 하는 대조군입니다.

검증 결과 카드 — 새 테스트 12건 통과, 고치기 전 코드에서 9건 실패, collector 전체 438건 통과
같은 테스트가 수리 전에는 실패하고 수리 후에는 통과했다

저장소 전체 테스트도 돌렸습니다. collector 전체 438건이 통과했고, 실패 2건이 남았습니다. 이 2건은 master 브랜치에서도 이미 실패하는 기존 건이라 이번 수리와 따로 봅니다.

테스트가 12건이라는 점도 짚어 둡니다. 원인이 셋이고 세 원인이 각각 여러 갈래로 나타날 수 있어, 테스트 한두 건으로는 세 곳의 빈틈을 다 덮지 못합니다. 12건 가운데 9건이 수리 전에 실패했다는 것은 새 테스트의 4분의 3이 이번 사고의 빈틈을 정확히 겨냥했다는 뜻입니다.

정직하게 적자면 여기까지가 코드와 테스트 수준의 재측정입니다. 고친 뒤 실제로 스레드 새 글이 며칠 연속 올라갔는지는 이 기록에 없습니다.

폴백이 있는 AI 자동화에서는 무엇부터 점검해야 하나

이 사고는 저희 스크립트에서 났지만, 모델 호출에 예비 길을 둔 자동화라면 어디서든 같은 모양으로 날 수 있습니다. 아래는 이번 기록에서 거꾸로 뽑은 점검 질문입니다. 일반적인 원칙으로 정리했으므로 저희 사례의 숫자와 섞어 읽지 않으시길 바랍니다.

  • 예비 길에도 첫 길과 같은 성공 판정이 걸려 있나 — 종료 코드, 응답 상태, 비어 있지 않은 결과를 같은 함수로 검사하면 한쪽만 빠지는 일이 줄어든다
  • 결과 길이가 평소 범위에서 크게 벗어나는가 — 이번에는 35자였고, 오류 문장은 대개 정상 초안보다 훨씬 짧다
  • 타임아웃이 실측 시간 가까이에 있나 — 실측 3회가 145.7초에서 174.2초였는데 한도가 180초였으니 조금만 느려져도 선을 넘는 구조였다
  • 같은 실패를 세고 있나 — 횟수를 세지 않으면 84회가 쌓여도 스크립트는 그 사실을 모른다
  • 로그 문구가 실패를 실패라고 적는가 — «조각 1개 · 35자»처럼 성공으로 읽히는 문장이 209번 찍히는 동안에는 사람도 못 알아챈다

첫째 질문이 가장 값쌉니다. 성공 판정을 한 곳에 두면 폴백을 몇 개 더 붙여도 같은 검사를 지납니다. 이번 수리에서 새 테스트 12건 가운데 9건이 고치기 전 코드에서 실패한 사실도, 이 검사가 폴백 쪽에 없었다는 사실을 테스트가 그대로 드러냈다는 뜻입니다.

둘째와 셋째 질문은 숫자를 직접 재 봐야 답이 나옵니다. 초안 한 번에 걸리는 시간을 세 번밖에 재지 못한 것은 이 기록의 약점이기도 합니다. 한도를 정하기 전에 더 많이 재면 선을 넘는 회차가 얼마나 되는지 알 수 있습니다. 이번에는 83회라는 실패 횟수가 대신 그 신호를 줬습니다.

마지막 두 질문은 운영 습관과 관련이 있습니다. 실패가 쌓여도 알림이 오지 않으면 자동화는 가장 조용한 방식으로 망가집니다. 이번에는 새 글 수가 0이 되고 나서야 문제가 눈에 띄었습니다. 새 글 수 같은 결과 지표와 실패 횟수 같은 과정 지표를 함께 보면 며칠 일찍 발견할 수 있습니다.

남는 한계는 무엇인가

이 수리로 풀린 것과 풀리지 않은 것을 나눠 적습니다.

  • 고친 뒤 새 글 발행 수가 실제로 회복됐는지는 이 기록에 없다. 확인한 것은 테스트 12건과 collector 전체 438건이다
  • 초안 시간 실측은 3회뿐이다. 선을 넘은 3회차의 시간은 따로 재지 못했고, 420초라는 값의 계산 근거도 적혀 있지 않다
  • 209회와 83회가 어떻게 다른 숫자인지 기록에 설명이 없다. 하나는 폴백 오류문이 통과한 횟수이고 다른 하나는 3회차 35자 실패 횟수로 적혀 있다
  • 남은 실패 2건은 이번 수리와 무관한 기존 건이다. 이 2건이 다른 문제를 가리는지는 확인하지 않았다
  • 폴백 결과를 검사하는 장치는 생겼지만, 폴백 모델의 잔액이 바닥나는 일 자체를 미리 알리는 경보가 이 수리에 들어갔는지는 기록에서 확인되지 않는다

이 한계를 숨기지 않고 적는 이유는 «고쳤다»와 «나아졌다»가 다른 말이기 때문입니다. 테스트는 고친 코드가 의도대로 움직인다는 사실만 보여 주고, 운영에서 새 글이 다시 나오는지는 며칠 지켜봐야 압니다. 이 글을 읽는 분이 같은 방식으로 수리 보고를 받으신다면, 테스트 통과 수와 운영 지표 회복을 따로 물어보시길 권합니다.

한 줄로 줄이면 이 사고의 교훈은 «실패를 성공의 문장으로 적는 로그는 로그가 아니다»입니다. 폴백이나 재시도 경로를 둔 자동화라면, 그 길에도 첫 길과 같은 검사가 걸려 있는지 먼저 열어 보시기 바랍니다.

저희는 AI 업무 자동화를 만들고, 만든 뒤 이런 사고를 같은 방식으로 추적해 고치는 일까지 합니다. 비슷한 자동화가 말없이 멈춰 있다는 의심이 있다면 상담에서 상황을 적어 보내 주세요.

작성: SGK 스튜디오 기술블로그 편집.

같은 방식으로 우리 업무를 재보고 싶다면

이 글에 쓴 계측·판정·자동화는 저희가 실제로 매일 돌리는 방식 그대로입니다. 어떤 작업을 자동화할 수 있고 어디를 사람이 잡아야 하는지, 현재 업무 흐름을 놓고 같이 짚어 드립니다.

새 글이 올라오면 알려 드립니다

이런 계측 기록을 새로 쓰면 메일로 한 통 보냅니다. 광고나 판촉은 보내지 않습니다.