1. MVVM(Model-View-ViewModel) 패턴
- 애플리케이션의 UI 코드와 비즈니스 로직을 분리하여 더 유지보수하기 쉬운 코드를 작성할 수 있도록 도와줍니다.
- Model: 애플리케이션의 데이터와 비즈니스 로직을 담당합니다.
- View: UI 요소를 담당하며, 사용자에게 데이터를 보여주고 사용자 입력을 수집합니다.
- ViewModel: Model과 View 사이의 인터페이스 역할을 하며, 데이터 바인딩과 상태 관리를 처리합니다.
위는 사전적 의미이고 연습은
- View : UI 요소, 화면
- Respository : 서버와 통신
- ViewModel
- 상태 관리
- 비즈니스 로직
- 데이터 파싱
이렇게 할 예정입니다.
Server와 View는 만들어진 것을 가져왔습니다.
2. 서버를 담당하는 스프링 프로젝트 배포(jar) 파일 생성
git에서 clone으로 프로젝트를 가져옵니다.
- test 폴더 전체를 실행해서 모든 테스트가 성공하는지 체크합니다.
./gradlew clean build
- 터미널에서 위의 명령어를 실행합니다.
- clean → 기존의 빌드 결과물을 삭제
- build → jar 파일 배포
- build/lib 안의 jar 파일을 복사


서버에서 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