Flutter

토큰 재발급 요청이 중복으로 나가는 문제 - 요청 대기열 만들어 해결

땅콩콩 2024. 2. 6. 15:43

개인 프로젝트를 위한 플러터 앱에서 Dio Interceptor를 사용해서 토큰 에러가 나면 토큰 재발급을 위한 api로 요청을 보내 토큰을 재발급받는 로직으로 처리하고 있었다.

 

그런데 앱을 테스트할 때, 프론트에서 토큰 재발급 요청이 중복으로 날아가 서버에서 SQLIntegrityConstraintViolationException 이 발생하였다.

새로 생성된 refreshToken이 저장되는 과정에서 Duplicate entry 문제가 발생한 것인데, 로그를 찍어보니 프론트 상에서 토큰이 필요한 api를 중복으로 보낼때 적절한 처리가 되어있지 않아서 발생한 문제 같았다.

 

만약 인증이 필요한 여러 api를 호출하였는데, accessToken이 만료된 경우, 이 각 호출들이 Interceptor에서 같은 refreshToken을 담아 토큰 새로고침을 위한 요청을 보내고, 이렇게 같은 토큰을 담은 중복 요청에 의해서 서버에서 문제가 발생한 것이다.

(토큰 재발급 중복 요청 -> 재발급한 새로운 토큰을 두번 저장하려고 함. 그런데 백엔드 구조상 리프레시 토큰이 중복되어 저장될 수 없기 때문에 이런 오류가 났다.)

 

따라서 여러 API들의 동시 요청에서 토큰 재발급이 필요한 경우를 처리하는 로직을 구현해야 했다.

 

이것이 좋은 방법인지는 모르겠지만, 학교의 운영체제 수업에서 프로세스의 lock을 걸었다 풀었다하면서 동시 요청들을 처리했던 부분을 떠올려서 대기열을 만들어 해결해봤다.

 

class CustomInterceptor extends Interceptor {
  final FlutterSecureStorage storage;
  final Ref ref;
  final Dio dio;

  // 리프레쉬 토큰을 새로 발급받고 있으면 대기열에서 기다렸다가 요청을 처리하도록 한다.
  bool isRefreshing = false;
  List<ErrorInterceptorHandler> requestQueue = [];

  CustomInterceptor(
    this.storage,
    this.ref,
    this.dio,
  );

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
  
    final refreshToken = await storage.read(key: REFRESH_TOKEN_KEY);
  
    if (refreshToken == null) {
      return handler.reject(err);
    }

    final isPathRefresh = err.requestOptions.path == '/token';

    if (err.response?.statusCode == 401 && !isPathRefresh) {
      RequestOptions options = err.requestOptions;

      if (!isRefreshing) {
        isRefreshing = true;
        getNewToken().then((newTokenAvailable) async {
          if (newTokenAvailable) {
            final accessToken = await storage.read(key: ACCESS_TOKEN_KEY);
            requestQueue.forEach(
              (handler) {
                options.headers.addAll({
                  'Authorization': 'Bearer $accessToken',
                });
                dio.fetch(options).then(
                      (response) => handler.resolve(response),
                      onError: (e) => handler.reject(e),
                    );
              },
            );
          } else{
            requestQueue.forEach((handler) {
              handler.reject(err);
            });
            //로그아웃 처리
            ref.read(authProvider.notifier).logout();
          }
          requestQueue.clear();
          isRefreshing = false;
        });
      }
      //isRefreshing이 true면 대기열에 들어가 기다린다.
      requestQueue.add(handler);
    } else{
      return super.onError(err, handler);
    }
  }

  Future<bool> getNewToken() async {
    final refreshToken = await storage.read(key: REFRESH_TOKEN_KEY);

    final dio = Dio();
    try {
      final resp = await dio.post(
        'http://$ip/token',
        options: Options(
          headers: {
            'Authorization': 'Bearer $refreshToken',
          },
        ),
      );

      // 새로 받아온 accessToken, refreshToken을 스토리지에 저장
      final refreshTokenArray = resp.data['refreshToken'];
      final accessTokenArray = resp.data['accessToken'];

      final newRefreshToken = refreshTokenArray != null
          ? refreshTokenArray!.substring("Bearer ".length)
          : null;
      final newAccessToken = accessTokenArray != null
          ? accessTokenArray!.substring("Bearer ".length)
          : null;

      if (newRefreshToken == null || newAccessToken == null) {
        print("token null!!!");
      }

      await storage.write(key: REFRESH_TOKEN_KEY, value: newRefreshToken);
      await storage.write(key: ACCESS_TOKEN_KEY, value: newAccessToken);

      return true;
    } on DioException catch (e) {
      return false;
    }
  }
}

 

토큰 에러 응답이 왔을 때 현재 isRefreshing이 false이면 이를 true로 바꿔서 중복요청을 보낼 수 없도록 일종의 락을 걸어준다.

 

그래서 이후 다른 요청들이 이곳으로 들어왔을 때 만약 리프레시 토큰을 새로 발급받고 있는 중이면 요청 대기열인 requestQueue에 들어가 대기하고, 토큰을 재발급받고나서 순차적으로 하나씩 모두 처리되도록 한다.

 

그리고 요청이 모두 끝나면 requestQueue를 비우고 isRefreshing을 다시 false로 두면서 락을 풀어준다.

 

이렇게 중복된 토큰 발급요청이 가지 않도록 해줬더니 문제가 해결되었다!