'남이 갱신한다'고 적어 둔 OAuth 토큰, 그 남이 없어서 답변이 26시간 죽었습니다
2026년 9월 14일 11시 31분, 사내 제안서 지식검색 도구가 답변 생성에 쓰는 구독 액세스 토큰이 만료됐습니다. 마지막 정상 답변은 10시 36분이었습니다. 알림은 어디에도 없었습니다. 검색은 멀쩡했고, 채팅과 메신저 답변만 죽어 있었습니다.
복구까지 26시간이 걸렸습니다. 토큰 자체는 로그인 명령 한 번이면 되는 일이었습니다. 시간을 잡아먹은 것은 '누가 이 토큰을 갱신하나'라는 질문에 아무도 답을 갖고 있지 않았다는 점이었습니다.
같은 원인이 창구마다 다른 얼굴로 나타났습니다
고장은 하나였는데 창구마다 다르게 보였습니다. 그래서 한 고장으로 안 읽혔습니다.
창구 | 보인 모습 |
|---|---|
웹 채팅 | 「응답이 일찍 끝났다」는 스트림 끊김. 200 헤더를 보낸 뒤 끊겨서 에러로 안 잡힙니다 |
메신저 | 에이전트가 500 을 받고 제 말로 지어낸 설명. 「두 번 모두 실패했습니다」 |
검색 | 멀쩡. LLM 을 안 쓰는 경로입니다 |
세 창구 어디에도 '재인증이 필요하다'는 말이 없었습니다. '반은 되니 큰 문제는 아니겠지'로 읽히는 상태였습니다. 반이 된다는 것은 안심 신호가 아니라 진단 단서입니다. 되는 반이 무엇을 안 쓰는지 보면 원인이 좁혀집니다. 검색만 LLM 을 안 씁니다.

배포 탓인 줄 알았습니다
같은 날 13시 02분에 권한 변경 배포가 있었습니다. '답이 안 온다'는 말이 그 뒤에 들어왔으니 의심이 먼저 배포로 갔습니다. 권한 문제면 로그에 '막힘(403)'이 남습니다. 실제 로그는 '엔진이 답을 못 줬다: 요청 실패(500)'였습니다. 상태 코드 하나가 '배포 탓'과 '별건'을 1분에 갈라 줬습니다.
인증 파일의 만료 시각을 읽으니 이미 지나 있었습니다. 전송 모듈을 열어 보니 주석이 하나 있었습니다.
구독을 에이전트·CLI 와 공유하므로 우리가 refresh 하지 않는다
배경은 이렇습니다. 이 도구는 답변 생성에 ChatGPT 구독을 코덱스 CLI 의 OAuth 토큰으로 REST 로 씁니다. 같은 서버에 개인 비서 에이전트 둘(메신저 봇용·개인용)이 같은 구독으로 돕니다. 그래서 '공유 자원이니 남이 갱신한다'고 적어 두고 토큰 갱신을 남에게 맡겼습니다.
공식 CLI 에 위임하면 토큰 갱신까지 넘어간다는 것이 그 구성의 출발점이었습니다. 의존은 사라지지 않고 자리를 옮겼는데, 옮겨 간 자리에 누가 있는지는 확인하지 않았습니다.
그 '남'은 없었습니다
실측했습니다. 에이전트 둘은 각자 자기 인증 파일을 씁니다. 우리 인증 파일은 아무도 안 건드립니다. 우리 파일을 갱신할 수 있는 경로는 사람이 CLI 로그인 명령을 칠 때뿐이었습니다. 액세스 토큰 수명은 10일입니다. 이 구조는 열흘마다 사람이 로그인하지 않으면 죽게 돼 있었습니다.
주석은 전제를 적을 뿐 전제를 검증하지 않습니다. 저 문장은 쓰인 날 이후 한 번도 실측되지 않았습니다. '남이 갱신한다'에 그 남의 이름이 안 붙어 있으면 아무도 안 하는 것입니다.
설계가 틀린 전제 하나 위에 서 있는 모양은 전에도 겪었습니다. 그때는 계층을 걷어냈고, 이번엔 주석을 걷어냈습니다.
복구는 사람이 로그인 명령을 쳤습니다. CLI 가 PATH 에 없어 설치 위치부터 찾아야 했습니다. 새 토큰의 만료는 9월 25일입니다. 엔진에 실제 질문을 넣어 답변 696자·근거 3건이 나오는 것까지 확인했습니다. 만료로부터 26시간, 복구 작업 자체는 60분이었습니다.
같은 날 120분으로 갱신 책임에 이름을 붙였습니다
재발 방지는 세 덩어리입니다. 갱신 모듈, 지킴이, 답변 경로입니다.
갱신 모듈은 인증 파일의 만료 시각을 직접 읽고 OAuth 로 갱신해 원자적으로 저장합니다. 토큰 갱신 주소와 클라이언트 ID 는 CLI 바이너리에서 문자열로 뽑았습니다. CLI 의 상태 명령은 '로그인됨'만 주고 남은 수명을 안 알려줍니다
refresh 토큰은 회전합니다. 실측으로 확인했습니다. 새것을 안 저장하면 다음 갱신이 막힙니다
갱신에 실패하면 파일을 건드리지 않습니다. 실패했는데 파일이 바뀌면 멀쩡한 토큰까지 잃습니다
지킴이는 매일 04:00 에 남은 수명을 읽고, 3일 아래일 때만 갱신합니다
답변 경로는 인증 만료 예외를 한도 초과와 같은 분기에 태웠습니다. 폴백 모델로 답하고, 그것도 못 하면 이유 코드와 근거를 줍니다
CLI 가 남은 수명을 안 준다고 '알 수 없다'로 결론짓지 않았습니다. 파일에 만료 시각이 있으니 거기서 읽습니다. '로그인됨'은 상태이지 수명이 아닙니다.
갈린 자리 둘 — 지금 되는 쪽이 아니라 제공자가 정한 쪽
설계에서 갈린 자리가 둘 있었습니다. 둘 다 지금 통과하는 안을 버리고 제공자가 정한 창을 따르는 안을 골랐습니다.
자리 | 버린 안 | 고른 안 | 이유 |
|---|---|---|---|
갱신 주기 | 매일 갱신 | 만료 3일 전에만 | 서버 응답이 「가장 이른 갱신 시각」으로 만료 하루 전쯤을 가리킵니다. 지금은 강제되지 않지만(2분 간격 두 번 갱신에 둘 다 200), 강제되는 날 매일 치는 쪽이 먼저 깨집니다 |
갱신 범위 | 세 통 다 갱신 | 우리 통만 갱신, 남의 통은 감시 | refresh 토큰이 회전합니다. 에이전트가 제 갱신을 하는 순간 서로의 토큰을 무효로 만듭니다. 원래 주석이 걱정하던 그 사고입니다 |
'본다'와 '갱신한다'를 나눴습니다. 지킴이는 세 통을 다 읽습니다. 9월 15일 실측으로 우리 엔진 10.0일, 메신저 봇 에이전트 3.1일, 개인 에이전트 9.2일이 남아 있었습니다. 갱신은 우리 통만 합니다. 남의 통은 만료가 가까우면 알리기만 합니다.
한 통이 터져도 나머지는 봅니다. '없는 것'과 '못 읽는 것'도 갈랐습니다. 개발 장비엔 에이전트 통이 없고, 그건 고장이 아닙니다.

인증 만료를 한도 초과 옆에 앉혔습니다
전에는 인증 만료 예외가 답변 경로의 분기에 없었습니다. 분기에 없는 예외는 그대로 500 으로 샙니다. 500 은 창구마다 다른 얼굴로 나타납니다. 스트림 끊김, 에이전트의 지어낸 말, 그리고 침묵입니다. 이제 인증 만료는 한도 초과와 같은 '이유 있는 실패' 채널로 갑니다. 폴백 모델로 답하되 이유 코드를 붙입니다. 창구마다 다른 얼굴이던 고장이 한 얼굴이 됐습니다.
폴백은 고장을 덮을 위험이 있습니다. 답이 나오면 아무도 안 봅니다. 이유 코드를 같이 주는 것이 그 보완입니다.

검증은 세 겹으로 했습니다
테스트 779건이 통과했습니다. 새로 넣은 테스트는 고치기 전 코드에서 3개가 갈리는 것까지 확인했습니다. 운영 서버의 실물 세 통에 대고 경로와 구조를 직접 확인했습니다. 배포 뒤 로그에 지킴이 기동이 남았습니다. 재발 방지 작업은 120분이었습니다.
한 구독을 여러 프로세스가 나눠 쓴다면
같은 자리에 있는 운영자라면 아래 다섯을 봅니다.
'남이 갱신한다'는 문장에 그 남의 이름이 붙어 있는가. 이름이 없으면 아무도 안 하는 것입니다
갱신 책임이 하나에만 있는가. 회전하는 자격증명은 갱신 주체가 둘이면 서로를 깨뜨립니다
남은 수명을 숫자로 읽고 있는가. '로그인됨'은 수명이 아닙니다
인증 만료가 한도 초과와 같은 분기에 있는가. 분기에 없는 예외는 500 으로 새고, 500 은 창구마다 다른 얼굴이 됩니다
갱신 실패가 정상 상태를 파괴하지 않는가. 실패하면 파일을 안 건드립니다
마무리
이 구조는 한 구독을 셋이 써서 생긴 제약입니다. 세 통이 각자 다른 계정이면 갱신 주체를 하나로 두는 규칙이 필요 없습니다. 제공자가 공식 refresh API 와 문서를 주면 바이너리에서 문자열을 뽑을 이유도 없고, 그 방법은 CLI 판이 바뀌면 깨질 수 있습니다.
그래도 하나는 어디서나 같습니다. 공유 자격증명은 '로그인됨'이 아니라 '남은 수명 며칠'이라는 숫자로 매일 읽습니다. 그리고 그 숫자를 움직일 주체의 이름이 코드 안에 하나 있는지 봅니다. 이름 없는 갱신은 만료일에 조용히 죽습니다.
연작 '토큰은 어디로 갔나'
이 글은 다섯 편 중 4편입니다. 구독 LLM 을 운영하며 한도·토큰·인증이 어디서 새고 어디서 안 보였는지를 이어서 다룹니다.