직접 해 본 결과만 적습니다 · 확인하지 못한 것은 그렇게 표시합니다
써 AI 마케팅 적용기 시중에 나온 AI 방법을 실무에 넣어 보고, 쓸만한지 남깁니다
반복업무 줄이기

OAuth 갱신 토큰은 1회용입니다 — 두 프로그램이 나눠 쓰면 한쪽이 계속 죽습니다

계속 쓴다2026-08-10·읽기 5분·글 써봄

OAuth 로그인을 두 프로그램이 나눠 쓰고 있었습니다. 어느 날부터 한쪽이 계속 실패했습니다. 그쪽 코드는 아무것도 안 건드렸는데요.

원인은 갱신 토큰이 1회용이라는 점이었습니다. 한쪽이 갱신하는 순간 다른 쪽이 들고 있던 값이 죽습니다.

1. invalid_grant 오류인지 확인합니다 2. 그 자격증명을 다른 프로그램도 쓰는지 확인합니다 3. 쓰고 있으면 각자 따로 발급합니다 4. 자동 복구 로직이 같은 값을 다시 쓰고 있지 않은지 봅니다

갱신 토큰이 어떻게 도나

API를 부를 때 쓰는 접근 토큰은 보통 한 시간쯤 살아 있습니다. 만료되면 갱신 토큰으로 새것을 받습니다.

여기서 많은 서비스가 갱신 토큰도 같이 새것으로 바꿔 줍니다. 그리고 이전 갱신 토큰은 그 순간 무효가 됩니다.

갱신 요청 시 새 토큰이 발급되고 이전 것이 무효가 되는 구조를 정리한 터미널 화면

혼자 쓰면 아무 문제 없습니다. 받은 새 값을 저장하고 다음에 그걸 쓰면 됩니다.

문제는 둘이 나눠 쓸 때 생깁니다.

증상 — 늦게 쓴 쪽이 계속 실패합니다

증상 — 한쪽은 멀쩡한데 다른 쪽만 HTTP 400 과 함께 invalid_grant 가 돌아옵니다. 재시도해도 같습니다.

한쪽이 갱신한 뒤 다른 쪽이 계속 실패하는 시간 순서를 정리한 터미널 화면

원인 — A가 먼저 갱신하면서 새 값을 자기 파일에만 저장했습니다. B는 여전히 옛 값을 들고 있고, 그건 이미 무효입니다.

B 입장에서는 자기가 아무것도 안 했는데 갑자기 실패합니다. 그래서 B 코드를 아무리 봐도 원인이 없습니다. 저는 B만 며칠 들여다봤습니다.

오류 뜻 볼 곳
401 접근 토큰 만료 갱신하면 됨
400 + invalid_grant 갱신 토큰 자체가 무효 누가 먼저 썼는지
403 권한 없음 토큰과 무관

400과 401을 구분하는 게 중요합니다. 401은 정상 흐름이고, invalid_grant 는 구조 문제입니다.

자동 복구가 겹겹이 실패합니다

여기가 제일 고약했습니다. 실패에 대비해 복구 단계를 여러 겹 넣어 뒀는데, 전부 소용이 없었습니다.

복구 단계들이 모두 같은 무효 토큰을 다시 읽는 구조를 정리한 터미널 화면

try:
    token = refresh(saved_refresh_token)      # 1차: 저장된 값으로
except InvalidGrant:
    clear_cache()
    token = refresh(load_from_file())         # 2차: 파일에서 다시 읽어 재시도
except InvalidGrant:
    restart_service()                         # 3차: 재시작

세 단계가 전부 같은 죽은 값을 봅니다. 파일에 있는 게 무효인데 캐시를 지우고 다시 읽어도 같은 값이고, 재시작해도 같은 파일을 읽습니다.

복구 로직이 많을수록 로그만 길어지고 원인은 더 안 보입니다. 저는 로그에 실패가 세 줄씩 찍혀서 뭔가 복잡한 문제인 줄 알았습니다.

해결 — invalid_grant 는 복구 대상이 아닙니다. 사람이 다시 로그인해야 풀립니다. 재시도하지 말고 바로 알립니다.

except InvalidGrant:
    notify("갱신 토큰이 무효입니다 — 다시 로그인이 필요합니다")
    raise                       # 재시도하지 않는다

해결 — 프로그램마다 따로 발급합니다

해결 — 자격증명을 공유하지 않습니다. 프로그램 수만큼 따로 발급받습니다.

공유 분리
발급 횟수 한 번 프로그램 수만큼
한쪽 갱신 시 다른 쪽 죽음 그쪽만 영향
원인 추적 어렵습니다 죽은 쪽만 보면 됩니다
권한 범위 뭉뚱그려짐 필요한 만큼만

발급을 한 번 더 하는 게 귀찮아서 미루면, 나중에 원인 모를 장애로 며칠을 씁니다.

대안으로 한쪽만 갱신을 담당하고 다른 쪽은 그 결과를 받아 쓰는 구조도 가능합니다. 다만 그러면 갱신 담당이 죽었을 때 전부 멈춥니다. 저는 그냥 나누는 쪽이 단순해서 그렇게 했습니다.

어쩔 수 없이 공유해야 한다면

발급 수에 제한이 있어서 나눌 수 없는 경우가 있습니다. 그때는 최소한 이 정도는 해둬야 합니다.

1. 갱신은 한 곳에서만 합니다. 나머지는 저장된 접근 토큰을 읽어 쓰기만 합니다 2. 갱신할 때 파일 잠금을 겁니다. 동시에 두 곳이 갱신을 시작하면 하나가 죽습니다 3. 갱신 결과는 모두가 읽는 한 파일에 씁니다 4. invalid_grant 가 나오면 재시도하지 말고 사람을 부릅니다

with locked("token.json"):
    tok = load()
    if tok.expires_soon():
        tok = refresh(tok.refresh_token)   # 잠금 안에서 한 번만
        save(tok)                          # 모두가 읽는 곳에 쓴다

주의할 점은 잠금을 걸어도 프로세스가 서로 다른 서버에 있으면 안 통한다는 것입니다. 그 경우는 다른 방법이 필요한데, 저는 거기까지는 확인하지 못했습니다. 같은 서버 안에서만 써봤습니다.

확인 순서

확인 방법
오류 종류 invalid_grant 인가 단순 만료인가
공유 여부 같은 자격증명 파일을 몇 곳이 읽는가
마지막 갱신 시각 실패 직전에 다른 쪽이 갱신했는가
복구 로직 같은 값을 다시 읽고 있지 않은가

두 번째가 핵심입니다. 파일 경로를 코드 전체에서 검색해 보면 몇 곳이 쓰는지 바로 나옵니다.

판정 — 계속 쓴다

자격증명 분리는 처음부터 하는 편이 낫습니다. 나중에 나누려면 어느 프로그램이 무엇을 쓰는지부터 찾아야 하는데 그게 오래 걸립니다.

invalid_grant 에 재시도를 넣지 않는 것도 중요합니다. 복구가 안 되는 오류에 복구를 걸면 로그만 지저분해지고 원인이 더 안 보입니다.

사람이 다시 로그인해야 하는 부분은 자동화로 못 풉니다. 빨리 알려주는 것까지가 코드의 몫입니다.

직접 하실 분께

하나. 400 invalid_grant 를 보면 누가 먼저 갱신했는지 부터 찾으세요. 그쪽 코드를 보지 마세요.

둘. 자격증명 파일 경로를 코드 전체에서 검색해 봅니다. 두 곳 이상이면 그게 원인입니다.

셋. 복구 로직이 같은 값을 다시 읽고 있지 않은지 확인합니다. 겹겹이 넣어도 소용없습니다.

넷. 나눌 수 없으면 갱신을 한 곳에서만 하고 잠금을 겁니다.

다음에는 서버가 여러 대일 때 갱신을 한 곳으로 모으는 방법을 확인해보려고 합니다. 되면 이어서 적겠습니다.