Google OAuth 외부 요청의 timeout 부재를 공통 10초 취소 예산으로 보완

이 글의 목차 8개 항목
  • 분야: BE
  • 기술 스택: TypeScript · Fetch API · AbortController · Google OAuth

문제와 원인

Google OAuth callback은 token 교환과 userinfo 조회를 순서대로 기다렸다. 두 fetch에 취소 신호가 없어 token 응답이 지연되면 userinfo로 넘어가지 못하고, userinfo 응답이 지연되면 세션 발급까지 대기했다.

수정 전 코드

completeGoogleAuth

취소 신호 없이 token·userinfo 응답을 순서대로 기다렸다.

const tokenResponse = await fetch(GOOGLE_TOKEN_ENDPOINT, {
  method: "POST",
  headers: {
    Accept: "application/json",
    "Content-Type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    code,
    client_id: configuration.clientId,
    client_secret: configuration.clientSecret,
    redirect_uri: configuration.redirectUri,
    grant_type: "authorization_code",
    code_verifier: pending.codeVerifier,
  }),
});

const userInfoResponse = await fetch(GOOGLE_USERINFO_ENDPOINT, {
  headers: {
    Accept: "application/json",
    Authorization: `Bearer ${token.access_token}`,
  },
});

수정 후 코드

completeGoogleAuth

state·PKCE 검사를 통과한 callback에서 token 요청 직전에 공통 예산 10초(10,000ms)의 타이머를 시작하고 두 요청에 같은 AbortSignal을 전달했다. JSON을 읽은 뒤에도 취소 상태를 확인한다.

const GOOGLE_OAUTH_RESPONSE_BUDGET_MS = 10_000;

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), GOOGLE_OAUTH_RESPONSE_BUDGET_MS);
try {
  const tokenResponse = await fetch(GOOGLE_TOKEN_ENDPOINT, {
    signal: controller.signal,
    // 기존 token 요청 메서드·헤더·본문
  });
  const token = await readJson<GoogleTokenResponse>(tokenResponse);
  controller.signal.throwIfAborted();
  // token 응답 검증
  const userInfoResponse = await fetch(GOOGLE_USERINFO_ENDPOINT, {
    signal: controller.signal,
    // 기존 userinfo 요청 헤더
  });
  const userInfo = await readJson<GoogleUserInfoResponse>(userInfoResponse);
  controller.signal.throwIfAborted();
  // 사용자 검증과 세션 생성
} catch {
  return failureRedirect(request, configuration, "google_unavailable");
} finally {
  clearTimeout(timeout);
}

token 단계에서 쓴 시간만큼 userinfo 단계의 남은 예산도 줄어든다. 현재 코드는 계정 준비와 세션 값 생성 뒤에도 throwIfAborted()를 호출한다. DB 작업은 반환된 뒤 취소 상태를 검사하므로 이 예산의 직접 취소 대상은 외부 fetch다.

검증 결과

합성 외부 응답의 함수 실행

Node.js에서 수정 전·후의 인증 서비스 함수를 실행했다. Google 응답은 취소 신호에 AbortError로 종료하는 프로세스 내 대역을 사용했으며 실제 벽시계 타이머로 각 조건을 1회 측정했다.

입력수정 전 관측수정 후 경과 시간token / userinfo계정 저장 / 세션
token 무응답10,500ms에 대기 중10,006.1ms1 / 0회0회 / 없음
정상 token, userinfo 무응답—10,001.6ms1 / 1회0회 / 없음
token·userinfo 각각 6,500ms 지연 예약—10,003.5ms1 / 1회0회 / 없음

수정 후 세 조건은 HTTP 302와 auth_error=google_unavailable을 반환했다. 수정 전 무응답 실행은 10,500ms 관측 후 측정 대역을 종료해 정리했다.

정상 응답은 token·userinfo를 각각 5ms 뒤 반환하도록 설정했다. 오류 응답도 같은 환경에서 각 1회 실행했다.

입력경과 시간token / userinfo계정 저장세션응답
정상 token·userinfo12.7ms1 / 1회1회발급HTTP 302, 내부 복귀 경로
token HTTP 5036.2ms1 / 0회0회없음HTTP 302, google_token_failed
token의 잘못된 JSON5.9ms1 / 0회0회없음HTTP 302, google_token_failed

브라우저 요청의 응답

같은 원본 callback과 합성 응답 대역을 로컬 서비스에 연결하고 Swagger UI에서 각 조건을 1회 요청했다. IAB 화면 조건은 1280×720 CSS px, DPR 2였다. 함수 경과 시간과 Swagger 요청 전체 시간은 각각의 측정 구간으로 기록했다.

검사 endpoint의 envelope 응답은 HTTP 200이며, 내부 handlerHttpStatus에 callback의 HTTP 302를 기록한다. 수정 후 두 요청의 authError는 google_unavailable이었다.

수정 전 token 무응답

callback 관측Swagger 요청 전체 시간token / userinfo계정 저장 / 세션
10,500ms에 대기, 정리 완료까지 10,502.3ms10,611ms1 / 0회0회 / 없음
수정 전: 10,500ms 대기와 watchdog 정리원본 보기 (새 창)

수정 전: 10,500ms 대기와 watchdog 정리

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

수정 후 token 무응답

callback 관측Swagger 요청 전체 시간token / userinfo계정 저장 / 세션
10,008.5ms에 실패 리다이렉트10,058ms1 / 0회0회 / 없음
수정 후 token 요청의 10,008.5ms 실패 리다이렉트원본 보기 (새 창)

수정 후 token 요청의 10,008.5ms 실패 리다이렉트

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

수정 후 각 단계 6,500ms 지연 예약

callback 관측Swagger 요청 전체 시간token / userinfo계정 저장 / 세션
10,006.8ms에 실패 리다이렉트10,055ms1 / 1회0회 / 없음
두 단계 지연 요청의 공통 예산 10,006.8ms원본 보기 (새 창)

두 단계 지연 요청의 공통 예산 10,006.8ms

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.