[Flutter] 데이터 모델링과 자동 로그인

merci·2023년 4월 17일
1

Flutter

목록 보기
20/24

데이터 모델링

연습용 api문서를 참고해 서버를 실행한다.
https://blog.naver.com/getinthere/222426449415

dio 라이브러리 테스트

우리 서버로 요청을 보낼것이므로 core패키지에 아래 설정을 추가한다.

import 'package:dio/dio.dart';

final dio = Dio(BaseOptions(
  baseUrl: "http://192.168.200.124:8080", // 위에서 실행시킨 스프링서버
  contentType: "application/json; charset=utf-8",
));

dio 는 BaseOptions을 매개변수로 넣어 생성자를 만들 수 있다.

void main() async {
  await fetchJoin_test();
}

Future<void> fetchJoin_test() async {
  // given
  String username = "gildong";
  String password = "1234";
  String email = "gildong@nate.com";

  // when
  Map<String, dynamic> requestBody = {
    "username": username,
    "password": password,
    "email": email
  };
  Response response = await dio.post("/join", data: requestBody);
  print(response.data);
}

http 라이브러리에서는 Map데이터를 jsonEncode을 이용해 String으로 변환해 보낸다.
dio 라이브러리를 이용하면 json변환 없이 Map데이터를 보내면 되고 응답받는 json을 Map으로 변환시켜준다.

터미널에서 플러터 테스트 실행하기

flutter test test/model/auth/auth_repository_test.dart

api문서에서는 다음과 같은 json을 리턴받는다고 되어있다.

{
    "code": 1,
    "msg": "회원가입완료",
    "data": {
        "id": 3,
        "username": "getinthere",
        "password": null,
        "email": "getinthere@nate.com",
        "created": "2021-07-10T07:45:15.764705",
        "updated": "2021-07-10T07:45:15.764705"
    }
}

I/O 결과는 터미널에 출력되었다.

json -> 오브젝트 모델링

fetchJoin_test 에 코드를 추가

Response response = await dio.post("/join", data: requestBody);
  // print(response.data);
  ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
  print(responseDTO.code);
  print(responseDTO.msg);
  print(responseDTO.data);
  // User result = User.fromJson(responseDTO.data); // map타입을(dynamic) User타입으로 변환
  // print(result.email); // 입력한 이메일 출력
  responseDTO.data =
      User.fromJson(responseDTO.data); // dynamic 타입이므로 다시 자신으로 리턴가능
  // print(responseDTO.data);  // User 타입이므로 [Instance of 'User'] 출력됨 
  User user = responseDTO.data;
  // print(user.email); // 유저 이메일에 접근

fromJson메소드로 map데이터를 dart오브젝트에 넣는다.

map데이터를 받기 위한 오브젝트를 만든다.
서버로부터 오는 모든 응답을 받으므로 api문서를 참고해 틀을 만든다.

class ResponseDTO {
  final int? code;
  final String? msg;
  String? token;
  dynamic data; // JsonArray [], JsonObject {}
  // dynamic 쓰는 이유는 타입이 자유롭기 때문에 다양한 타입으로 변환가능

  ResponseDTO({
    this.code,
    this.msg,
    this.data,
  });

  ResponseDTO.fromJson(Map<String, dynamic> json)
      : code = json["code"],
        msg = json["msg"],
        data = json["data"];
}

테스트 결과는

테스트 통과후 실제 코드로 가져간다.

로그인후 토큰 받기

Future<void> fetchLogin_test() async {
  // given
  String username = "ssar";
  String password = "1234";

  // when
  Map<String, dynamic> requestBody = {
    "username": username,
    "password": password
  };

  // 1. 통신 시작
  Response response = await dio.post("/login", data: requestBody);

  // 2. DTO 파싱
  ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
  responseDTO.data = User.fromJson(responseDTO.data); // map -> User 변환

  // 3. 토큰 받기
  final authorization = response.headers["Authorization"];
  if (authorization != null) {
    responseDTO.token = authorization.first; // headers는 map<String,String>의 배열로 되어 있다.
  }

  print(responseDTO.code);
  print(responseDTO.msg);
  print(responseDTO.token);
  User user = responseDTO.data;
  print(user.id);
  print(user.username);
}

결과는

$ flutter test test/model/auth/auth_repository_test.dart
00:01 +0: loading C:\Temp\Flutter-RiverPod_MVCS_Blog_Start\test\model\auth\auth_repository_test.dart                                                                                                             
1
성공
[Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb3PthqDtgbAiLCJpZCI6MSwiZXhwIjoxNjgyNTc2Nzk0fQ.52h441fuX29U1qEd01HxfXje3-0hQXaPOO90SVDSxsBgTJ4KFj-qgKABmlofpQeQxiOVlA1pS0xFxGNxKIu4ag]
1
ssar



로그인 구현

폼 유효성검사

폼에 데이터를 입력할때 유효성 검사를 해야하는데 다음과 같은 방법을 이용해보자.

class CustomTextFormField extends StatelessWidget {
  final String hint;
  final funValidator;
  final controller;

  const CustomTextFormField({
    required this.hint,
    required this.funValidator,
    this.controller,
  });

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 5),
      child: TextFormField(
        controller: controller,
        validator: funValidator,
        obscureText: hint == "Password" ? true : false,
        decoration: InputDecoration(
          hintText: "Enter $hint",
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
          errorBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
          focusedErrorBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(20),
          ),
        ),
      ),
    );
  }
}

obscureText는 텍스트필드에 입력된 데이터를 **** 처럼 숨긴다. ->
텍스트폼 이므로 controller에는 TextEditingController를 넣는다.

final _password = TextEditingController();

CustomTextFormField(
            controller: _password,
            hint: "Password",
            funValidator: validatePassword(),
          )

TextEditingController 는 폼에 입력된 데이터를 동적으로 읽고 수정할 수 있다.
TextEditingControlleronChanged같은 속성을 이용하면 입력할 때마다 호출될 기능을 넣을 수 있다.

텍스트 필드의 validator 에는 입력된 데이터의 유효성을 검사하는 콜백함수를 넣는다.
사용자가 입력한 데이터가 유효하다면 null을 리턴하고 유효하지 않다면 문자열을 리턴한다.
validatePassword() -> 콜백함수는 외부로 분리시킨다.

Function validatePassword() {
  return (String? value) {
    if (value!.isEmpty) {
      return "패스워드 공백이 들어갈 수 없습니다.";
    } else if (value.length > 12) {
      return "패스워드의 길이를 초과하였습니다.";
    } else if (value.length < 4) {
      return "패스워드의 최소 길이는 4자입니다.";
    } else {
      return null;
    }
  };
}

유효성을 통과하지 못하면 텍스트필드의 validator 는 위의 문자열을 리턴한다.

버튼 클릭시 라우팅

class LoginForm extends ConsumerWidget {
  final _formKey = GlobalKey<FormState>();
  final _username = TextEditingController();
  final _password = TextEditingController();
  LoginForm({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          CustomTextFormField(
            controller: _username,
            hint: "Username",
            funValidator: validateUsername(),
          ),
          CustomTextFormField(
            controller: _password,
            hint: "Password",
            funValidator: validatePassword(),
          ),
          CustomElevatedButton(
            text: "로그인",
            funPageRoute: () async {
              if (_formKey.currentState!.validate()) {
                ref
                    .read(userControllerProvider)
                    .login(_username.text.trim(), _password.text.trim());
              }
            },
          ),
          TextButton(
            onPressed: () {
              Navigator.pushNamed(context, Move.joinPage);
            },
            child: const Text("아직 회원가입이 안되어 있나요?"),
          ),
          TextButton(
            onPressed: () {
              Navigator.pushNamed(context, Move.postHomePage);
            },
            child: const Text("홈페이지 로그인 없이 가보는 테스트"),
          ),
        ],
      ),
    );
  }
}

Form 위젯과 상호작용을 하기위해 해당 폼의 고유한 GlobalKey를 생성한다.
_formKey.currentState로 해당 FormState의 인스턴스를 얻고 validate()로 해당 Form 위젯의 모든 텍스트필드의 유효성을 확인한 후 유효하다면 null을 리턴하고 유효하지 않다면 입력한 문자열을 리턴한다.
텍스트필드 위젯의 validator가 모두 null을 리턴하게 되면 _formKey.currentState!.validate()true가 되어 입력된 기능을 수행한다.

ref.read(userControllerProvider)로 프로바이더에 접근하고 해당 컨트롤러의 메소드를 호출한다.

컨텍스트가 없을때 라우팅

플러터에서는 라우팅할 때 해당 페이지의 컨텍스트를 참고해서 라우팅한다.
하지만 컨트롤러에서 라우팅을 한다면 컨텍스트가 존재하지 않아서 라우팅 로직을 만들 수가 없다.
이럴때는 main.dart에서 GlobalKey를 이용해서 위젯트리에 접근할 수 있다.

아래 코드를 추가해서 위젯트리의 네이게이션을 navigatorKey으로 어디서든 접근 가능하다.

  // GlobalKey
  GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  void main() async {
    // 생략
  }

컨트롤러에서 네비게이션에 접근한다면 다음코드를 추가해서 이용하면된다.

class UserController {
  final mContext = navigatorKey.currentContext; 
  // 생략
}

예를들어 버튼을 눌렀을때 해당 컨트롤러의 로직에서 스낵바를 날린다면

    ScaffoldMessenger.of(mContext!)
        .showSnackBar(const SnackBar(content: Text("회원가입 실패")));
    // 더 간단한 토스트
    // showToast('로그인 실패 !!');


SharedPreferences

dependencies:
  shared_preferences: ^2.0.8

안드로이드 플랫폼에서 제공하는 기능으로 Map 형태의 데이터를 저장한다.
SharedPreferences를 이용하면 앱이 종료되어도 데이터는 삭제되지 않고 남아있게 된다.
이를 이용해 앱이 다시 시작되어도 동일한 데이터를 유지할 수 있게 된다.( 로그인 및 여러 상태 정보 )
예를들어 안드로이드라면 data/data/[package-name]/shared_prefs 폴더에 XML 파일로 저장된다.

SecureStorage

dependencies:
  flutter_secure_storage: ^7.0.0 

데이터를 안전하게 저장하기 위해서 SecureStorage를 이용한다.
사용자의 토큰이나 비밀번호같은 정보가 저장된다. (ex. 계정 정보 저장)
앱 개발을 한다면 앱 실행시 가장 먼저 이러한 저장소에서 토큰과 같은 상태 정보를 검사해야한다.
주로 스플래쉬 페이지에서 토큰을 검사하고 메인 페이지 데이터를 다운받은 뒤 다음 화면을 렌더링한다.
따라서 최초 스플래쉬 페이지에서는 동기적으로 토큰을 먼저 다운 및 확인을 한다.


로그인 구현(컨트롤러)

// 어디서든 컨트롤러를 호출할수 있도록 프로바이더에 등록
final userControllerProvider = Provider<UserController>((ref) {
  return UserController(ref);
});

class UserController {
  // GlobalKey를 이용해서 라우팅
  final mContext = navigatorKey.currentContext;
  final Ref ref;
  UserController(this.ref);
  
  Future<void> login(String username, String password) async {
    LoginReqDTO loginReqDTO =
        LoginReqDTO(username: username, password: password);
    ResponseDTO responseDTO = await UserRepository().fetchLogin(loginReqDTO);
    if (responseDTO.code == 1) {
      // 1. 토큰을 휴대폰에 저장 -> SecureStorage
      await secureStorage.write(key: "jwt", value: responseDTO.token);

      // 2. 로그인 상태 등록 -> 세션 관리
      ref
          .read(sessionProvider)
          .loginSuccess(responseDTO.data, responseDTO.token!);

      // 3. 화면 이동 -> GlobalKey 이용
      Navigator.popAndPushNamed(mContext!, Move.postHomePage);
    } else {
      showToast('로그인 실패 !!');
    }
  }
}

호출된 레파지토리

  Future<ResponseDTO> fetchLogin(LoginReqDTO loginReqDTO) async {
    try {
      // 1. 통신 시작
      Response response = await dio.post("/login", data: loginReqDTO.toJson());

      // 2. DTO 파싱
      ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
      responseDTO.data = User.fromJson(responseDTO.data);

      // 3. 토큰 받기
      final authorization = response.headers["authorization"];
      if (authorization != null) {
        responseDTO.token = authorization.first;
      }
      return responseDTO;
    } catch (e) {
      return ResponseDTO(code: -1, msg: "유저네임 혹은 비번이 틀렸습니다");
    }
  }

서버로 로그인 데이터를 보내서 로그인을 성공하게 되면 서버에서는 로그인정보와 토큰을 리턴한다.
레파지토리는 통신과 파싱을 하고 컨트롤러에서 반환 받은 토큰을 secureStorage에 저장한다.
로그인 상태 등록은 밑에서 이어 설명한다.


자동로그인 기능 구현

앱 실행시 자동로그인을 구현하기 위해서는 로그인시 로그인 데이터를 기기에 저장해두어야 한다.
이를 위해서 SecureStorage를 이용했고 앱 실행시에는 이 저장소에서 로그인과 관련된 데이터를 가져와야 한다.
SecureStorage에 JWT를 저장해두었는데 스플래쉬 페이지에서 이 토큰이 유효한지 확인을 먼저 해야한다.
토큰을 검사하기 위해서 렌더링 전에 서버에 토큰을 날려서 유효한지 확인을 받는다.

토큰 검사

SessionUser sessionUser = await UserRepository().fetchJwtVerify();
  Future<SessionUser> fetchJwtVerify() async {
    SessionUser sessionUser = SessionUser();
    // 디바이스에서 토큰 가져옴
    String? deviceJwt = await secureStorage.read(key: "jwt");
    if (deviceJwt != null) {
      try {
        Response response = await dio.get("/jwtToken",
            options: Options(headers: {"Authorization": deviceJwt}));
        ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
        responseDTO.token = deviceJwt;
        responseDTO.data = User.fromJson(responseDTO.data);

        if (responseDTO.code == 1) {
          sessionUser.loginSuccess(responseDTO.data, responseDTO.token!);
        } else {
          sessionUser.logoutSuccess();
        }
        return sessionUser;
      } catch (e) {
        Logger().d("에러 이유 : $e");
        sessionUser.logoutSuccess();
        return sessionUser;
      }
    } else {
      sessionUser.logoutSuccess();
      return sessionUser;
    }
  }

서버에 요청을 보낼때 바디데이터는 없고 헤더만 있으므로 get요청을 한다.
위에서 다시 토큰을 응답 받았는데 자동로그인 기간을 갱신하는 등의 작업을 추가할 수 있다.

토큰이 유효하지 않다면 logoutSuccess를 호출해서 모든 로그인정보를 제거한다.
세션을 저장하는 스토어를 프로바이더로 만들어 어디서든 접근할 수 있도록 한다.

Provider에 세션 저장

final sessionProvider = Provider<SessionUser>((ref) {
  return SessionUser();
});

// 최초 앱이 실행될 때 초기화 되어야 함.
// 1. JWT 존재 유무 확인 (I/O) - 디바이스
// 2. JWT로 회원정보 받아봄 (I/O) - 서버
// 3. OK -> loginSuccess() 호출
// 4. FAIL -> loginPage로 이동 or 유저 데이터가 없는 MainPage
class SessionUser {
  User? user;
  String? jwt;
  bool? isLogin;

  void loginSuccess(User user, String jwt) {
    this.user = user;
    this.jwt = jwt;
    isLogin = true;
  }

  Future<void> logoutSuccess() async {
    user = null;
    jwt = null;
    isLogin = false;
    // I/O가 발생하는 모든 접근은 비동기로 수행
    await secureStorage.delete(key: "jwt");
    Logger().d("세션 종료 및 디바이서 jwt 삭제");
  }
}

로그인 상태에따라 메인화면의 정보나 페이지가 달라질 수 있으므로 사용자 경험을 좋게 하기 위해 이러한 작업은 동기적으로 수행되어야 한다. 즉 모든 상태정보의 확인이 끝난뒤 화면을 렌더링한다.

앱 실행시 동기화 순서

GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // 1. secure Storage 안에 jwt 확인
  // 2. jwt로 회원정보를 가져옴
  // 3. SessionUser 동기화
  // 서버에 요청을 보내서 검사했지만 프론트에서 토큰으로 세션을 만드는 방법도 있음

  SessionUser sessionUser = await UserRepository().fetchJwtVerify();

  runApp(
    ProviderScope(
      // 세션을 업데이트
      overrides: [sessionProvider.overrideWithValue(sessionUser)],
      child: const MyApp(),
    ),
  );
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    SessionUser sessionUser = ref.read(sessionProvider);
    return MaterialApp(
      navigatorKey: navigatorKey, //  글로벌키를 이용하면 어디서든 위젯트리에 접근가능
      debugShowCheckedModeBanner: false,
      // 자동로그인 기능 -> 사용자 경험을 좋게 만든다.
      initialRoute: sessionUser.isLogin! ? Move.postHomePage : Move.loginPage,
      routes: getRouters(),
    );
  }
}

getRouters() -> 간단히 페이지 라우팅

class Move {
  static String postHomePage = "/post/home";
  static String postWritePage = "/post/write";
  static String joinPage = "/join";
  static String loginPage = "/login";
  static String userDetailPage = "/user/detail";
}

Map<String, Widget Function(BuildContext)> getRouters() {
  return {
    Move.joinPage: (context) => JoinPage(),
    Move.loginPage: (context) => LoginPage(),
    Move.postHomePage: (context) => PostHomePage(),
    Move.postWritePage: (context) => PostWritePage(),
    Move.userDetailPage: (context) => const UserDetailPage(),
  };
}
profile
작은것부터

0개의 댓글