-
[Flutter] JWT 토큰관리 및 자동로그인 구현하기 feat. Dio Interceptor, Social LoginFlutter/project 2024. 2. 6. 15:09
자동로그인은 앱 개발에 대중적으로 들어가는 기능 중 하나입니다. 현업에서는 신입에게 처음 내주는 흔한 과제이기도 하지만 인증·인가를 생각한다면 쉽게 구현할 수 있는 기능은 아닙니다. 그래서 이번에는 기본적이지만 많은 학습이 필요한 로그인과 토큰 관리에 대해 알아보도록 하겠습니다.
✓JWT 발행 조건 설정 with 소셜로그인
토큰은 인증이 성공되면 그 결과로 발행됩니다. 즉 성공적으로 로그인이 된다면 서버에서 토큰을 발급해주는 로직이 구축되야 하는 것이죠.
이전에 인증은 "유저가 서비스에 자신의 신원을 확인하고 증명하는 과정"이라고 설명했었습니다.
유저의 신원을 확인하기위해선 먼저 유저를 특정할 유니크한 값을 고민해야 합니다.
자체 회원가입만 있는 앱의 경우 유저가 입력한 ID를 식별값으로 쓰면 되기 때문에 고민이 크게 없습니다.
하지만 소셜로그인을 통해 로그인을 해야 할 경우 어떻게 해야 할까요?
이전에 '미리'앱에서는 유저 식별값으로 소셜 이메일+소셜 Type을 사용하고 회원가입 시, 이메일과 유저 전화번호를 이용해 중복검사를 한다고 했던 적이 있습니다.
[Project] 로그인&회원가입 기능을 설계할 때, UX관점에서 고려해야하는 부분
소셜 로그인은 왜 필요한가? 로그인과 회원가입의 경우 앱의 성격에 따라 굉장히 다양하게 흘러간다. '미리'를 제작할 때 비슷한 서비스를 제공하는 앱들을 조사하고 그들이 어떤 형태로 진행고
nomal-dev.tistory.com
그럼 로그인을 시도할때 유저끼리 중복되지 않는 이 두 값을 서버로 넘겨주고 서버에서 클라이언트로 JWT 토큰을 발급해주는 방식으로 진행하면 어떨까요?
'중고나라' 앱 카카오 소셜로그인 중 소셜로그인을 사용하는 다수의 앱에서는 버튼을 누르면 별다른 추가정보 입력 없이 그대로 로그인이 진행됩니다.
즉, 해당 소셜플랫폼에서 제공하는 정보에서 유저 식별값을 추출한다는 의미가 되죠.
모든 소셜플랫폼에서는 이메일을 필수로 제공합니다.
실제로 대부분의 소셜플랫폼에서는 Token 발급이 성공적으로 이루어지면 'email'을 알 수 있으므로 이를 활용해서 다음과 같이 코드를 작성할 수 있습니다.
@riverpod SocialLoginRepository socialLoginRepository(SocialLoginRepositoryRef ref) { return SocialLoginRepository(); } class SocialLoginRepository implements SocialRepositoryImpl { @override Future<Result<UserModel?, CustomExceptions>> socialLogin({ required SocialType socialType, }) async { switch (socialType) { case SocialType.kakao: return _kakao(); case SocialType.naver: return _naver(); case SocialType.apple: return _apple(); } } Future<Result<UserModel?, CustomExceptions>> _kakao() async { return await runCatchingExceptions(() async { bool isInstalled = await isKakaoTalkInstalled(); if (Platform.isIOS) { isInstalled = false; } isInstalled ? await UserApi.instance.loginWithKakaoTalk() : await UserApi.instance.loginWithKakaoAccount(); final User user = await UserApi.instance.me(); final String? userEmail = user.kakaoAccount?.email; await UserApi.instance.unlink(); return userEmail != '' ? UserModel(socialEmail: userEmail!, socialType: 0) : null; }); } Future<Result<UserModel?, CustomExceptions>> _naver() async { return await runCatchingExceptions(() async { final NaverLoginResult res = await FlutterNaverLogin.logIn(); final String userEmail = res.account.email; return userEmail.isNotEmpty ? UserModel(socialEmail: userEmail, socialType: 1) : null; }); } Future<Result<UserModel?, CustomExceptions>> _apple() async { return await runCatchingExceptions(() async { final AuthorizationCredentialAppleID credential = await SignInWithApple.getAppleIDCredential( scopes: [ AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName, ], webAuthenticationOptions: WebAuthenticationOptions( clientId: dotenv.env["APPLE_SERVICE_ID"]!, redirectUri: Uri.parse(""), ), ); final String? userEmail = credential.email; return userEmail != '' ? UserModel(socialEmail: userEmail!, socialType: 2) : null; }); } } // 코드의 안정화를 위해 추가 abstract class SocialRepositoryImpl { Future<Result<UserModel?, CustomExceptions>> socialLogin({ required SocialType socialType, }); }
socialType은 타입만 나누면 되기 때문에 서버에서 요구하는 대로 int 값을 전달하는 방식으로 구성했습니다.
ex. 카카오 ==0, 네이버==1, 애플==2✓ SecureStorage
인증을 통해 전달받은 accessToken과 refreshToken은 어딘가에 안전하게 저장해야 합니다.
Access Token과 Refresh Token을 사용하는 이유와 인증인가 프로세스에 대해 알고 싶다면 아래의 글을 참고해 주세요
JWT를 이용한 인증·인가 프로세스에대해 낱낱히 파헤쳐보자! feat. 토큰, 세션
로그인을 구현하면서 인증과 인가에 대한 부분은 개인적으로도 학습하고, 개발 팀원들과 많이 토론했던 부분입니다. 그때 얻은 인사이트와 학습한 내용을 토대로 정리해보겠습니다. ✓ 인증(Aut
nomal-dev.tistory.com
이때 flutter에서 사용되는 패키지가 바로 SecureStorage입니다.
- SecureStorage
- 민감한 데이터(ex. 사용자 정보, 토큰)를 암호화해서 안전하게 관리한다.
- 주로 보안과 데이터 안전성을 보장하기 위해 사용된다.
flutter_secure_storage | Flutter package
Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android.
pub.dev
SecureStorage는 repository 수준에서 굉장히 자주 쓰이기 때문에 모듈화 해두고 필요할 때마다 repository에 의존성을 주입하면서 사용하면 repository와 책임을 분리할 수 있으며 관리하기가 수월해집니다.
@Riverpod(keepAlive: true) FlutterSecureStorage storage(StorageRef ref) { return const FlutterSecureStorage(); } @Riverpod(keepAlive: true) SecureStorage secureStorage(SecureStorageRef ref) { final FlutterSecureStorage storage = ref.read(storageProvider); return SecureStorage(storage: storage); } class SecureStorage { final FlutterSecureStorage storage; SecureStorage({ required this.storage, }); // 리프레시 토큰 저장 Future<void> saveRefreshToken(String refreshToken) async { try { print('[SECURE_STORAGE] saveRefreshToken: $refreshToken'); await storage.write(key: REFRESH_TOKEN, value: refreshToken); } catch (e) { print("[ERR] RefreshToken 저장 실패: $e"); } } // 리프레시 토큰 불러오기 Future<String?> readRefreshToken() async { try { final refreshToken = await storage.read(key: REFRESH_TOKEN); print('[SECURE_STORAGE] readRefreshToken: $refreshToken'); return refreshToken; } catch (e) { print("[ERR] RefreshToken 불러오기 실패: $e"); return null; } } // 에세스 토큰 저장 Future<void> saveAccessToken(String accessToken) async { try { print('[SECURE_STORAGE] saveAccessToken: $accessToken'); await storage.write(key: ACCESS_TOKEN, value: accessToken); } catch (e) { print("[ERR] AccessToken 저장 실패: $e"); } } // 에세스 토큰 불러오기 Future<String?> readAccessToken() async { try { final accessToken = await storage.read(key: ACCESS_TOKEN); print('[SECURE_STORAGE] readAccessToken: $accessToken'); final refreshToken = await storage.read(key: REFRESH_TOKEN); print('[SECURE_STORAGE] readRefreshToken: $refreshToken'); return accessToken; } catch (e) { print("[ERR] AccessToken 불러오기 실패: $e"); return null; } } }
실제로 레포지토리단에서는 의존성을 주입해 아래와 같이 사용할 수 있습니다.
- final storage = ref.watch(secureStorageProvider);
@Riverpod(keepAlive: true) AuthRepository authRepository(AuthRepositoryRef ref) { final dio = ref.watch(dioProvider); final storage = ref.watch(secureStorageProvider); return AuthRepository(baseUrl: '인증·인가 관련 url', dio: dio, storage: storage); } class AuthRepository { AuthRepository({ required this.baseUrl, required this.dio, required this.storage, }); final String baseUrl; final Dio dio; final SecureStorage storage; Future<Result<void, CustomExceptions>> login( {required UserModel model, required String notificationToken}) async { return runCatchingExceptions(() async { final resp = await dio.post('$baseUrl/login', data: UserModelWithFcmTokenModel( notificationToken: notificationToken, socialEmail: model.socialEmail, socialType: model.socialType) .toJson()); final String accessToken = resp.headers.value('authorization').toString(); final refreshToken = LoginModel.fromJson(resp.data).refreshToken; await Future.wait([ storage.saveAccessToken(accessToken), storage.saveRefreshToken(refreshToken) ]); }); } Future<Result<void, CustomExceptions>> getAccessToken( {required LoginModel model}) async { return runCatchingExceptions(() async { final resp = await dio.post('$baseUrl/refresh', data: model.toJson()); final String accessToken = resp.headers.value('Authorization').toString(); await storage.saveAccessToken(accessToken); }); } Future<Result<void, CustomExceptions>> logout() async { return runCatchingExceptions(() async { final refreshToken = await storage.readRefreshToken(); dio.options.headers["authorization"] = "Bearer $refreshToken"; await dio.post('$baseUrl/logout'); }); } }
❗️주의: 코드 속에 등장하는 'notificationToken'은 푸시 서비스를 사용할 때 'Firebase'에서 발행해주는 토큰입니다. 해당 글과는 관련 없으며 추후에 FCM을 다룰 때 작성해 보겠습니다.
✓ intercepter with Dio
인증이 성공적으로 구축되었다면 다음 단계는 인가에 대한 로직을 구축하는 것입니다.
인가 프로세스는 accessTeken의 유효기간이 짧기 때문에, 만료될 경우 즉시 재발급 후 재요청하는 것이 가장 중요합니다.
이를 통해 사용자는 서비스가 중단되지 않고 지속적으로 사용할 수 있습니다.
accessToken 재발급 프로세스 (ex.장바구니 조회) 서버 측에서는 Spring Boot의 Spring Security를 통해 또는 Nest.js의 Guard를 통해 API가 실행되기 전에 토큰의 유효성을 검사해 보안을 강화하기 때문에 토큰과 관련된 에러처리가 다른 에러보다 상대적으로 우선순위가 높습니다.
클라이언트단도 마찬가지로 Seucrity나 Guard와 유사하게, 에러가 UI에 반영되기 전에 먼저 처리해야 할 필요성이 있습니다.
또한 api 통신할 때마다 적용되어야 하기 때문에 모든 repository에서 사용할 수 있도록 전역 핸들러를 만들어서 관리해야 할 필요성이 있습니다. 이때 사용되는 것이 바로 Dio 패키지에서 제공하는 'interceptor class'입니다.
📌 interceptor class
class Interceptor { void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) => handler.next(options); void onResponse( Response response, ResponseInterceptorHandler handler, ) => handler.next(response); void onError( DioError err, ErrorInterceptorHandler handler, ) => handler.next(err); }
interceptor는 dio를 통해 네트워크 통신이 일어날 때, 이를 가로채 수정을 하거나 추가적인 작업을 할 수 있도록 도와줍니다.
- onRequest()
- 요청을 보내기 전에 실행되는 메서드
- onResponse()
- 응답을 받은 후 실행되는 메서드
- onError()
- 요청 또는 응답 중 에러가 발생했을 때 실행되는 메서드
📌 interceptor의 역할
유저 입장에서는 "1. 장바구니 조회 요청"을 통해 "10. 유저 장바구니 데이터 응답"만 수행된 결과만 알면 됩니다. 노란 박스로 표시된 과정들은 굳이 유저에게 보여주지 않아도 되며 모든 과정을 UI로 반영한다면 오히려 좋지 않은 사용자 경험을 주게 됩니다.
인터셉터는 해당 부분을 유저 몰래 처리해 주는 역할과 요청을 보내기 전에 secureStorage에서 accessToken을 불러와 헤더에 실어주는 역할을 합니다.
그럼 시나리오를 작성해 보겠습니다.
- 먼저 secureStorage에서 accessToken을 불러와서 onRequest()를 통해 Authorization 헤더에 실어서 요청을 보낸다.
- (서버에서 ERROR 반환) onError()에서 토큰 만료 에러인지 체크한다.
- (에센스 토큰 만료 에러 ❌)
- Error를 그대로 전달한다.
- (에세스 토큰 만료 에러 ⭕️)
- secureStorage에서 refreshToken을 읽어와 accessToken 재발급 api를 요청한다.
- accessToken을 성공적으로 반환받으면 secureStorage에 저장 후 실패했던 api를 재요청한다.
- (에센스 토큰 만료 에러 ❌)
🤷🏻♂️ "만약 refreshToken도 만료되었다면 어떻게 해야 하나요?"
원래는 로그인페이지로 라우팅해 유저에게 다시 로그인을 하도록 해야 합니다.
하지만 이런 작업 역시 유저에게 좋지 않은 사용자 경험을 줄 수 있죠.
인증 프로세스도 자의로 인한 로그아웃이 아니라면 유저 모르게 진행되는 게 좋을 것 같다는 생각이 자연스럽게 떠오릅니다.
이때 우린 자동로그인에 대해 고민하게 됩니다.
📌 자동 로그인
자동로그인을 구현하기 위해선 인증에 필요한 데이터(ex. email, socialType)를 미리 디바이스 내에 저장하고 있어야 합니다.
출처: 중고나라 실제로 중고나라 앱을 보면 자동로그인 기능이 켜져 있지만 데이터 삭제했을 때 자동로그인이 되지 않았습니다.
여기서 우리는 타사 앱도 자동로그인을 위해 로그인 시 '유저의 정보'를 미리 저장했을 것이라고 유추할 수 있습니다.
그럼 다시 시나리오를 정리해보겠습니다.
- 먼저 secureStorage에서 accessToken을 불러와서 onRequest()를 통해 Authorization 헤더에 실어서 요청을 보낸다.
- (서버에서 ERROR 반환) onError()에서 토큰 만료 에러인지 체크한다.
- (에센스 토큰 만료 에러 ❌)
- Error를 그대로 전달한다.
- (에세스 토큰 만료 에러 ⭕️)
- secureStorage에서 refreshToken을 읽어와 accessToken 발급 api를 요청한다.
- refreshToken이 만료되면 미리 저장하고 있던 로그인에 필요한 데이터를 통해 refreshToken을 재발급 및 저장 후, accessToken 발급 api를 요청한다.
- accessToken을 성공적으로 반환받으면 secureStorage에 저장 후 실패했던 api를 재요청한다.
- secureStorage에서 refreshToken을 읽어와 accessToken 발급 api를 요청한다.
- (에센스 토큰 만료 에러 ❌)
📌 로그아웃
유저가 로그아웃 기능을 실행하면 어떤 조치를 취해야 할까요?
서버 사이드에서 생각한다면 로그아웃을 한다는 것은 인 메모리DB던 디스크 베이스 DB던 리소스 관리를 위해 유저의 토큰을 삭제하는 과정을 수행하게 됩니다.
그럼 다음과 같이 진행됩니다.
- accessToken과 refreshToken을 삭제하는 api를 요청
- secureStorage에 있는 accessToken과 refreshToken 삭제 (null로 만듦)
✍🏼 로그아웃이 실행되는 경우
로그아웃이 되어야 하는 경우는 버튼을 직접 눌러서 실행할 때도 있지만 다른 케이스도 존재합니다.
- 유저가 로그아웃 버튼을 누른 경우
- 서버의 문제로 인해 토큰 재발급이 제대로 수행되지 않은 경우
- 토큰의 오염으로 인해 서버로부터 에러가 반환된 경우
- 자동로그인 기능에 필요한 데이터가 없는데 토큰이 존재하는 경우
📌 Intercepter 전체 Code
@Riverpod(keepAlive: true) Dio dio(DioRef ref) { final dio = Dio(); final storage = ref.watch(secureStorageProvider); dio.interceptors.add(TokenInterceptor( ref: ref, storage: storage, )); return dio; } class TokenInterceptor extends Interceptor { final SecureStorage storage; final Ref ref; TokenInterceptor({ required this.storage, required this.ref, }); // 1) 요청을 보낼때 @override void onRequest( RequestOptions options, RequestInterceptorHandler handler) async { print('[REQ] [${options.method}] ${options.uri}'); if (options.headers['accessToken'] == 'true') { // 헤더 삭제 options.headers.remove('accessToken'); final token = await storage.readAccessToken(); print('[BEFORE_REQ_HEADER] ${options.headers}'); // 실제 토큰으로 대체 options.headers.addAll({ 'authorization': token, }); print('[REQ_HEADER] ${options.headers}'); print('[REQ_DATA] ${options.data}'); } return super.onRequest(options, handler); } // 2) 응답을 받을때 @override void onResponse(Response response, ResponseInterceptorHandler handler) { print( '[RES] [${response.requestOptions.method}] ${response.requestOptions.uri}'); return super.onResponse(response, handler); } // 3) 에러가 났을때 @override void onError(DioError err, ErrorInterceptorHandler handler) async { print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri} '); final errorModel = ExceptionModel.fromJson(err.response!.data); final UserModel? userInfo = await storage.readSocialInfo(); if (userInfo == null) { storage.logOut(); // 토큰 비우기 return handler.reject(err); } final unAuthorized = errorModel.errorCode == '0007'; final isPathRefresh = err.requestOptions.path == '/auth/refresh'; // false: 에세스 토큰 재발급 필요 final isPathLogin = err.requestOptions.path != '/auth/login'; // false: 리프레시토큰 재발급 필요 if (!unAuthorized || (!isPathRefresh && !isPathLogin)) { return handler.reject(err); } // 에세스 토큰 발급 요청이 아닐 때 => 에세스토큰 만료 확인 // 리프레시 토큰마저 만료일 때 => 리프레시 토큰 만료 확인 final dio = Dio(); final refreshToken = await storage.readRefreshToken(); try { final resp = await dio.post( 'http://$ip/auth/refresh', options: Options( headers: { 'authorization': 'Bearer $refreshToken', }, ), ); final String accessToken = resp.headers.value('authorization').toString(); final options = err.requestOptions; // 토큰 변경하기 options.headers.addAll({ 'authorization': 'Bearer $accessToken', }); await storage.saveAccessToken(accessToken); // 요청 재전송 final response = await dio.fetch(options); return handler.resolve(response); } on DioError catch (e) { final errorModel = ExceptionModel.fromJson(e.response!.data); print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri}'); print('[ERR_NUMBER] ${errorModel.errorCode}'); final unAuthorized = errorModel.errorCode == '0007'; if (!unAuthorized) { return handler.reject(e); } // 리프레시 토큰 에러 try { final userInfo = await storage.readSocialInfo(); final resp = await dio.post('http://$ip/auth/login', data: userInfo!.toJson()); final String accessToken = resp.headers.value('authorization').toString(); final refreshToken = LoginModel.fromJson(resp.data).refreshToken; await Future.wait([ storage.saveAccessToken(accessToken), storage.saveRefreshToken(refreshToken) ]); final options = err.requestOptions; options.headers.addAll({ 'authorization': 'Bearer $accessToken', }); final response = await dio.fetch(options); return handler.resolve(response); } catch (e) { // 로그인 불가능 -> 로그인 화면으로 라우팅 print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri}'); print('[ERR_NUMBER] ${errorModel.errorCode}'); await ref.read(authControllerProvider.notifier).logout(); return handler.reject(err); } } } }
✍🏼 onRequest()
- void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
if (options.headers ['accessToken'] == 'true') {
options.headers.remove('accessToken');
final token = await storage.readAccessToken();
print('[BEFORE_REQ_HEADER] ${options.headers}');
options.headers.addAll({
'authorization': token,
});
print('[REQ_HEADER] ${options.headers}');
print('[REQ_DATA] ${options.data}');
}
return super.onRequest(options, handler);
}
onRequest()는 secureStorage에서 액세스 토큰을 가져와 보내려고 하는 api의 헤더에 넣어주는 역할로 사용됩니다.
'accessToken'이라는 key와 'true'라는 value는 단순히 인터셉터에서 올바른 양식으로 바꿔주기 위해 작성된 부분입니다.
final resp = await dio.get( "$baseUrl/$id/메뉴 목록", // 인터셉터에서 accessToken이라는 헤더의 'true'라는 문자열을 감지해서 값을 바꾸기 위한 조치 options: Options(headers: {'accessToken': 'true'}), );
header: {key:value}는 인터셉터에서 감지만 가능하면 되기 때문에 아무 값이나 넣어도 상관없습니다.
- repository api method() 중
- options: Options(headers: {'씰룩거리기': '플러터'}),
- onRequest()
- if (options.headers ['씰룩거리기'] == '플러터')
✍🏼 onError()
인터셉터의 핵심 로직이 있는 부분입니다. 코드 작성 시 api통신을 제외한 나머지 부분에서 주의해야 할 사항을 적어보겠습니다.
- final errorModel = ExceptionModel.fromJson(err.response!. data);
- 저희 프로젝트는 에러 발생 시 서버에서 모든 에러를 핸들링한 후에 custom errorCode를 반환해 주기 때문에 토큰만료 에러 코드를 사용하기 위해 errorResponse를 model로 받는 부분입니다.
에러를 반환해 주는 방법은 서버마다 다르기 때문에 이 부분은 다를 수 있습니다.
- 저희 프로젝트는 에러 발생 시 서버에서 모든 에러를 핸들링한 후에 custom errorCode를 반환해 주기 때문에 토큰만료 에러 코드를 사용하기 위해 errorResponse를 model로 받는 부분입니다.
- final UserModel? userInfo = await storage.readSocialInfo();
if (userInfo == null) {
storage.logOut();
return handler.reject(err);
}- 자동로그인을 위한 데이터는 토큰들을 발급받기 위해 사용되는 것이므로 어떤 이유로든 토큰이 있을 때 이 데이터가 없어선 안됩니다. 혹시나 이러한 버그가 생겼을 경우 토큰을 삭제 처리하는 부분입니다.
- final unAuthorized = errorModel.errorCode == '0007';
final isPathRefresh =
err.requestOptions.path != '/auth/refresh'; // false: 에세스 토큰 재발급 필요
final isPathLogin =
err.requestOptions.path != '/auth/login'; // false: 리프레시토큰 재발급 필요
if (!unAuthorized || (!isPathRefresh && !isPathLogin)) {
return handler.reject(err);
}
- 토큰 재발급 요청 경로가 아닌 경우에만 재발급 로직이 필요하기 때문에 이외의 상황들은 제외해 줍니다.
(저 같은 경우는 편의상 '에러 헤드 퍼스트' 패턴으로제외해 두었습니다)
- 토큰 재발급 요청 경로가 아닌 경우에만 재발급 로직이 필요하기 때문에 이외의 상황들은 제외해 줍니다.
Error Head First의 장점: 빠른 대응과 신속한 진단으로 시스템의 안전성을 향상하고 사용자 경험을 개선하는데 도움이 된다.
- catch (e) {
// 로그인 불가능 -> 로그인 화면으로 라우팅
print('[ERR] [${err.requestOptions.method}] ${err.requestOptions.uri}');
print('[ERR_NUMBER] ${errorModel.errorCode}');
await ref.read(authControllerProvider.notifier).logout();
return handler.reject(err);
}- 서버의 문제로 refresh 토큰 재발급까지 실패한다면 현재 서비스를 유지할 수 없습니다. 따라서 로그인 화면으로 리다이렉트 시켜주는 코드를 추가해주어야 합니다.
오늘은 interceptor가 왜 필요하고 어떻게 구현해야 하는지에 대해 알아보았습니다. 인터셉터는 서비스의 품질과 유저의 사용자 경험을 향상하는데 도움을 줍니다. 마지막으로 사용자 로그인 상태를 확인하고 로그인화면으로 리다이렉트 되는 과정까지 다루고 싶었는데 이 파트에서는 크게 중요한 게 아니라서 다음에 따로 다뤄보도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] 무한스크롤 성능 최적화를 해보자 feat. Lazy Loading, Throttle #3 (0) 2024.02.13 [Flutter] 무한 스크롤 구현해보기 feat. cursor based pagination #2 (0) 2024.02.09 [Flutter] 위젯, 뷰, 컴포넌트, 스크린 어떤 차이일까? UI 구조를 잡아보자! (0) 2024.01.30 [Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#3 (Presentation-Layer) (0) 2024.01.30 [Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#1 (Data, Domain) (1) 2024.01.22 - SecureStorage