[플러터] 21. MVVM 패턴 - (2) 로그인, 자동 로그인, 로그아웃

KangHo Lee's avatar
Jan 03, 2025
[플러터] 21. MVVM 패턴 - (2) 로그인, 자동 로그인, 로그아웃
위 게시물에서 이어집니다.

1. 로그인

1) LoginPage

login_body.dart
const SizedBox(height: largeGap), CustomElevatedButton( text: "로그인", click: () { gvm.login(_username.text.trim(), _password.text.trim()); }, ),

2) sessionGVM (로그인 메서드)

session_gvm.dart
class SessionUser { int? id; String? username; String? accessToken; bool? isLogin; SessionUser({this.id, this.username, this.accessToken, this.isLogin = false}); } // GVM -> Global View Model class SessionGVM extends Notifier<SessionUser> { // main.dart에서 설정한 navigatorKey 를 가져온다. final mContext = navigatorKey .currentContext!; UserRepository userRepository = const UserRepository(); @override SessionUser build() { return SessionUser( id: null, username: null, accessToken: null, isLogin: false); } Future<void> login(String username, String password) async { // Map 안 쓰는 이유 -> Dart는 타입 추론이 가능 final requestBody = { "username": username, "password": password, }; // 구조 분해 할당으로 responseBody와 accessToken 모두 반환 final (responseBody, accessToken) = await userRepository.findByUsernameAndPassword(requestBody); if (!responseBody["success"]) { // 로그인 실패 ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("로그인 실패 : ${responseBody["errorMessage"]}")), ); return; } // 로그인 코드 아래쪽에 작성 // 화면 전환 Navigator.popAndPushNamed(mContext, "/post/list"); } } final sessionProvider = NotifierProvider<SessionGVM, SessionUser>(() { return SessionGVM(); });
  • final requestBody = { "username": username, "password": password, };
    • Map<String, String> 생략 가능한 이유 → Dart는 타입 추론이 가능하기 때문입니다.
    • final은 불변이라 붙였습니다.
  • login 요청 시 돌아오는 response 예시
HTTP/1.1 200 OK // 이외 header 생략 Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpbWdVcmwiOiIvaW1hZ2VzLzEucG5nIiwic3ViIjoibWV0YWNvZGluZyIsImlkIjoxLCJleHAiOjE3MzU5Njc1NzQsInVzZXJuYW1lIjoic3NhciJ9.-xTRkcd_0AE2lEpkj0Q4e7ooDpzVIPAtQlV4H8OPUHZGWQJfAY5i1xDEl-BJ8PWUvr4B5R6l5SOw-R7xd-friQ Content-Type: application/json;charset=UTF-8 Content-Length: 166 { "success" : true, "response" : { "id" : 1, "username" : "ssar", "imgUrl" : "/images/1.png" }, "status" : 200, "errorMessage" : null }
  • success로 로그인 성공 여부 판별이 가능합니다.

로그인 코드

// 1. 토큰을 Storage 저장 -> flutter_secure_storage 라이브러리 필요 await secureStorage.write( key: "accessToken", value: accessToken); // I/O -> 오래 걸린다. -> await // 2. SessionUser 갱신 Map<String, dynamic> data = responseBody["response"]; state = SessionUser( id: data["id"], username: data["username"], accessToken: accessToken, isLogin: true, ); // 3. Dio 토큰 세팅, dio는 메모리 저장이라 await 필요 없음 -> 껐다 키면 없어지는 데이터 dio.options.headers["Authorization"] = accessToken;
  1. 토큰을 Storage에 저장합니다.
      • flutter_secure_storage 라이브러리로 SecureStorage 안에 토큰 내용을 저장합니다.
      • I/O 과정이라 오래 걸리기 때문에 await를 걸어줘야 합니다.
  1. SessionUser(상태)를 갱신합니다.
  1. 토큰은 헤더에 저장되기 때문에 dio.options.headers 로 저장합니다.

3) user_repository.dart

class UserRepository { const UserRepository(); // Login관련 메서드, 구조 분해 할당 적용 Future<(Map<String, dynamic>, String)> findByUsernameAndPassword( Map<String, dynamic> data) async { Response response = await dio.post("/login", data: data); Map<String, dynamic> body = response.data; // 로그인 실패 시 토큰이 null이기 때문에 return에서 문제 생기지 않도록 초기화 String accessToken = ""; // 로그인 실패 시 토큰 없어서 예외가 발생하기 때문에 try catch 로 try { accessToken = response .headers["Authorization"]![0]; } catch (e) {} // body와 토큰 동시에 보내기 위해 구조 분해 할당 사용 return (body, accessToken); } }
  • dio.post("/login", data: data)
    • dio 메서드를 이용해 /login으로 post 요청을 보냅니다.
  • accessToken = response.headers["Authorization"]![0];
    • 토큰이 null일 경우 예외가 발생하므로 try catch로 감싸줍니다.
    • [0]이라고 적어야 하는 이유
response.headers = { "Authorization": [ "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9...", "Bearer anotherTokenHere..." ] }; // 첫 번째 토큰 값 String accessToken1 = response.headers["Authorization"]![0]; // 두 번째 토큰 값 String accessToken2 = response.headers["Authorization"]![1];
  • return (body, accessToken);
    • 구조 할당 분해로 두 데이터를 동시에 반환합니다.
    • String accessToken = null일 경우 예외가 발생하므로 String accessToken = ""; 이렇게 미리 초기화합니다.

4) user_repository_test.dart

void main() async { TestRepository testRepository = TestRepository(); await testRepository.testFindByUsernameAndPassword(); // await testRepository.testSave(); } class TestRepository { UserRepository userRepository = const UserRepository(); // 회원 가입 메서드 생략 Future<void> testSave() async { } Future<void> testFindByUsernameAndPassword() async { // 1. given final requestBody = { "username": "ssar1", "password": "1234", }; // 2. when (Map<String, dynamic>, String) responseBody = await userRepository.findByUsernameAndPassword(requestBody); // 3. then -> Logger } }

2. 자동 로그인

main.dart
// Stack의 가장 위 context를 알고 있다. GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( navigatorKey: navigatorKey, // context가 없는 곳에서 context를 사용할 수 있는 방법 debugShowCheckedModeBanner: false, home: SplashPage(), routes: { "/login": (context) => const LoginPage(), "/join": (context) => const JoinPage(), "/post/list": (context) => PostListPage(), "/post/write": (context) => const PostWritePage(), }, theme: theme(), ); } }

1) SplashPage

  • home으로 설정되어 앱 실행 시 바로 열리는 페이지
class SplashPage extends ConsumerWidget { const SplashPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { ref.read(sessionProvider.notifier).autoLogin(); return Scaffold( body: // 앱 최초 로딩 화면 생략 ); } }

2) sessionGVM (자동 로그인 메서드)

session_gvm.dart
class SessionUser { // 생략 } class SessionGVM extends Notifier<SessionUser> { // 0. 이 메서드가 실행되는 시점에는 SessionUser가 있을 수가 없다. Future<void> autoLogin() async { // 1. 디바이스에서 토큰 가져오기 (오래 걸리는 작업) String? accessToken = await secureStorage.read(key: "accessToken"); // 토큰 없을 경우 로그인 화면으로 if (accessToken == null) { Navigator.popAndPushNamed(mContext, "/login"); return; } // 2. 로그인 통신 Map<String, dynamic> responseBody = await userRepository.autoLogin(accessToken); // 로그인 실패 시 if (!responseBody["success"]) { Navigator.popAndPushNamed(mContext, "/login"); return; } // 3. 로그인 성공 시 SessionUser 상태 업데이트 Map<String, dynamic> data = responseBody["response"]; state = SessionUser( id: data["id"], username: data["username"], accessToken: accessToken, isLogin: true); dio.options.headers["Authorization"] = accessToken; // 4. 화면 이동 Navigator.popAndPushNamed(mContext, "/post/list"); } }
  • 디바이스에서 토큰 가져오기
    • String? accessToken = await secureStorage.read(key: "accessToken")
    • 오래 걸리는 작업(I/O)이므로 await를 걸어줍니다.
💡
I/O (Input/Output) 작업이란 데이터를 읽거나 쓰는 작업으로, 주로 파일 시스템, 네트워크, 데이터베이스와 같은 외부 자원과의 상호 작용을 포함합니다

3) user_repository.dart

class UserRepository { const UserRepository(); Future<Map<String, dynamic>> autoLogin(String accessToken) async { Response response = await dio.post( "/auto/login", options: Options(headers: {"Authorization": accessToken}), ); Map<String, dynamic> body = response.data; return body; } }
  • dio.post( "/auto/login", options: Options( headers: {"Authorization": accessToken} ) );
    • body에 보낼 data는 없습니다.
    • 토큰을 보내서 검증받아야 하기 때문에 header에 토큰 정보는 담아서 보냅니다.

3. 로그아웃

1) 로그아웃 버튼에서 onPressed 설정을 하면 됩니다.

custom_navigator.dart
TextButton( onPressed: () { gvm.logout(); }, child: const Text( "로그아웃", style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black54, ), ), )

2) sessionGVM (로그아웃 메서드)

session_gvm.dart
Future<void> logout() async { // 1. 디바이스 토큰 삭제 await secureStorage.delete(key: "accessToken"); // 2. 상태 갱신 (SessionUser 정보 삭제) state = SessionUser( ); // 3. dio 갱신 dio.options.headers["Authorization"] = ""; // 4. 화면 이동 Navigator.popAndPushNamed(mContext, "/login"); }

상태 갱신

// 생성자 SessionUser({this.id, this.username, this.accessToken, this.isLogin = false}); // 상태 갱신 state = SessionUser( );
  • 생성자에 매개변수를 넣지 않았으니 id, username, accessToken은 null이 되고 isLogin 은 디폴트값인 false가 들어갑니다.
 
Share article

devleekangho