Flutter 개발 기록

Flutter Dio 401 토큰 갱신 인터셉터 구현: 동시 요청부터 데드락까지

dogmania 2026. 7. 13. 18:17

들어가며

앱을 사용하다가 액세스 토큰이 만료되면 보통 두 가지 중 하나가 발생한다.

갑자기 로그아웃되거나, 앱이 조용히 새 토큰을 받아와 아무 일 없었던 것처럼 원래 요청을 이어가거나.

 

나는 후자를 만들기로 결정했다.

 

이미 요청 헤더에는 토큰을 붙이는 AuthInterceptor가 있었기 때문에 여기에 401 처리 로직만 추가하면 될 것 같았다.

하지만 실제로 구현해보니 동시 요청, 인터셉터 재진입, 데드락 같은 문제가 생길 수 있었다.

 

이번 글에서는 왜 토큰 갱신 로직을 별도 인터셉터로 분리했고, QueuedInterceptor와 별도의 Dio 인스턴스를 사용했는지 정리한다.


왜 AuthInterceptor에 합치지 않았나

기존 AuthInterceptor는 요청 전에 저장된 액세스 토큰을 읽고 Authorization 헤더에 추가한다.

 
class AuthInterceptor extends QueuedInterceptor {
  AuthInterceptor({
    required TokenStorage tokenStorage,
  }) : _tokenStorage = tokenStorage;

  final TokenStorage _tokenStorage;

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final accessToken = ...;

    if (accessToken != null && accessToken.isNotEmpty) {
      options.headers['Authorization'] = 'Bearer $accessToken';
    }

    handler.next(options);
  }
}
 

이 클래스의 책임은 명확하다.

요청을 보내기 전에 액세스 토큰을 붙인다.

여기에 401 처리와 토큰 갱신까지 넣으면 하나의 클래스가 다음 두 가지 이유로 변경될 수 있다.

  • 토큰을 헤더에 붙이는 방식이 변경될 때
  • 토큰 갱신 또는 실패 정책이 변경될 때

두 책임을 분리하기 위해 TokenRefreshInterceptor를 별도로 만들고, AuthInterceptor 다음에 등록했다.

 
dio.interceptors.add(
  AuthInterceptor(
    tokenStorage: tokenStorage,
  ),
);

dio.interceptors.add(
  TokenRefreshInterceptor(
    dio: dio,
    tokenStorage: tokenStorage,
    onRefreshFailed: () async {
      ref.read(authStateProvider.notifier).clearSession();
    },
  ),
);
 

문제 1. 동시에 여러 요청이 401을 받으면?

화면 하나에서 API를 여러 개 동시에 호출하는 일은 흔하다.

토큰이 만료된 시점에 요청 A, B, C가 동시에 401을 받으면, 각 요청이 독립적으로 /auth/refresh를 호출하는 구현은 갱신 요청도 여러 번 발생시킨다.

A 요청 → 401 → 토큰 갱신
B 요청 → 401 → 토큰 갱신
C 요청 → 401 → 토큰 갱신
 

이 문제는 리프레시 토큰이 rotation 방식일 때 특히 위험하다.

첫 번째 갱신 요청이 성공하면서 기존 리프레시 토큰이 무효화되면, 뒤이어 들어온 요청은 이미 폐기된 리프레시 토큰으로 갱신을 시도하게 된다. 이를 막기 위해 TokenRefreshInterceptor는 QueuedInterceptor를 상속했다.

 
class TokenRefreshInterceptor extends QueuedInterceptor {
  // ...
}
 

QueuedInterceptor는 인터셉터 콜백을 큐에서 순차적으로 처리한다.

A 401 ──┐
B 401 ──┼─▶ [ QueuedInterceptor 큐 ] ─▶ A 처리
C 401 ──┘                              B, C 대기
 

다만 큐만 사용하면 갱신이 동시에 실행되지 않을 뿐, A 이후 B와 C가 다시 갱신을 호출할 수 있다.

그래서 실패 당시 사용한 토큰과 현재 저장된 토큰을 비교한다.

 
final failedAccessToken = _extractBearerToken(
  options.headers,
);

final currentAccessToken =
    await _tokenStorage.readAccessToken();

if (currentAccessToken != null &&
    currentAccessToken.isNotEmpty &&
    currentAccessToken != failedAccessToken) {
  await _retryWithToken(
    options,
    handler,
    currentAccessToken,
  );
  return;
}
 

A가 먼저 갱신에 성공하면 저장소의 토큰은 새 토큰으로 바뀐다.

 

이후 B의 차례가 왔을 때:

B가 실패할 때 사용한 토큰: old-token
현재 저장소의 토큰: new-token
 

두 토큰이 다르므로, 다른 요청이 이미 갱신을 끝냈다는 것을 알 수 있다.

B와 C는 다시 /auth/refresh를 호출하지 않고 새 토큰으로 원래 요청만 재시도한다.

첫 번째 갱신이 성공한다는 전제에서, 같은 만료 토큰으로 실패한 요청 묶음은 갱신 API를 한 번만 호출하게 된다.


문제 2. 같은 Dio로 다시 요청하면 데드락이 생길 수 있다

토큰 갱신에 성공하면 원래 요청을 다시 보내야 한다.

가장 먼저 떠오르는 방식은 같은 Dio 인스턴스로 재시도하는 것이다.

 
final response = await dio.fetch(options);
 

하지만 이 Dio에는 TokenRefreshInterceptor가 등록돼 있다. 재시도 요청이 또 401을 반환하면 해당 에러도 같은 인터셉터의 onError 큐로 들어가야 한다. 문제는 기존 요청 A의 onError가 아직 재시도 요청의 완료를 기다리고 있다는 점이다.

A의 onError
→ 재시도 요청 완료를 await

재시도 요청의 onError
→ A의 onError가 큐를 반환하기를 기다림
 

A는 재시도를 기다리고, 재시도 요청의 에러 처리는 A가 끝나기를 기다린다. 이런 순환 대기 때문에 인터셉터가 멈출 수 있다.

 

/auth/refresh 요청을 같은 Dio로 보낼 때도 마찬가지다. 리프레시 토큰까지 만료되어 갱신 요청이 401을 반환하면, 그 에러가 다시 같은 TokenRefreshInterceptor로 들어오려 한다.


해결 방법: 인터셉터가 없는 별도 Dio 사용

갱신 요청과 재시도 요청은 기존 Dio가 아닌 별도 Dio로 보냈다.

 
class TokenRefreshInterceptor extends QueuedInterceptor {
  TokenRefreshInterceptor({
    required Dio dio,
    required TokenStorage tokenStorage,
    required Future<void> Function() onRefreshFailed,
  })  : _tokenStorage = tokenStorage,
        _onRefreshFailed = onRefreshFailed,
        _refreshDio = Dio(
          dio.options.copyWith(),
        );

  final Dio _refreshDio;
  final TokenStorage _tokenStorage;
  final Future<void> Function() _onRefreshFailed;
}
 

BaseOptions는 복사하지만 인터셉터는 복사되지 않는다. 따라서 _refreshDio에는 AuthInterceptor와 TokenRefreshInterceptor가 없다.

기존 Dio
→ AuthInterceptor
→ TokenRefreshInterceptor

_refreshDio
→ 인터셉터 없음
 

갱신 요청이나 재시도 요청이 실패해도 다시 같은 큐로 돌아오지 않으므로 인터셉터 재진입을 구조적으로 차단할 수 있다.

다만 원본 Dio가 커스텀 HttpClientAdapter, 인증서 설정, 프록시, Transformer 등을 사용한다면 별도 Dio에도 필요한 설정을 직접 적용해야 한다.


새 토큰으로 원래 요청 재시도하기

별도 Dio에는 AuthInterceptor가 없으므로 새 토큰을 직접 헤더에 넣어준다.

 
Future<void> _retryWithToken(
  RequestOptions options,
  ErrorInterceptorHandler handler,
  String accessToken,
) async {
  options.headers['Authorization'] = 'Bearer $accessToken';

  try {
    final response = await _refreshDio.fetch<dynamic>(options);

    handler.resolve(response);
  } on DioException catch (error) {
    handler.next(error);
  }
}
 

재시도에 성공하면 handler.resolve(response)를 호출한다. Repository 입장에서는 첫 요청이 성공한 것처럼 정상 응답을 받는다.

재시도마저 실패하면 해당 에러를 그대로 다음 단계로 전달한다.


문제 3. 토큰 갱신 응답을 어떻게 파싱할 것인가

처음에는 다음처럼 직접 파싱할 수도 있다.

 
final accessToken = response.data?['accessToken'] as String?;
 

동작은 하지만 응답 필드가 누락되거나 타입이 바뀌었을 때 문제를 추적하기 어렵다.

그래서 프로젝트의 다른 API 응답과 동일하게 freezed와 json_serializable 기반 DTO를 사용했다.

 
@freezed
abstract class RefreshTokenResponse
    with _$RefreshTokenResponse {
  const factory RefreshTokenResponse({
    required String accessToken,
    required String refreshToken,
  }) = _RefreshTokenResponse;

  factory RefreshTokenResponse.fromJson(
    Map<String, dynamic> json,
  ) =>
      _$RefreshTokenResponseFromJson(json);
}
 

DTO를 저장 타입으로 변환하는 매퍼도 분리했다.

 
extension RefreshTokenResponseMapper
    on RefreshTokenResponse {
  AuthToken toAuthToken() {
    return AuthToken(
      accessToken: accessToken,
      refreshToken: refreshToken,
    );
  }
}
 

필수 필드가 없거나 타입이 다르면 파싱 단계에서 명확하게 실패한다.


토큰 갱신 요청 구현

 
Future<String?> _refreshAccessToken() async {
  final refreshToken =
      await _tokenStorage.readRefreshToken();

  if (refreshToken == null || refreshToken.isEmpty) {
    return null;
  }

  try {
    final response =
        await _refreshDio.post<Map<String, dynamic>>(
      '/auth/refresh',
      data: {
        'refreshToken': refreshToken,
      },
    );

    final data = response.data;

    if (data == null) {
      _logger.warning(
        '토큰 갱신 응답 데이터가 비어 있습니다.',
      );
      return null;
    }

    final token =
        RefreshTokenResponse.fromJson(data).toAuthToken();

    await _tokenStorage.save(token);

    return token.accessToken;
  } on DioException catch (error, stackTrace) {
    _logger.error(
      '토큰 갱신 API 호출에 실패했습니다.',
      error: error,
      stackTrace: stackTrace,
    );
    return null;
  } catch (error, stackTrace) {
    _logger.error(
      '토큰 갱신 응답 처리 또는 저장에 실패했습니다.',
      error: error,
      stackTrace: stackTrace,
    );
    return null;
  }
}
 

사용자에게는 모두 토큰 갱신 실패로 처리하더라도, 로그에서는 네트워크 실패인지 파싱 실패인지 구분할 수 있어야 한다.

 
catch (_) {
  return null;
}
 

처럼 모든 예외를 조용히 삼키면 운영 중 문제를 찾기 어렵다.


전체 흐름

요청 → 401
  │
  ▼
skipAuth 요청인가?
  │
  ├─ Yes → 원래 에러 전달
  │
  └─ No
      │
      ▼
현재 저장된 토큰과 실패 당시 토큰 비교
      │
      ├─ 다름
      │   └─ 다른 요청이 이미 갱신 완료
      │       → 새 토큰으로 즉시 재시도
      │
      └─ 같음
          │
          ▼
    /auth/refresh 호출
    별도 Dio 사용
          │
          ├─ 성공
          │   ├─ 응답 파싱
          │   ├─ 새 토큰 저장
          │   └─ 원래 요청 재시도
          │
          └─ 실패
              ├─ 토큰 저장소 초기화
              ├─ 세션 초기화
              └─ 원래 401 전달
 

호출하는 Repository는 이 과정을 알 필요가 없다. 갱신이 성공하면 원래 요청이 성공한 것처럼 응답이 오고, 실패하면 기존과 동일하게 인증 오류를 받는다.


재시도 시 확인할 점

이 구조는 서버가 인증 검증 단계에서 요청을 차단하고, 비즈니스 로직을 실행하기 전에 401을 반환한다는 전제를 갖는다.

결제, 코인 사용, 주문 생성처럼 중복 실행에 민감한 요청은 서버의 멱등성 보장 여부도 함께 확인해야 한다.


아직 하지 않은 것

현재 토큰 갱신이 완전히 실패하면 인증 세션을 초기화한다.

 
ref
    .read(authStateProvider.notifier)
    .clearSession();
 

하지만 아직 인증 상태를 구독해 로그인 화면으로 이동시키는 라우터 가드는 없다. 다음 단계에서는 GoRouter의 redirect에서 인증 상태를 확인해, 세션이 끊기는 순간 현재 화면과 관계없이 로그인 화면으로 이동하도록 구성할 예정이다.

 

네트워크 인터셉터가 직접 화면 이동까지 담당하기보다는, 인터셉터는 인증 상태만 초기화하고 라우터가 상태 변화를 구독하는 구조가 더 적절하다고 판단했다.


마무리

이번 구현에서 중요했던 설계 판단은 세 가지였다.

  1. 책임 분리
    토큰을 붙이는 것과 갱신하는 것은 서로 다른 이유로 변경되는 코드이므로 인터셉터를 분리했다.
  2. QueuedInterceptor와 토큰 비교
    여러 요청이 동시에 401을 받아도 갱신 요청이 중복 실행되지 않도록 했다.
  3. 별도 Dio 인스턴스 사용
    인터셉터가 자기 자신의 실패를 다시 처리하려다 멈추는 재진입 문제를 구조적으로 차단했다.

처음 요구사항은 단순했다.

401을 받으면 토큰을 갱신하고 원래 요청을 다시 보내자.

 

하지만 실제 앱에서는 여러 API가 동시에 실행되고, 리프레시 토큰이 교체될 수 있으며, 재시도 요청도 실패할 수 있다.

결국 이 세 가지 선택은 단순히 동작하는 코드와 실제 프로덕션 환경에서도 안정적으로 동작하는 코드의 차이를 메우기 위한 것이었다.

반응형