[플러터] 19. MVVM 패턴 - (1) 회원 가입 구현

KangHo Lee's avatar
Jan 02, 2025
[플러터] 19. MVVM 패턴 - (1) 회원 가입 구현

1. MVVM(Model-View-ViewModel) 패턴

  • 애플리케이션의 UI 코드와 비즈니스 로직을 분리하여 더 유지보수하기 쉬운 코드를 작성할 수 있도록 도와줍니다.
  • Model: 애플리케이션의 데이터와 비즈니스 로직을 담당합니다.
  • View: UI 요소를 담당하며, 사용자에게 데이터를 보여주고 사용자 입력을 수집합니다.
  • ViewModel: Model과 View 사이의 인터페이스 역할을 하며, 데이터 바인딩과 상태 관리를 처리합니다.
💡
위는 사전적 의미이고 연습은
  • View : UI 요소, 화면
  • Respository : 서버와 통신
  • ViewModel
      1. 상태 관리
      1. 비즈니스 로직
      1. 데이터 파싱
이렇게 할 예정입니다.
💡
Server와 View는 만들어진 것을 가져왔습니다.

2. 서버를 담당하는 스프링 프로젝트 배포(jar) 파일 생성

git에서 clone으로 프로젝트를 가져옵니다.

  • test 폴더 전체를 실행해서 모든 테스트가 성공하는지 체크합니다.
./gradlew clean build
  • 터미널에서 위의 명령어를 실행합니다.
    • clean → 기존의 빌드 결과물을 삭제
    • build → jar 파일 배포
  • build/lib 안의 jar 파일을 복사
notion image
notion image
💡
서버에서 images 폴더를 사용하고 있다면 플러터 서버에 images 폴더도 같이 넣어야 합니다.

3. View 세팅

git에서 clone으로 프로젝트를 가져옵니다.

flutter --version // 명령어 결과 Flutter 3.27.1 • channel stable • https://github.com/flutter/flutter.git Framework • revision 17025dd882 (2 weeks ago) • 2024-12-17 03:23:09 +0900 Engine • revision cb4b5fff73 Tools • Dart 3.6.0 • DevTools 2.40.2
  • pubspec.yaml
    • environment → sdk 버전이랑 호환되는지 체크합니다.
    • pub get을 실행해서 라이브러리를 다운로드합니다.
dependencies: flutter: sdk: flutter // 라이브러리 버전 문제가 발생하면 pub dev에서 최신 버전으로 업그레이드하세요 flutter_svg: ^2.0.6 // svg파일은 사진이 매우 커져도 깨지지 않음 flutter_lints: ^2.0.1 validators: ^3.0.0 intl: ^0.18.1 // 텍스트 등의 국제화 및 지역화 -> 배포 단계에서 필요 cupertino_icons: ^1.0.2 dio: ^5.2.0 // 서버와 통신하기 위해 필요한 라이브러리 입니다. flutter_riverpod: ^2.3.6 // 상태관리 Riverpod 라이브러리 입니다. logger: ^1.3.0 // 콘솔창에서 결과물을 쉽게 확인할 수 있도록 하는 Log 라이브러리입니다. flutter_secure_storage: ^8.0.0 // 어플리케이션 Secure Storage를 쉽게 사용할 수 있도록 도와주는 라이브러리입니다.

Secure Storage

  • 휴대폰 디바이스의 Secure Storage는 사용자 데이터를 보호하기 위해 디바이스 내부에 저장되는 안전한 영역입니다.
  • 저장된 데이터는 앱이나 사용자가 직접 접근할 수 없으며, 보안 키를 통해 접근이 제한됩니다.
  • JWT 등을 보관합니다.

서버 실행

  • jar 파일을 넣을 폴더를 만들고 배포한 서버 파일 jar을 넣습니다.
  • 만든 폴더 우클릭 → Open in → Terminal
    • 기본 터미널과 다른 서버용 터미널을 실행합니다.
// 터미널에서 jar 파일 실행 java -jar 파일이름.jar // jar까지 적고 tab 누르면 파일이름 자동 완성

Dio 세팅 파일

lib/_core/util/my_http.dart
// ip 주소는 컴퓨터마다 다릅니다. final baseUrl = "http://192.168.0.26:8080"; final dio = Dio( BaseOptions( baseUrl: baseUrl, // 내 IP 입력 contentType: "application/json; charset=utf-8", validateStatus: (status) => true, // 200 이 아니어도 예외 발생안하게 설정 ), ); const secureStorage = FlutterSecureStorage();
  • baseUrl은 터미널에서 ipconfig를 실행 후 IPv4 주소를 넣어야 합니다.
  • validateStatus: (status) => true,
    • dio 관련 메서드 실행 시 응답 코드가 200이 아닐 경우 예외를 발생시킵니다.
    • 예외처리가 까다롭기 때문에 true로 예외를 발생하지 않도록 합니다.
  • secureStorage 설정도 가능합니다.

4. ViewModel에서 회원가입 구현

main.dart (메인)

// Stack의 가장 위 context를 알고 있다. GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); void main() { runApp(const ProviderScope(child: MyApp())); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( // context가 없는 곳에서 context를 사용할 수 있는 방법 navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, home: SplashPage(), routes: { "/login": (context) => const LoginPage(), "/join": (context) => const JoinPage(), "/post/list": (context) => PostListPage(), "/post/write": (context) => const PostWritePage(), }, theme: theme(), ); } }
  • GlobalKey 설정
    • navigatorKey를 설정하면 다른 화면에서 Stack의 가장 위에 있는 화면의 BuildContext에 접근 가능합니다.
    • BuildContext가 있어야 화면 전환, 다시 그리기, alert 등이 가능합니다.

JoinPage

class JoinBody extends ConsumerWidget { final _username = TextEditingController(); final _email = TextEditingController(); final _password = TextEditingController(); @override Widget build(BuildContext context, WidgetRef ref) { // 회원가입, 로그인은 watch 할 필요 없다. SessionGVM gvm = ref.read(sessionProvider.notifier); return Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ const CustomLogo("Blog"), CustomAuthTextFormField( text: "Username", controller: _username, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( text: "Email", controller: _email, ), const SizedBox(height: mediumGap), CustomAuthTextFormField( text: "Password", obscureText: true, controller: _password, ), const SizedBox(height: largeGap), CustomElevatedButton( text: "회원가입", click: () { // 공백 제거 후 입력값 VM에 위임 gvm.join(_username.text.trim(), _email.text.trim(), _password.text.trim()); }, ), ], ), ); } }

회원 가입 요청을 보냈을 때 돌아오는 response 예시

HTTP/1.1 200 OK Access-Control-Expose-Headers: Authorization Access-Control-Allow-Origin: * Access-Control-Allow-Methods: POST, PUT, PATCH, GET, DELETE, OPTIONS Access-Control-Max-Age: 3600 Access-Control-Allow-Headers: Origin, X-Api-Key, X-Requested-With, Content-Type, Accept, Authorization Content-Type: application/json;charset=UTF-8 Content-Length: 202 { "success" : true, "response" : { "id" : 4, "username" : "hello", "imgUrl" : "/images/ff2b9fc9-74a9-4be3-bcf8-169eb1e35c6b.jpg" }, "status" : 200, "errorMessage" : null }

session_gvm.dart (Global View Model)

class SessionUser { int? id; String? username; String? accessToken; bool? isLogin; SessionUser({this.id, this.username, this.accessToken, this.isLogin}); } // 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() async {} Future<void> join(String username, String email, String password) async { final requestBody = { "username": username, "email": email, "password": password, }; // userRepository의 save 메서드 작성 필요 Map<String, dynamic> responseBody = await userRepository.save(requestBody); if (!responseBody["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( // 회원가입 실패 시 errorMessage 출력 SnackBar(content: Text("회원가입 실패 : ${responseBody["errorMessage"]}")), ); // 코드가 더 진행되지 않도록 return; } // 회원가입 성공 시 로그인 페이지로 Navigator.pushNamed(mContext, "/login"); } } final sessionProvider = NotifierProvider<SessionGVM, SessionUser>(() { return SessionGVM(); });
💡
GVM(Global View Model)
  • View Model이 따로 없는 페이지 모두를 관리하는 VM입니다.
  • 회원가입, 로그인 등 화원 관련 기능은 성공 시 데이터를 받아서 화면에 뿌릴 일이 없기 때문에 VM을 따로 만들지 않습니다.
  • final mContext = navigatorKey.currentContext!;
    • Stack의 가장 위 BuildContext를 가져옵니다.

user_repository.dart (Repository)

import 'package:dio/dio.dart'; import 'package:flutter_blog/_core/utils/my_http.dart'; class UserRepository { const UserRepository(); Future<Map<String, dynamic>> save(Map<String, dynamic> data) async { // contentType 필요 없다. my_http.dart에 설정되어 있음 Response response = await dio.post("/join", data: data); // Map으로 변환 Map<String, dynamic> body = response.data; // Logger().d(body); return body; } }
  • contentType은 Dio 설정에 되어있기 때문에 생략 가능합니다.
  • dio의 메서드로 받아온 데이터가 JSON일 경우 Map으로 변환해줍니다.
  • Logger().d(body)
    • test를 위한 코드입니다.

user_repository_test.dart(test 코드)

void main() async { UserRepository userRepository = UserRepository(); // 1. given final requestBody = { "username": "user", "email": "email@nate.com", "password": "password", }; // 2. when Map<String, dynamic> responseBody = await userRepository.save(requestBody); // 3. then -> Logger로 확인 }
 
Share article

devleekangho