- 분야: 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.1ms | 1 / 0회 | 0회 / 없음 |
| 정상 token, userinfo 무응답 | — | 10,001.6ms | 1 / 1회 | 0회 / 없음 |
| token·userinfo 각각 6,500ms 지연 예약 | — | 10,003.5ms | 1 / 1회 | 0회 / 없음 |
수정 후 세 조건은 HTTP 302와 auth_error=google_unavailable을 반환했다. 수정 전 무응답 실행은 10,500ms 관측 후 측정 대역을 종료해 정리했다.
정상 응답은 token·userinfo를 각각 5ms 뒤 반환하도록 설정했다. 오류 응답도 같은 환경에서 각 1회 실행했다.
| 입력 | 경과 시간 | token / userinfo | 계정 저장 | 세션 | 응답 |
|---|---|---|---|---|---|
| 정상 token·userinfo | 12.7ms | 1 / 1회 | 1회 | 발급 | HTTP 302, 내부 복귀 경로 |
| token HTTP 503 | 6.2ms | 1 / 0회 | 0회 | 없음 | HTTP 302, google_token_failed |
| token의 잘못된 JSON | 5.9ms | 1 / 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.3ms | 10,611ms | 1 / 0회 | 0회 / 없음 |
수정 후 token 무응답
| callback 관측 | Swagger 요청 전체 시간 | token / userinfo | 계정 저장 / 세션 |
|---|---|---|---|
| 10,008.5ms에 실패 리다이렉트 | 10,058ms | 1 / 0회 | 0회 / 없음 |
수정 후 각 단계 6,500ms 지연 예약
| callback 관측 | Swagger 요청 전체 시간 | token / userinfo | 계정 저장 / 세션 |
|---|---|---|---|
| 10,006.8ms에 실패 리다이렉트 | 10,055ms | 1 / 1회 | 0회 / 없음 |