[Flutter] Dio Interceptor②-Dio onError Interceptor 작업

겨레·2024년 7월 19일
0
post-thumbnail

① 3) 에러가 났을 때 먼저 작업해보기
onError를 작성하면 자동완성 됨.

에러가 발생했을 땐 어떤 상황을 캐치하고싶은지가 중요하다.
현재 나는 토큰에 문제가 있을 때 401 에러 났을 때 캐치하고싶음!
super.onError(err, handler); 앞에 return 붙여주고
그 위에 어떤 에러를 캐치하고싶은지 작성하면 됨.

// 3) 에러가 났을 때
    
    void onError(DioException err, ErrorInterceptorHandler handler) {
     
     // 401 에러 발생 시(statue code)
     // 토큰을 재발급 받는 시도를 하고 토큰이 재발급되면
     // 다시 새로운 토큰으로 요청을 함 
     return super.onError(err, handler);
    }
  }
}

401 에러가 만약 났다면 이를 인지해서
새로 토큰을 발급받을 수 있는 Auth Token URL에 요청을 넣고,
그렇게 새로 발급받은 accessToken으로 저장해서
원래 보내려고 했던 요청(= accessToken에서 에러나서 제대로된 응답을 받지 못한 그 요청!)에
새로운 토큰으로 요청을 넣어서 결과 값(return super.onError(err, handler); 이거!)을
다시 반환해 주는 것!

이걸 해 볼 건데...

일단 에러가 난 상황이라고 가정해보자!

// 3) 에러가 났을 때
    
    void onError(DioException err, ErrorInterceptorHandler handler) {
      // 401 에러 발생 시(statue code)
      // 토큰을 재발급 받는 시도를 하고 토큰이 재발급되면
      // 다시 새로운 토큰으로 요청을 함

      // 프린트로 어떤 요청, 어떤 URL의 요청에서 에러가 났는지 확인 
      print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri}');

      return super.onError(err, handler);
    }
  }
}

어떤 요청, 어떤 URL의 요청에서 에러가 났는지 확인해보자.

② refreshToken 새로 가져오기!

근데 refreshToken 왜 가져올까? 어떻게든 써야하기 때문이다.
그게 무슨 말이냐면, 401 에러가 만약 났다면 이 refreshToken 사용해서
accessToken을 새로 발급받아야하기 때문이라는 것!

// 3) 에러가 났을 때
    
    void onError(DioException err, ErrorInterceptorHandler handler) async {
      // 401 에러 발생 시(statue code)
      // 토큰을 재발급 받는 시도를 하고 토큰이 재발급되면
      // 다시 새로운 토큰으로 요청을 함

      // 프린트로 어떤 요청, 어떤 URL의 요청에서 에러가 났는지 확인
      print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri}');

      // 3)-1. refreshToken 가져오기
      final refreshToken = await storage.read(key: REFRESH_TOKEN_KEY);

      // 3)-2. 가져왔는데 refreshToken이 아예 없으면 에러를 던진다
      if (refreshToken == null) {
        // 에러를 던질 때는 handler.reject 사용(dio 룰임)
        return handler.reject(err);
      }

      // 3)-3. 401 상태인지 확인
      // requestOptions => 요청의 모든 값을 가져올 수 있음
      // response => 응답의 모든 값을 가져올 수 있음
      // (응답이 없을 수도 있으니깐 물음표)
      // statusCode가 401이면 isStatus401는 true
      final isStatus401 = err.response?.statusCode == 401;

      // 3)-4. 에러가 난 요청이 토큰을 리프레시하려다 에러가 났는지를 확인
      // 여기서 true를 반환받으면 '/auth/token'에 accessToken을
      // 새로 발급받으려다 에러가 난 거라서 refreshToken 자체에 문제가 있다는 의미!
      // => 결국 새로 요청을 보내봤자 에러가 남...
      //    이럴 땐, 리젝트를 또 해줘야 함!
      final isPathRefresh = err.requestOptions.path == '/auth/token';

      // 3)-5. isStatus401이 true고 isPathRefresh가 false면
      // (즉, 토큰을 새로 리프레시 하려는 의도가 아니었는데 401에러가 발생했다면)
      if (isStatus401 && !isPathRefresh) {
        // 3)-6. dio를 새로 생성하고, dio로 토큰 리프레시 요청
        final dio = Dio();

        // resp 요청을 보내면 dio로 post 요청을 보내서
        // auth/token에 refreshToken을 사용해서
        // 새로운 accessToken을 발급받을 수 있게 됨!
        final resp = await dio.post(
          'http://$ip/auth/token',
          options: Options(
            headers: {
              'authorization': 'Bearer $refreshToken',
            },
          ),
        );

        // 3)-7. 실제 accessToken 가져오기
        // 그럴려면 데이터에서 accessToken이라는 값을 가져와야 함!
        final accessToken = resp.data['accessToken'];
      }

      // response => 무언가 응답을 받아와서 이 안에 넣어줘야 함!
      // 그렇게 되면, onError가 났음에도 불구하고
      // 실제로 요청을 실행한 화면에서는 에러가 나지 않을 것처럼 인식됨.
      // return handler.resolve(response);

      return super.onError(err, handler);
    }
  }
}

그런데 resp에서 에러를 반환받았다면??
에러를 잡아주자!
드래그한 코드를 try-catch문으로 옮겨준다.

  • 여기까지 dio.dart 전체 코드
import 'package:dio/dio.dart';
import 'package:flutter_actual/common/const/data.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class CustomInterceptor extends Interceptor {
  final FlutterSecureStorage storage;

  // 스토리지에 넣어주기
  CustomInterceptor({
    required this.storage,
  });

// 1) 요청을 보낼 때
// 요청이 보내질 때마다
// 만약에 요청의 Header에 accessToken: true 라는 값이 있다면,
// 실제 토큰을 storage에서 가져와서
// authorization : Bearer $token으로 Header를 변경
  
  void onRequest(
      RequestOptions options, RequestInterceptorHandler handler) async {
    // 이때 method는 get, post, delete를 의미
    print('[REQ] [${options.method}] ${options.uri}');

    // headers = 실제 요청의 헤더 => @Headers({'accessToken': 'true'}, )
    // headers에서 accessToken이란 값이 true라면 accessToken이라는 키를 삭제!
    if (options.headers['accessToken'] == 'true') {
      options.headers.remove('accessToken');

      // 그리고 진짜 토큰으로 바꿔주기
      final token = await storage.read(key: ACCESS_TOKEN_KEY);

      // 토큰을 가져왔다면 어떻게 넣어야 할까?
      //return super.onRequest(options, handler);
      options.headers.addAll({'authorization': 'Bearer $token'});

      return super.onRequest(options, handler); // handler.next(options); 반드시 호출
    }

// 2) 응답을 받을 때

// 3) 에러가 났을 때
    
    void onError(DioException err, ErrorInterceptorHandler handler) async {
      // 401 에러 발생 시(statue code)
      // 토큰을 재발급 받는 시도를 하고 토큰이 재발급되면
      // 다시 새로운 토큰으로 요청을 함

      // 프린트로 어떤 요청, 어떤 URL의 요청에서 에러가 났는지 확인
      print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri}');

      // 3)-1. refreshToken 가져오기
      final refreshToken = await storage.read(key: REFRESH_TOKEN_KEY);

      // 3)-2. 가져왔는데 refreshToken이 아예 없으면 에러를 던진다
      if (refreshToken == null) {
        // 에러를 던질 때는 handler.reject 사용(dio 룰임)
        return handler.reject(err);
      }

      // 3)-3. 401 상태인지 확인
      // requestOptions => 요청의 모든 값을 가져올 수 있음
      // response => 응답의 모든 값을 가져올 수 있음
      // (응답이 없을 수도 있으니깐 물음표)
      // statusCode가 401이면 isStatus401는 true
      final isStatus401 = err.response?.statusCode == 401;

      // 3)-4. 에러가 난 요청이 토큰을 리프레시하려다 에러가 났는지를 확인
      // 여기서 true를 반환받으면 '/auth/token'에 accessToken을
      // 새로 발급받으려다 에러가 난 거라서 refreshToken 자체에 문제가 있다는 의미!
      // => 결국 새로 요청을 보내봤자 에러가 남...
      //    이럴 땐, 리젝트를 또 해줘야 함!
      final isPathRefresh = err.requestOptions.path == '/auth/token';

      // 3)-5. isStatus401이 true고 isPathRefresh가 false면
      // (즉, 토큰을 새로 리프레시 하려는 의도가 아니었는데 401에러가 발생했다면)
      if (isStatus401 && !isPathRefresh) {
        // 3)-6. dio를 새로 생성하고, dio로 토큰 리프레시 요청
        final dio = Dio();

        // 3)-8. 그런데 resp에서 에러를 반환받았다면?
        // 에러를 잡아주자!
        // 어떤 이유든 여기서 에러가 나면,
        // 더이상 토큰을 리프레시할 수 있는 상황이 아님...
        try {
          // resp 요청을 보내면 dio로 post 요청을 보내서
          // auth/token에 refreshToken을 사용해서
          // 새로운 accessToken을 발급받을 수 있게 됨!
          final resp = await dio.post(
            'http://$ip/auth/token',
            options: Options(
              headers: {
                'authorization': 'Bearer $refreshToken',
              },
            ),
          );

          // 3)-7. 실제 accessToken 가져오기
          // 그럴려면 데이터에서 accessToken이라는 값을 가져와야 함!
          final accessToken = resp.data['accessToken'];

          // 3)-11. 만약 에러가 나지 않았다면?
          // requestOptions를 가져온다.
          final options = err.requestOptions;

          // 3)-12. accessToken 새로 넣어주기(토큰 변경하기)
          // 그럼 토큰을 넣을 수 있음.
          // 하지만 새로 토큰을 발급받았기 때문에
          // final FlutterSecureStorage storage 안에도 업데이트가 필요!
          options.headers.addAll(
            {
              'authorization': 'Bearer $accessToken',
            },
          );

          // 3)-13. final FlutterSecureStorage storage 업데이트
          await storage.write(key: ACCESS_TOKEN_KEY, value: accessToken);

          // 3)-14.원래 보냈던 요청 다시 보내기(요청 재전송)
          // 위에 생성해준 dio에 fetch 붙여주기
          // 그러면 (requestOptions)이 자동완성 되는데,
          // 이를 통해 실제 요청을 보낼 때 필요한 모든 값들은
          // requestOptions 안에 들어있는 걸 알 수 있음!
          // 3)-15. requestOptions에 그냥 options 넣기
          // 그러면 실제 err를 발생시킨 모든 요청과 관련된 옵션들을 다 받아서
          // 토큰만 바꾼 다음 다시 요청을 다시 보내는 것!
          final response = await dio.fetch(options);

          // 3)-16. final response = await dio.fetch(options); 이렇게
          // 응답이 오면 onError가 불렸지만, 실제로 반환해야 하는 값은
          // 응답(요청)이 잘 왔다고 await dio.fetch(options); 에서
          // 받은 응답을 다시 되돌려줘야 함! 

        } on DioException catch (e) {
          // 3)-10. 그냥 catch에서 수정
          // on DioException catch (e)로 바꿔주면 (아래 reject도 e를 넣어줌)
          // 그냥 catch로 에러 전체를 잡아도 되지만,
          // 예상되는 건 DioError니까 이렇게 따로 잡으면 좀 더 합리적임

          // 3)-9. 더이상 토큰을 리프레시할 수 있는
          // 상황이 아니라면 그냥 에러를 던져줌.
          return handler.reject(e);
        }
      }

      // response => 무언가 응답을 받아와서 이 안에 넣어줘야 함!
      // 그렇게 되면, onError가 났음에도 불구하고
      // 실제로 요청을 실행한 화면에서는 에러가 나지 않을 것처럼 인식됨.
      // handler.resolve => 요청이 잘 끝났다는 의미
      return handler.resolve(response);

      
    }
  }
}

final response = await dio.fetch(options); 이렇게 응답이 오면
onError가 불렸지만, 실제로 반환해야 하는 값은
응답(요청)이 잘 왔다고 await dio.fetch(options); 에서
받은 응답을 다시 되돌려줘야 함!

return handler.resolve(response); 이 코드를
final response = await dio.fetch(options); 아래에 집어 넣어준다.


  • dio.dart 최종 코드
import 'package:dio/dio.dart';
import 'package:flutter_actual/common/const/data.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class CustomInterceptor extends Interceptor {
  final FlutterSecureStorage storage;

  // 스토리지에 넣어주기
  CustomInterceptor({
    required this.storage,
  });

// 1) 요청을 보낼 때
// 요청이 보내질 때마다
// 만약에 요청의 Header에 accessToken: true 라는 값이 있다면,
// 실제 토큰을 storage에서 가져와서
// authorization : Bearer $token으로 Header를 변경
  
  void onRequest(
      RequestOptions options, RequestInterceptorHandler handler) async {
    // 이때 method는 get, post, delete를 의미
    print('[REQ] [${options.method}] ${options.uri}');

    // headers = 실제 요청의 헤더 => @Headers({'accessToken': 'true'}, )
    // headers에서 accessToken이란 값이 true라면 accessToken이라는 키를 삭제!
    if (options.headers['accessToken'] == 'true') {
      options.headers.remove('accessToken');

      // 그리고 진짜 토큰으로 바꿔주기
      final token = await storage.read(key: ACCESS_TOKEN_KEY);

      // 토큰을 가져왔다면 어떻게 넣어야 할까?
      //return super.onRequest(options, handler);
      options.headers.addAll({'authorization': 'Bearer $token'});

      return super.onRequest(options, handler); // handler.next(options); 반드시 호출
    }

// 2) 응답을 받을 때

// 3) 에러가 났을 때
    
    void onError(DioException err, ErrorInterceptorHandler handler) async {
      // 401 에러 발생 시(statue code)
      // 토큰을 재발급 받는 시도를 하고 토큰이 재발급되면
      // 다시 새로운 토큰으로 요청을 함

      // 프린트로 어떤 요청, 어떤 URL의 요청에서 에러가 났는지 확인
      print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri}');

      // 3)-1. refreshToken 가져오기
      final refreshToken = await storage.read(key: REFRESH_TOKEN_KEY);

      // 3)-2. 가져왔는데 refreshToken이 아예 없으면 에러를 던진다
      if (refreshToken == null) {
        // 에러를 던질 때는 handler.reject 사용(dio 룰임)
        return handler.reject(err);
      }

      // 3)-3. 401 상태인지 확인
      // requestOptions => 요청의 모든 값을 가져올 수 있음
      // response => 응답의 모든 값을 가져올 수 있음
      // (응답이 없을 수도 있으니깐 물음표)
      // statusCode가 401이면 isStatus401는 true
      final isStatus401 = err.response?.statusCode == 401;

      // 3)-4. 에러가 난 요청이 토큰을 리프레시하려다 에러가 났는지를 확인
      // 여기서 true를 반환받으면 '/auth/token'에 accessToken을
      // 새로 발급받으려다 에러가 난 거라서 refreshToken 자체에 문제가 있다는 의미!
      // => 결국 새로 요청을 보내봤자 에러가 남...
      //    이럴 땐, 리젝트를 또 해줘야 함!
      final isPathRefresh = err.requestOptions.path == '/auth/token';

      // 3)-5. isStatus401이 true고 isPathRefresh가 false면
      // (즉, 토큰을 새로 리프레시 하려는 의도가 아니었는데 401에러가 발생했다면)
      if (isStatus401 && !isPathRefresh) {
        // 3)-6. dio를 새로 생성하고, dio로 토큰 리프레시 요청
        final dio = Dio();

        // 3)-8. 그런데 resp에서 에러를 반환받았다면?
        // 에러를 잡아주자!
        // 어떤 이유든 여기서 에러가 나면,
        // 더이상 토큰을 리프레시할 수 있는 상황이 아님...
        try {
          // resp 요청을 보내면 dio로 post 요청을 보내서
          // auth/token에 refreshToken을 사용해서
          // 새로운 accessToken을 발급받을 수 있게 됨!
          final resp = await dio.post(
            'http://$ip/auth/token',
            options: Options(
              headers: {
                'authorization': 'Bearer $refreshToken',
              },
            ),
          );

          // 3)-7. 실제 accessToken 가져오기
          // 그럴려면 데이터에서 accessToken이라는 값을 가져와야 함!
          final accessToken = resp.data['accessToken'];

          // 3)-11. 만약 에러가 나지 않았다면?
          // requestOptions를 가져온다.
          final options = err.requestOptions;

          // 3)-12. accessToken 새로 넣어주기(토큰 변경하기)
          // 그럼 토큰을 넣을 수 있음.
          // 하지만 새로 토큰을 발급받았기 때문에
          // final FlutterSecureStorage storage 안에도 업데이트가 필요!
          options.headers.addAll(
            {
              'authorization': 'Bearer $accessToken',
            },
          );

          // 3)-13. final FlutterSecureStorage storage 업데이트
          await storage.write(key: ACCESS_TOKEN_KEY, value: accessToken);

          // 3)-14.원래 보냈던 요청 다시 보내기(요청 재전송)
          // 위에 생성해준 dio에 fetch 붙여주기
          // 그러면 (requestOptions)이 자동완성 되는데,
          // 이를 통해 실제 요청을 보낼 때 필요한 모든 값들은
          // requestOptions 안에 들어있는 걸 알 수 있음!
          // 3)-15. requestOptions에 그냥 options 넣기
          // 그러면 실제 err를 발생시킨 모든 요청과 관련된 옵션들을 다 받아서
          // 토큰만 바꾼 다음 다시 요청을 다시 보내는 것!
          final response = await dio.fetch(options);

          // 3)-16. final response = await dio.fetch(options); 이렇게
          // 응답이 오면 onError가 불렸지만, 실제로 반환해야 하는 값은
          // 응답(요청)이 잘 왔다고 await dio.fetch(options); 에서
          // 받은 응답을 다시 되돌려줘야 함!
          return handler.resolve(response); // handler.resolve => 요청이 잘 끝났다는 의미
        } on DioException catch (e) {
          // 3)-10. 그냥 catch에서 수정
          // on DioException catch (e)로 바꿔주면 (아래 reject도 e를 넣어줌)
          // 그냥 catch로 에러 전체를 잡아도 되지만,
          // 예상되는 건 DioError니까 이렇게 따로 잡으면 좀 더 합리적임

          // 3)-9. 더이상 토큰을 리프레시할 수 있는
          // 상황이 아니라면 그냥 에러를 던져줌.
          return handler.reject(e);
        }
      }
      return super.onError(err, handler); // 반드시 호출
    }
  }
}

response는 어떤 response일까?
바로 final response = await dio.fetch(options); 새로 보낸 요청이다.

새로 보낸 요청에 대한 응답을 받아서
handler.resolve에 넣으면 에러가 난 이 상황에
똑같은 요청을 토큰만 바꿔서 새로 요청하고,
그 성공적인 요청에 대한 값을 반환할 수가 있다.

그러면 실제로는 에러가 났지만,
final response = await dio.fetch(options); 이렇게
새로 요청해서 응답을 받아온 다음 return handler.resolve(response);를
불렀기 때문에 UI(레스토랑 디테일 스크린)에서
현재 이 CustomInterceptor를 적용한 dio를 사용했을 때,
실제로 에러가 나지 않은 것처럼 착각을 줄 수 있음!

profile
호떡 신문지에서 개발자로 환생

0개의 댓글