Gmail 자동화 봇이 열흘 만에 조용히 죽었다 — 알면서도 지나친 함정들
열흘 전, 모니터링 메일을 AI 장애 보고서로 바꾸는 자동화를 만든 이야기를 썼다. 잘 돌아갔다. 그리고 열흘 뒤, 그 봇은 죽어 있었다.
더 나쁜 건 죽은 걸 몰랐다는 것이다. 서비스는 멀쩡히 "실행 중"이었고, 프로세스도 살아 있었다. 다만 보고서 메일이 안 오고 있었을 뿐이다. 로그를 열어보니 이 한 줄이 반복되고 있었다.
google.auth.exceptions.RefreshError: ('invalid_grant: Token has been expired or revoked.')범인을 찾고 나서 한동안 말이 없었다. 원인은 내가 열흘 전 그 글에 직접 써놓은 문장이었다.
내가 써놓고 내가 지나친 문장
이전 글의 구축 절차에는 이런 대목이 있다.
"…테스트 사용자에 추가해야 인증 차단을 피할 수 있습니다."
맞는 말이다. Google Cloud에서 OAuth 동의 화면을 만들면 앱은 기본적으로 "테스트" 상태이고, 본인 계정을 테스트 사용자로 넣어야 인증이 통과된다. 나는 그걸 알고 있었고, 그대로 했고, 글에까지 적었다.
그런데 한 번도 묻지 않은 질문이 있었다. "테스트 모드로 두면, 나중에 무슨 대가를 치르지?"
답은 이렇다. 테스트 상태의 앱이 발급한 리프레시 토큰은 7일 뒤 만료된다. 그게 구글의 정책이다. 즉 내 봇은 만들어진 순간부터 7일짜리 시한폭탄을 안고 있었다. 나는 그 폭탄을 직접 설치하고, 설치 방법까지 글로 남기고, 타이머가 돌고 있다는 사실만 몰랐다.
해결은 허무할 만큼 간단했다. Google Auth Platform에서 앱을 "프로덕션"으로 게시하고 토큰을 재발급하면 끝이다. 그 뒤로는 만료되지 않는다. 5분이면 되는 일 때문에 자동화가 열흘간 죽어 있었다.
이게 이 글의 진짜 주제다
이번 사고를 정리하면서 깨달은 게 있다. 나를 물어뜯은 것들은 하나같이 "몰랐던 것"이 아니었다. 알고는 있었는데, 그 앎이 딱 한 걸음 부족했거나, 알면서도 그냥 지나친 것들이었다.
같은 날 발견한 나머지 실수들도 정확히 같은 모양이었다.
실수 1. 알고 썼는데, 그 다음을 안 물었다
위의 테스트 모드가 그렇다. "테스트 사용자 추가"라는 절차는 알았지만, "테스트 모드"라는 상태가 가진 비용은 생각하지 않았다. 설정 화면에서 뭔가를 선택했다면, 그건 선택하지 않은 쪽과 무엇이 다른지를 알아야 한다는 뜻인데도 말이다.
구축할 때는 "인증이 통과되는가"만 보였다. 통과되는 순간 그 항목은 머릿속에서 끝난 일이 됐다. 자동화의 문제는 구축 시점에 잘 돌아가는 것과 두 달 뒤에도 돌아가는 것이 완전히 다른 문제라는 데 있다.
실수 2. 안다고 생각했는데, 틀렸다
토큰을 재발급하면서 두 번째 봇이 문제가 됐다. 이 봇은 메일을 읽기만 하면 되고(gmail.modify), 저 봇은 보고서를 보내야 한다(gmail.send). 그래서 나는 이렇게 생각했다. "구글 콘솔에서 이 토큰에 send 권한만 추가하면 되지 않나?"
안 된다. 그리고 이건 꽤 많은 사람이 오해하는 지점이다.
- 콘솔의 scope 목록은 "이 앱이 요청할 수 있는 권한"을 선언하는 곳이다.
- 토큰에 실제로 부여된 권한은 사용자가 브라우저에서 동의를 누르는 순간 그 토큰에 고정된다.
즉 modify로 동의받은 토큰은 영원히 modify 전용이다. 나중에 콘솔의 어떤 버튼으로도 send를 붙일 수 없다. 실제로 시도해봤다. modify 토큰에 send를 얹어 갱신을 요청하니 구글이 invalid_scope로 거부했다. 권한을 넓히려면 그 scope를 포함해 다시 동의받는 방법뿐이다. 우회로는 없고, 그게 보안 설계다.
덕분에 하나 배웠다. 같은 계정·같은 클라이언트를 쓰는 봇이 여러 개라면, 필요한 scope를 모두 포함한 토큰 하나를 발급해 공용으로 쓰면 다음에 재인증할 일이 생겨도 한 번에 끝난다. 실제로 그렇게 통합했다.
실수 3. API가 주는 안전장치를 안 썼다
같은 날, 다른 봇에서 이런 에러가 났다. json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 4764
코드를 열어보니 원인이 있었다. 프롬프트로 "JSON 형식으로 답해줘"라고 부탁만 하고 있었다. 모델이 잘 지켜주길 기대하는 구조였다. 비싼 모델은 우연히 잘 지켰고, 저렴한 모델은 긴 문자열 중간에서 형식을 깨뜨렸다.
그런데 OpenAI Responses API에는 json_schema로 응답 형식을 강제하는 기능이 이미 있다. strict: true로 스키마를 주면 모델이 그 형태를 벗어날 수 없다. 몰랐던 기능이 아니다. 그냥 안 썼다. "지금 잘 되고 있으니까."
그 결과는 단순한 버그가 아니었다. 형식을 강제하지 않은 탓에 "형식을 잘 지키는 비싼 모델"에만 묶여 있었다. 안전장치 하나를 생략한 대가가 모델 선택권이었던 셈이다.
실수 4. 실패했을 때 어떻게 되는지를 설계하지 않았다
이게 제일 아찔했다. 그날 API 크레딧이 바닥났는데, 코드를 다시 보니 이런 구조였다.
- 생성에 실패하면 그 메일을
error라벨로 옮기고 처리 기록에 남긴다. - 그런데 "이미 처리했나?"를 판단하는 함수가 성공/실패를 구분하지 않고 "기록이 있으면 True"를 돌려준다.
- 결과: 한 번 실패한 메일은 영원히 재시도되지 않는다.
즉 크레딧이 없는 동안 도착한 메일은 조용히 영구 손실된다. 봇은 30분마다 멀쩡히 돌면서 메일을 하나씩 버리고 있었을 것이다. 다행히 알아채고 타이머부터 멈춰서 손실은 0건이었다.
성공 경로만 생각하고 짠 코드였다. 실패를 "예외 처리했다"고 착각했지만, 실제로 한 일은 실패를 영구화한 것이었다.
실수 5. 도구를 켜면서 상한을 안 걸었다
글 품질을 올리려고 AI에게 웹검색 도구를 붙였다. 효과는 확실했다. 그리고 하루에 5달러가 사라졌다. 직전 2주 동안 쓴 돈이 전부 합쳐 0.12달러였는데.
"상위 5~8개를 조사하라"고 시켰더니 모델은 글 한 편에 13회 검색했다. 검색은 호출료가 붙고, 검색해온 내용이 다음 요청의 입력 토큰으로 다시 과금된다. 편당 입력이 3천 토큰에서 6만9천 토큰으로 뛰었다. 이중 과금이라는 사실을 몰랐던 게 아니다. 도구를 켜면서 "몇 번까지"를 정하지 않았을 뿐이다. (이 이야기는 모델 3개를 비교한 실험에 자세히 적었다.)
공통점
다섯 개를 늘어놓고 보니 하나로 모인다. 전부 "몰라서"가 아니었다.
- 테스트 모드 — 알고 설정했고 글에도 썼다. 다만 그 다음을 안 물었다.
- scope 고정 — 안다고 생각했다. 확인해보니 틀렸다.
- json_schema — 있는 걸 알았다. 지금 잘 돼서 안 썼다.
- 실패 처리 — 예외 처리를 했다. 그게 재시도를 막는 줄 몰랐다.
- 검색 상한 — 과금 구조를 알았다. 상한 거는 걸 잊었다.
지식이 부족해서 생긴 사고가 하나도 없다. 전부 아는 것과 대비하는 것 사이의 틈에서 터졌다. 그리고 자동화는 이 틈을 가장 잔인하게 파고든다. 사람이 하는 일이면 이상하면 멈추는데, 자동화는 이상해도 계속 돌기 때문이다.
배운 것
- 설정 화면에서 뭔가를 선택했으면, 선택하지 않은 쪽과 뭐가 다른지 한 번은 물어보자. "테스트 / 프로덕션"처럼 무해해 보이는 토글에 만료 시한이 숨어 있다.
- "나중에 바꾸면 되지"가 안 되는 것들이 있다. OAuth scope처럼 발급 순간 고정되는 값은 나중에 못 고친다. 처음에 넓게 받아두는 게 낫다.
- 잘 돌아가는 것과 안전한 것은 다르다. API가 안전장치(스키마 강제 같은)를 제공하면 지금 안 깨져도 켜두자. 생략의 대가는 나중에 선택권으로 청구된다.
- 실패 경로를 코드로 써보자. "실패하면 어떻게 되지?"에 답을 못 하면 예외 처리를 한 게 아니다. 특히 "다시 시도되나?"는 반드시 확인해야 한다.
- 도구·루프·재시도에는 반드시 상한을 걸자. 켜는 순간 상한을 같이 정하지 않으면, 상한은 결국 청구서가 정해준다.
- 조용한 실패를 감시하자. 이번 봇은 "실행 중"이면서 아무 일도 안 하고 있었다. 프로세스 생존이 아니라 결과물을 확인해야 한다.