[Flutter] Token Login / Auth 구현하기

Token Login / Auth 구현하기

https://nx006.tistory.com/64

 

Session VS Token Authentication - feat. JWT 기술

Authentication 앱을 만들 때 회원가입/로그인 기능을 구현해야 할 때가 있습니다. 인증 및 인가된 사용자에게 앱의 기능을 사용할 수 있게 하기 위해서는, 로그인이라는 Authentication(인증) 기능을 거

nx006.tistory.com

이전 글에서 Session 로그인과 Token Login 방식에 대해서 알아봤습니다. 이번 글에서는 어떻게 하면 Flutter에서 Token 로그인 방식을 관리할 수 있는지, 클라이언트 관점에서 구현해보겠습니다.

 

이 방법은 코드팩토리 님의 Flutter 강의를 듣고서 정리한 글입니다.

시작하기 전

가독성을 위해 import 문과 part 문은 제외하였습니다.

 

마찬가지로 가독성을 위해서 코드의 일부를 생략하기도 하였습니다 (관심을 갖지 않는 함수를 //... 으로 표현하는 등).

 

이 글에 나오는 외부 라이브러리들은 다음과 같습니다.

dependencies:
  flutter:
    sdk: flutter
    json_annotation: ^4.8.0
    dio: ^5.2.1+1
  flutter_secure_storage: ^8.0.0
  retrofit: '>=4.0.0 <5.0.0'
    flutter_riverpod: ^2.3.6
    freezed_annotation: ^2.4.1
  go_router: ^10.0.0

dev_dependencies:
  build_runner: ^2.3.3
  json_serializable: ^6.7.1
  retrofit_generator: ^5.0.0
  freezed: ^2.4.1

API 분석하기

우선 가장 먼저 API 부터 따야 합니다.

Swagger API
Swagger API

Swagger와 같은 API 문서가 있으면 이를 통해 확인할 수 있습니다. 혹은 백엔드 개발하시는 분과 API를 정해놓은 대로 하면 됩니다.

Auth를 보면, /auth/login, auth/token 두 URL이 있습니다.

/auth/login

authorization 헤더에 Basic Token을 담아서 보냅니다. Basic Token은 유저 식별자와 비밀번호 정보를 담고 있는, Base64로 인코딩된 토큰이었습니다.

 

서버에서는 이를 받고서 유효한 토큰이라면, Access Token과 Refresh Token을 담아 보냅니다.

Response:

{
  "accessToken": "asdiofjzxl;ckvjoiasjewr.asdfoiasjdflkajsdf.asdfivjiaosdjf",
  "refreshToken": "asdiofjzxl;ckvjoiasjewr.asdfoiasjdflkajsdf.asdfivjiaosdjf"
}

/auth/token

이는 토큰을 Refresh 하는 주소입니다. Auth 토큰은 매우 짧은 시간(예를 들어 5분) 안에 만료되기에, 유저가 원활하게 서비스를 이용하기 위해서 이 주소를 통해서 access token을 새로 발급받을 수 있도록 합니다.

 

authorization 헤더에 refresh token을 담아서 bearer로 보냅니다.

 

서버에서는 유효한 새로운 access token을 발급합니다.

Response:

{
  "accessToken": "asdiofjzxl;ckvjoiasjewr.asdfoiasjdflkajsdf.asdfivjiaosdjf"
}

모델 만들기

유저 모델

사실 Authorization에 관한 코드는 UserModel 없이도 만들 수 있긴 한데, 어차피 사용자 정보를 관리하는 코드를 만들다보면 UserModel은 만들게 돼있으므로 여기부터 시작합니다.

 

유저 모델은 다음과 같이 만들 수 있습니다.

class UserModel {
  final String id;
  final String username;
  final String imageUrl;

  UserModel({
    required this.id,
    required this.username,
    required this.imageUrl,
  });

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'],
      username: json['username'],
      imageUrl: json['imageUrl'],
    );
  }
}

그런데 이전 글에서 소개했듯 저는 코드 제너레이션을 선호하므로, freezed 를 이용해서 클래스를 다시 구성했습니다.

@freezed
class UserModel with _$UserModel {
  const factory UserModel({
    required String id,
    required String username,
        required String imageUrl,
  }) = _UserModel;

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

상태 패턴 적용

예전에 Pagination을 다룬 글에서, 상태 모델을 만드는 패턴을 소개한 적이 있습니다. 유저 모델에도 같은 패턴을 적용하고자 합니다.

abstract class UserModelBase {}

@freezed
class UserModel extends UserModelBase with _$UserModel {
  // 나머지는 모두 동일
}

UserModelBase abstract class를 만든 뒤에, UserModel이 이를 extends합니다.

그리고서 나머지 UserModel이 가질 수 있는 다른 상태를 정의합니다.

class UserModelError extends UserModelBase {
  final String message;

  UserModelError({
    required this.message,
  });
}

class UserModelLoading extends UserModelBase {}

UserModelErrorUserModelLoading 상태를 정의해두었습니다.

LoginResponse 모델 만들기

{
  "accessToken": "asdiofjzxl;ckvjoiasjewr.asdfoiasjdflkajsdf.asdfivjiaosdjf",
  "refreshToken": "asdiofjzxl;ckvjoiasjewr.asdfoiasjdflkajsdf.asdfivjiaosdjf"
}

/auth/login를 했을 때 서버에서 반환되는 위와 같은 Response를 담는 모델을 만들어야 합니다. 이를 LoginResponse라 합니다.

@freezed
class LoginResponse with _$LoginResponse {
  const factory LoginResponse({
    required String accessToken,
    required String refreshToken,
  }) = _LoginResponse;

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

TokenModel

{
  "accessToken": "asdiofjzxl;ckvjoiasjewr.asdfoiasjdflkajsdf.asdfivjiaosdjf"
}

/auth/token을 했을 때 서버에서 반환되는 Response는 위와 같습니다. 이를 TokenResponse라 하겠습니다.

@freezed
abstract class TokenResponse with _$TokenResponse {
  const factory TokenResponse({
    required String accessToken,
  }) = _TokenResponse;

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

Repository 구현

Auth Repository 구현

먼저 로그인부터 구현합니다.

로그인과 관련된 레포지토리는 AuthRepository 에서 처리를 하겠습니다.

import 'dart:convert';
import 'package:dio/dio.dart';

class AuthRepository {
  final String baseUrl;
  final Dio dio;

  AuthRepository({
    required this.baseUrl,
    required this.dio,
  });

  Future<LoginResponse> login({
    required String username,
    required String password,
  }) async {
        final serialized = base64Encode(utf8.encode('$username:$password'));

    final response = await dio.post(
      '$baseUrl/login',
      options: Options(
        headers: {
          'authorization': 'Basic $serialized',
        },
      ),
    );

    return LoginResponse.fromJson(response.data);
  }
}

AuthRepository는 baseUrl(여기서는 http://{주소}/auth/)과 dio를 받습니다.

dio를 함수에서 새로 생성하지 않고서 굳이 DI(Dependency Injection)으로 외부에서 Injection하는 이유는 추후에 다시 나옵니다.

코드를 하나씩 설명하자면:

final serialized = base64Encode(utf8.encode('$username:$password'));

먼저 username:password 문자열을 base64로 인코딩한 뒤에, 이를 basic username:password 형태로 서버로 전송합니다.

 

Dart에서는 base64 인코딩을 간단하게 base64Encode 함수로 구현할 수 있습니다.

 

base64Encode(List<int> bytes) 함수는 List<int> 타입의 bytes를 받는데, 그래서 String을 utf8.encode로 변환해주어야 합니다.

await dio.post(
  '$baseUrl/login',
  options: Options(
    headers: {
      'authorization': 'Basic $serialized',
    },
  ),
);

그리고 이를 authorization header에 담아서 보내야 하는데, 이때는 dio 중에 options 안에 headers로 넘겨줍니다. headers는 map 형태로 값을 넘겨주면 됩니다.

 

그리고 response 받아서 반환해주면 됩니다.

UserMeRepository 구현

UserMeRepository는 현재 로그인된 유저 정보를 반환하는 레포지토리입니다.

 

이 부분은 비교적 로직이 간단하므로 Retrofit을 이용해서 구현합니다.

import 'package:dio/dio.dart' hide Headers;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:retrofit/retrofit.dart';

part 'user_me_repository.g.dart';

final userMeRepositoryProvider = Provider<UserMeRepository>((ref) {
  final dio = ref.watch(dioProvider);

  return UserMeRepository(
    dio,
    baseUrl: 'http://$ip/user/me',
  );
});

@RestApi()
abstract class UserMeRepository {
  factory UserMeRepository(Dio dio, {String baseUrl}) = _UserMeRepository;

  @GET('/')
  @Headers({'accessToken': 'true'}) // Header 부분은 이후에 설명할 예정
  Future<UserModel> getMe();
}

Login 로직 구현

SecureStorage에 보관하기

이렇게 만든 Token을 메모리 상에서만 들고 있다가, 날려먹기에는 아깝겠죠. Secure Storage에 보관해야 합니다.

 

Secure Storage에 보관하는 이유는, Token같은 민감한 정보의 경우 일반 파일에 보관할 시 보안 상의 위협이 있기 때문입니다. 특히 모바일 환경에서는 보안에 대해서 민감하게 신경써야 합니다.

 

Flutter Secure Storage는 pub.dev에서 확인해볼 수 있습니다.

$ flutter pub add flutter_secure_storage

Secure Storage는 Riverpod Provider를 이용해서 싱글턴 인스턴스처럼 관리하겠습니다.

/// secure_storage.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
  return const FlutterSecureStorage();
});

Secure Storage는 다음과 같이 사용할 수 있습니다.

/// AuthRepository의 `login` 함수에서 토큰 가져오기
final response = await authRepository.login(
  username: username,
  password: password,
);

/// accessTokenKey는 'access_token' 문자열(하드 코딩 방지용)
/// value에는 실제 Access Token 기록
await storage.write(key: accessTokenKey, value: response.accessToken);

/// refreshTokenKey는 'refresh_token' 문자열(하드 코딩 방지용)
/// value에는 실제 Access Token 기록
await storage.write(key: refreshTokenKey, value: response.refreshToken);

Secure Storage는 key - value 형태로 데이터를 보관합니다. 데이터를 쓸 때는 write로, 읽을 때는 read 함수를 씁니다.

 

이때 key에는 String이 들어가지만, 어차피 Access Token, Refresh Token에 쓰이는 키는 여러 곳에서 사용되기에, 단순 문자열로 하드코딩하는 것보다 어딘가에 변수로 저장해두는 게 좋습니다.

// const.dart

/// access token key for secure storage
const accessTokenKey = 'access_token';

/// refresh token key for secure storage
const refreshTokenKey = 'refresh_token';

Login 로직 구현하기

로그인을 담당하는 비즈니스 로직은 Riverpod Provider를 이용해서 구현하고 있습니다.

/// [AuthRepository], [UserMeRepository], [FlutterSecureStorage]는 외부 주입으로 받음
class UserMeStateNotifier extends StateNotifier<UserModelBase?> {
  final AuthRepository authRepository;
  final UserMeRepository userMeRepository;
  final FlutterSecureStorage storage;
  UserMeStateNotifier({
    required this.authRepository,
    required this.userMeRepository,
    required this.storage,
  }) : super(UserModelLoading()) { // 첫 User State는 Loading
        // 이 부분은 이후에 구현
    getMe();
  }

  Future<UserModelBase> login({
    required String username,
    required String password,
  }) async {
    try {
            // 첫 state는 Loading 상태
      state = UserModelLoading();

            // 이 부분은 앞에서 본 부분과 동일
      final response = await authRepository.login(
        username: username,
        password: password,
      );

            // secure storage에 Token 보관
      await storage.write(key: accessTokenKey, value: response.accessToken);
      await storage.write(key: refreshTokenKey, value: response.refreshToken);

      final userResponse = await userMeRepository.getMe();

            // 현 state를 userReponse로 받음
      state = userResponse;

      return userResponse;
    } catch (e) {
      state = UserModelError(message: '로그인 실패: $e');

            // 반환되는 값은 `UserModelError`임
      return Future.value(state);
    }
  }
}

AuthRepository, UserMeRepository, FlutterSecureStorage는 외부에서 주입받고 있습니다.

final userMeProvider =
    StateNotifierProvider<UserMeStateNotifier, UserModelBase?>((ref) {
  final authRepository = ref.watch(authRepositoryProvider);
  final userMeRepository = ref.watch(userMeRepositoryProvider);
  final storage = ref.watch(secureStorageProvider);

  return UserMeStateNotifier(
    authRepository: authRepository,
    userMeRepository: userMeRepository,
    storage: storage,
  );
});

이게 Provider인데, 각 레포지토리들을 개별 Provider한테서 불러와서 Inject합니다.

Logout 로직 구현하기

class UserMeStateNotifier extends StateNotifier<UserModelBase?> {
  final AuthRepository authRepository;
  final UserMeRepository userMeRepository;
  final FlutterSecureStorage storage;

    /// ...

    Future<UserModelBase> login() async // ...

    Future<void> logout() async {
        // 로그아웃 시 User 상태를 null로 초기화
    state = null;

        // Secure Storage에서 Access Token과 Refresh Token 삭제
    await Future.wait([
      storage.delete(key: accessTokenKey),
      storage.delete(key: refreshTokenKey),
    ]);
  }
}

Dart 언어의 팁이라면, 만약에 두 Async 함수를 동시에 실행시키면서 await 하고 싶은 경우, Future.wait 함수를 이용할 수 있습니다.

 

이 경우 wait 함수 안에 있는 storage.delete(key: accessTokenKey)storage.delete(key: refreshTokenKey)가 동시에 실행되고, 둘 다 끝나면 await가 풀리고 다음 코드가 실행됩니다.

Access / Refresh Token 사용하기

Dio Interceptor

이제 발급받은 Access Token을 사용하고, Refresh 로직을 구현할 차례입니다.

 

Token을 사용할 때는 Basic이 아닌 Bearer로 헤더에 담아서 보냅니다.

 

그런데 Token을 사용하는 로직은 인증이 필요한 모든 곳에 사용됩니다. 따라서 인증이 필요한 모든 기능에 이 로직이 자동으로 추가되길 원합니다.

 

DioInterceptor를 구현한다면, Dio를 통해서 http 통신을 수행할 때마다, 이 로직을 추가할 수 있습니다.

 

Interceptor는 HTTP Request, Response, Error 시에 중간에 끼어들어서, 새로운 행동을 추가할 때 사용합니다.

 

기존 객체의 로직이 A → B 였으면, Interceptor로 C를 정의하고 이를 기존 객체에 추가하여서, A → C → B 라는 로직으로 변경시킬 수 있습니다.

final dioProvider = Provider<Dio>((ref) {
  final dio = Dio();

  final storage = ref.watch(secureStorageProvider);

  dio.interceptors.add(
    CustomInterceptor(storage: storage, ref: ref),
  );

  return dio;
});

위와 같이 dioProvider를 구성합니다. dio.interceptors.add 부분에서 Interceptor를 추가할 수 있습니다.

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

  CustomInterceptor({
    required this.ref,
    required this.storage,
  });

    @override
  Future<void> onRequest // ...

    @override
  void onResponse // ... onResponse는 이 글에서는 구현할 게 없음

    @override
  void onError // ...

Interceptor를 extends하는 CustomInterceptor를 만들고, http Request를 보낼 때와 http response에서 error가 났을 때의 로직을 추가합니다.

onRequest

@override
Future<void> onRequest(
  RequestOptions options,
  RequestInterceptorHandler handler,
) async {
  if (options.headers['accessToken'] == 'true') {
    // 헤더 삭제
    options.headers.remove('accessToken');

    final token = await storage.read(key: accessTokenKey);

    // 실제 토큰으로 대체
    options.headers.addAll({
      'authorization': 'Bearer $token',
    });
  } else if (options.headers['refreshToken'] == 'true') {
    // 헤더 삭제
    options.headers.remove('refreshToken');

    final token = await storage.read(key: refreshTokenKey);

    // 실제 토큰으로 대체
    options.headers.addAll({
      'authorization': 'Bearer $token',
    });
  }

  super.onRequest(options, handler);
}

onRequest 로직은 다음과 같이 동작합니다.

  1. 헤더에 {'accessToken': 'true'} 가 있다면:
    • accessToken 헤더를 삭제합니다(실제 요청 시 불필요한 HTTP 헤더 없애기)
    • Secure Storage에서 저장된 Access Token을 읽어서, {'authorization': Bearer $token'} 헤더를 추가합니다
  2. 헤더에 {'refreshToken': 'true'} 가 있다면:
    • refreshToken 헤더를 삭제합니다(이유는 위와 동일)
    • Secure Storage에서 저장된 Refresh Token을 읽어서, {'authorization': Bearer $token'} 헤더를 추가합니다
  3. 마지막으로 super.onRequest로 요청 전송

onError

onError는 언제 발생할 수 있냐면, Access Token 혹은 Refresh Token이 만료되었을 시, 401 에러가 발생할 수 있습니다.

각 상황에 따라 다음 처리를 할 수 있습니다.

  1. Access Token이 만료되었다면
    • Refresh Token을 이용해서, 새로운 Access Token을 발급받습니다.
    • 성공 시 Secure Storage에서 새로운 Access Token을 덮어씁니다.
    • 새로운 Access Token을 이용해서, 기존에 시도했던 HTTP Request를 다시 요청합니다.
  2. Refresh Token이 만료되었다면(위 과정에서도 한 번 더 401 에러 발생 시)
    • 이때는 그 어떤 토큰도 믿을 수 없는 상황입니다.
    • 앱에서 유저를 로그아웃시키고, 발생한 에러를 그대로 발생시킵니다.
  3. 401 에러가 아니라면
    • 이때는 인증 관련된 에러가 아니므로, 발생한 에러를 그대로 내보냅니다. 여기서 신경 쓸 에러는 아닙니다.

위 로직을 아래 코드에 담았습니다:

@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
  // 401 에러가 발생했을 때 (status code)
  // 토큰을 재발급받는 시도를 하고, 토큰이 재발급되면
  // 다시 새로운 토큰을 요청한다.
  final refreshToken = await storage.read(key: refreshTokenKey);

  // refreshToken이 null이면 에러 반환
  if (refreshToken == null) {
    return handler.reject(err);
  }

  final isStatus401 = err.response?.statusCode == 401;
  final isPathRefresh = err.requestOptions.path == '/auth/token';

  // token을 refresh하려는 의도가 아니었는데 401 에러가 발생했을 때
  if (isStatus401 && !isPathRefresh) {
    // 기존의 refresh token으로 새로운 accessToken 발급 시도
        // 반드시 새로운 Dio 객체를 생성해야 함
    final dio = Dio();

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

      final accessToken = response.data['accessToken'];

      final options = err.requestOptions;

      // 요청의 헤더에 새로 발급받은 accessToken으로 변경하기
      options.headers.addAll({
        'authorization': 'Bearer $accessToken',
      });

      // secure storage도 update
      await storage.write(key: accessTokenKey, value: accessToken);

      // 원래 보내려던 요청 재전송
      final newResponse = await dio.fetch(options);

      return handler.resolve(newResponse);
    } on DioException catch (e) {
            // 새로운 Access Token임에도 에러가 발생한다면, Refresh Token마저도 만료된 것임
      ref.read(authProvider.notifier).logout();

      return handler.reject(e);
    }
  }

  return handler.reject(err);
}

여기서 질문.

📌 왜 새로운 Dio 인스턴스를 이용하나요?

주의할 점인데, DioInterceptor 안에서 새로운 Dio(인스턴스 2번이라 합시다)를 추가해서, HTTP Request를 보내고 있습니다. 왜냐면 이 Interceptor는 Dio Instance 1번에 추가할 Interceptor를 구현하고 있는데, 이 Interceptor 내부에서 1번 인스턴스를 그대로 다시 사용한다면 다시 onError 함수에 빠지는 순환 오류가 발생합니다. 그래서 새로운 Dio를 생성해야 합니다.

 

만약에 새로운 Access Token을 받고서 다시 보낸 요청이 정상적으로 끝났을 경우에는 에러를 내보낼 필요가 없습니다. 이때는 handler.resolve 함수를 통해서, 정상적으로 요청이 일어난 척 Dio를 속입시다.

 

그리고 Refresh Token이 만료된 경우에는, handler.reject 함수를 통해서 에러를 처리하지 않고 다시 일으킵니다.

 

이러한 과정을 통해서, Access Token이 만료되어서 401 에러가 발생했을 때의 에러 처리를, 필요할 때마다 다시 구현하지 않고서, 자동적으로 처리되도록 구현하였습니다.

Access Token, Refresh Token 사용하기

Auth Repository

아까전에 만든 Auth Repository에서, Refresh Token을 다시 발급받는 레포지토리 로직을 새로 추가합니다.

class AuthRepository {
  final String baseUrl;
  final Dio dio;

    // ...

    Future<LoginResponse> login() // 위와 동일

    Future<TokenResponse> token() async {
    final response = await dio.post(
      '$baseUrl/token',
      options: Options(
        headers: {
          'refreshToken': 'true',
        },
      ),
    );

    return TokenResponse.fromJson(response.data);
  }
}    

Dio에 Refresh Token에 관한 onRequest 로직을 Interceptor에 추가했으므로, 별도의 로직을 구현할 필요도 없이 간단하게 Token 로직을 구현할 수 있습니다.

UserMeRepository

아까 구현한 UserMeRepository도 다시 봅시다.

final userMeRepositoryProvider = Provider<UserMeRepository>((ref) {
  final dio = ref.watch(dioProvider);

  return UserMeRepository(
    dio,
    baseUrl: 'http://$ip/user/me',
  );
});

@RestApi()
abstract class UserMeRepository {
  factory UserMeRepository(Dio dio, {String baseUrl}) = _UserMeRepository;

  @GET('/')
  @Headers({'accessToken': 'true'})
  Future<UserModel> getMe();

  @GET('/basket')
  @Headers({'accessToken': 'true'})
  Future<List<BasketItemModel>> getBasket();

  @PATCH('/basket')
  @Headers({'accessToken': 'true'})
  Future<List<BasketItemModel>> patchBasket({
    @Body() required PatchBasketBody body,
  });
}

여기서도 @Headers({'accessToken': 'true'}) Annotation을 붙여주는 것만으로도, 가볍게 Access Token 인증이 필요한 Request를 구현하고 있습니다.

 

그 외 인증 로직이 필요한 모든 곳에서, 매번 Secure Storage에서 Token 가져오기, 401 에러 처리하기 등 복잡한 과정을 생각할 필요 없이 Annotation 하나만 붙여줘서 끝낼 수 있습니다.

Auth Provider

선택적인 사항이지만, 이 앱에서는 Auth Provider를 통해서 현재 유저 정보가 있는 지 없는 지에 따라서, Redirect 로직을 구현하고 있습니다.

final authProvider = ChangeNotifierProvider((ref) => AuthNotifier(ref: ref));

class AuthNotifier extends ChangeNotifier {
  final Ref ref;
  AuthNotifier({
    required this.ref,
  }) {
    ref.listen<UserModelBase?>(userMeProvider, (previous, next) {
      // userMeProvider에서 변경 사항이 생겼을 때만
      // authProvider에서 이를 감지
      if (previous != next) {
        notifyListeners();
      }
    });
  }

  void logout() {
    ref.read(userMeProvider.notifier).logout();
  }

  /// 앱을 처음 시작했을 때 토큰이 존재하는 지를 확인하고,
  /// [LoginScreen] 또는 [HomeScreen]으로 이동한다.
  ///
  /// - Token이 존재: [HomeScreen]
  /// - Token이 존재하지 않음: [LoginScreen]
  String? redirectLogic(BuildContext _, GoRouterState goState) {
    final user = ref.read(userMeProvider);

    final logginIn = goState.fullPath == '/login';

    // 유저 정보가 없는데 로그인 중이면 그대로 로그인 페이지에 둔다
    // 만약에 로그인이 아니라면, 로그인 페이지로 이동시킨다
    if (user == null) {
      return logginIn ? null : '/login';
    }

    // user가 null이 아님
    // 사용자 정보가 존재함
    // 로그인 중이거나 현재 위치가 SplashPage라면 home으로 이동
    if (user is UserModel) {
      switch (logginIn || goState.fullPath == '/splash') {
        case true:
          return '/';
        case false:
          return null;
      }
    }

    // userModelError일 때
    if (user is UserModelError) {
      return logginIn ? null : '/login';
    }

    return null;
  }
}

기본 아이디어는, UserModel을 관리하는 userMeProvider에 변화가 생겼을 때 이를 감지하여 Redirection 로직을 구현합니다.

 

만약에 user state가 UserModel이라면 로그인이 이미 되었단 뜻이므로 진입하고자 하는 화면에 진입하고, 그게 아니라 null이거나 UserModelError라면 login 페이지로 이동합니다.

final routerProvider = Provider<GoRouter>((ref) {
  final provider = ref.read(authProvider);
  return GoRouter(
    routes: _routes,
    initialLocation: '/splash',
    refreshListenable: provider,
    redirect: provider.redirectLogic,
    debugLogDiagnostics: true,
  );
});

마지막으로 이 authProvider.redirectLogicGoRouterredirect에 전달되어서, 최종적인 라우팅 로직을 완성합니다.

Login Page

마지막으로 Login Page View를 구현할 때는 이렇게 구현하였습니다.

@override
Widget build(BuildContext context) {
  final state = ref.watch(userMeProvider);

  return 
    // ...
    ElevatedButton(
    onPressed: state is UserModelLoading
      ? null
      : () async {
        await ref.read(userMeProvider.notifier).login(
          username: username,
          password: password,
        );
      },
    child: const Text(
      '로그인',
    ),
  ),
}

로그인 버튼인데, 만약에 userMeProvider의 state가 UserModelLoading이라면 버튼을 비활성화시키고, 아니라면 onPressed에 userMeProvider login 로직을 호출합니다.

 

이렇게 하면 장점이, 로그인 버튼을 눌렀을 때, User 상태가 Loading 상태가 되면서, 버튼이 비활성화됩니다. 그래서 유저가 여러 번 버튼을 누르지 않도록 UI를 구현할 수 있습니다.

 

로그인 버튼 클릭 시 비활성화 효과
로그인 버튼 클릭 시 비활성화 효과

위 gif에서 로그인 버튼을 누르는 순간 버튼이 비활성화되는 것을 볼 수 있습니다.

정리

폴더 구조

lib
│   ├── const
│   │   └── data.dart
│   ├── dio
│   │   └── dio.dart
│   ├── model
│   │   ├── login_response.dart
│   │   └── token_response.dart
│   ├── provider
│   │   └── router_provider.dart
│   ├── secure_storage
│   │   └── secure_storage.dart
│   └── utils
│       └── data_utils.dart
├── main.dart
└── user
    ├── model
    │   └── user_model.dart
    ├── provider
    │   ├── auth_provider.dart
    │   └── user_me_provider.dart
    ├── repository
    │   ├── auth_repository.dart
    │   └── user_me_repository.dart
    └── view
        └── login_page.dart

위에서 소개된 코드들을 위와 같은 폴더 구조로 담았습니다.

 

참고 자료