728x90
이번 글에서는 Flutter에서 로그인 기능 구현을 다룹니다.
특히, **상태 관리(Riverpod)**를 활용하여 로그인 상태를 유지하고, **예외 처리(ExceptionHandler)**를 적용하여 보다 안전한 로그인 프로세스를 구축합니다.
1. 로그인 기능 설계
로그인 요청을 보낼 때 어떤 변수가 필요할까?
뷰모델(SessionGVM)에서 세션 유저(SessionUser)를 관리하며, 다음과 같은 기능을 수행합니다.
📌 뷰모델의 역할
- 세션관리 : 현재 로그인된 사용자 정보(ID, username, accessToken, 로그인 여부)를 저장
- 로그인 처리: 서버에 로그인 요청을 보내고, 성공 시 JWT 토큰을 저장하고 상태를 유지
- 예외 처리: 네트워크 오류, 서버 에러 등을 처리하여 사용자에게 알림.
2. 상태 관리(ViewModel) 구현
**SessionGVM(Global ViewModel)**은 앱 전역에서 로그인 상태를 관리하는 역할을 합니다.
📌 SessionGVM 클래스
import 'package:class_f_story/_core/utils/exception_handler.dart';
import 'package:class_f_story/_core/utils/my_http.dart';
import 'package:class_f_story/data/model/session_user.dart';
import 'package:class_f_story/data/repository/user_repository.dart';
import 'package:class_f_story/main.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 뷰 모델 --> 화면에 있는 데이터를 가지고 와서 사용한다.
// 로그인 요청을 할 때 어떤 변수를 필요할까?
// 뷰모델에 속성 --> 세션 유저가 된다.
// 뷰모델에 행위 --> 로그인 행위, 로그아웃 행위, 자동 로그인 행위
class SessionGVM extends Notifier<SessionUser> {
// 뷰 모델에서 컨텍스트를 사용하는 방안
final mContext = navigatorkey.currentContext!;
UserRepository userRepository = UserRepository();
// 상태를 초기화 해주어야 한다. (선언형 UI 이기 때문에)
@override
SessionUser build() {
// 추상화 변수 state(외부 에서 접근하는 변수명 )
// state ==> SessionUser() 객체가 된다.
return SessionUser(
id: null,
username: null,
accessToken: null,
isLogin: false,
);
}
📌 설명
- SessionGVM은 현재 로그인 상태를 전역적으로 관리하는 역할을 합니다.
- build() 메서드에서 초기 상태를 설정합니다.
- 로그인 여부(isLogin): 기본값 false
- 사용자 정보(id, username, accessToken): null
3. 로그인 요청 처리
로그인 버튼을 눌렀을 때 서버로 요청을 보내고, 상태를 갱신하는 기능입니다.
📌 로그인 메서드 (login)
// 로그인 행위
// 화면에서 뷰 모델에게 로그인 요청 위임
Future<void> login(String username, String password) async {
// 서버측으로 던질 데이터를 구축해야 한다.
try {
// 요청 HTTP 메세지 body
final body = {
'username': username,
'password': password,
};
final (responseBody, accessToken) =
await userRepository.findByUsernameAndPassword(body);
// responseBody['success'] -- true --> false
// 서버측에서 통신은 성공 했으나 내부 오류 판단
if (!responseBody['success']) {
ExceptionHandler.handleException(
responseBody['errorMessage'], StackTrace.current);
return; // 실행의 제어권 반납
}
// 1. JWT 토큰을 안전한 금고에 보관 처리 //
// I/O 시간이 많이 걸리기 때문에 비동기 처리
await secureStorage.write(key: 'accessToken', value: accessToken);
// 2. 뷰 모델에 상태 갱신
// 깊은 복사 처리
Map<String, dynamic> resData = responseBody['response'];
state = SessionUser(
id: resData['id'],
username: resData['username'],
accessToken: accessToken,
isLogin: true);
// 3. Dio 헤더에 JWT 토큰 설정(객체 상태값 추가)
dio.options.headers['Authorization'] = accessToken;
// 화면 이동 처리 pushNamed -> pushNamed -> pushNamed
// Navigator stack 메모리에 계속 쌓인다..
// Navigator.pushNamed(mContext, '/post/list');
// 이전에 쌓여 있던 stack(화면) 다 파괴 하면서 이동 처리
Navigator.popAndPushNamed(mContext, '/post/list');
// 모든 예외처리가 설정 된다.
} catch (e, stackTrace) {
// IP 주소가 잘못, 서버가 종료 되어 있을 때, 서버 연결 시간 초과
ExceptionHandler.handleException('서버 연결 실패', stackTrace);
}
}
📌 설명
- 서버로 username, password를 포함한 로그인 요청을 보냅니다.
- 로그인 성공 시 JWT 토큰을 안전한 저장소(secureStorage)에 저장하고 Dio 헤더에 추가합니다.
- 로그인 후 기존 화면을 제거하고 (Navigator.popAndPushNamed) 새 페이지로 이동합니다.
4. 로그인 상태 관리 (Provider)
Riverpod을 활용하여 SessionGVM을 전역적으로 관리합니다.
📌 Provider 선언
// 창고 관리자 선언 (창고 - 뷰모델), 창고 어떤 관리해라 지정 !!
final sessionProvider = NotifierProvider<SessionGVM, SessionUser>(
() => SessionGVM(),
);
📌 설명
- **NotifierProvider**를 사용하여 SessionGVM을 관리합니다.
- UI에서 sessionProvider를 사용하면 로그인 상태 변경 사항을 자동으로 반영할 수 있습니다.
5. 로그인 시 예외 처리
로그인 실패 또는 네트워크 오류 발생 시 사용자에게 알림을 제공합니다.
📌 예외 처리 클래스
import 'package:class_f_story/main.dart';
import 'package:flutter/material.dart';
import 'logger.dart';
class ExceptionHandler {
static void handleException(dynamic exception, StackTrace stackTrace) {
logger.e('Exception : $exception');
logger.e('StackTrace : $stackTrace');
// 간혹 비동기 작업시 currentContext 사라질 수 있다.
final mContext = navigatorkey.currentContext;
if (mContext == null) return;
// 시스템 키보드가 있다면 내려 주자
FocusScope.of(mContext).unfocus();
ScaffoldMessenger.of(mContext).showSnackBar(
SnackBar(
content: Text(
exception.toString(),
),
),
);
}
}
📌 설명
- 로그 출력: logger.e()를 사용하여 오류 내용을 콘솔에 기록.
- 키보드 처리: 예외 발생 시 활성화된 입력 필드를 비활성화하여 키보드를 숨김.
- 오류 메시지 출력: ScaffoldMessenger를 사용하여 사용자에게 스낵바 형태로 오류 메시지 표시.
6. 로그인 UI (LoginBody) 구현
로그인 UI는 사용자가 아이디, 비밀번호를 입력하고, 로그인 버튼을 눌러 로그인 요청을 보낼 수 있도록 구성됩니다.
📌 로그인 화면 구조
- CustomLogo → 앱 로고 표시
- CustomAuthTextFormField → 아이디 및 비밀번호 입력 필드
- CustomElevatedButton → 로그인 버튼
- CustomTextButton → 회원가입 버튼
📌 LoginBody 코드
import 'package:class_f_story/_core/constants/size.dart';
import 'package:class_f_story/data/gvm/session_gvm.dart';
import 'package:class_f_story/ui/widgets/custom_auth_text_form_field.dart';
import 'package:class_f_story/ui/widgets/custom_elevated_button.dart';
import 'package:class_f_story/ui/widgets/custom_logo.dart';
import 'package:class_f_story/ui/widgets/custom_text_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 로그인 화면 UI를 담당하는 위젯
// ConsumerWidget을 사용하여 Provider에서 데이터 상태를 읽어올 수 있음
class LoginBody extends ConsumerWidget {
// 사용자 입력을 받을 컨트롤러 생성
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
LoginBody({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 창고 자체에 접근하는 코드 --> 뷰모델을 가지고 옴
SessionGVM gvm = ref.read(sessionProvider.notifier);
return Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
children: [
// 앱 로고 표시
CustomLogo('f-story'),
// 사용자명 입력 필드
CustomAuthTextFormField(
text: 'Username',
controller: _usernameController,
),
const SizedBox(height: largeGap),
// 비밀번호 입력 필드
CustomAuthTextFormField(
text: 'Password',
controller: _passwordController,
),
const SizedBox(height: largeGap),
// 로그인 버튼
CustomElevatedButton(
text: '로그인',
click: () {
// 로그인 요청
gvm.login(
_usernameController.text.trim(),
_passwordController.text.trim(),
);
},
),
// 회원가입 페이지 이동 버튼
CustomTextButton(
text: '회원가입 페이지로 이동',
click: () {
Navigator.pushNamed(context, '/join');
},
),
],
),
);
}
}
📌 코드 설명
1. ConsumerWidget 사용
class LoginBody extends ConsumerWidget {
- ConsumerWidget을 사용하면 Riverpod의 상태 관리 기능을 활용하여 SessionGVM의 상태를 읽을 수 있습니다.
2. 뷰모델(SessionGVM) 가져오기
SessionGVM gvm = ref.read(sessionProvider.notifier);
- sessionProvider.notifier를 사용하여 로그인 상태를 관리하는 뷰모델(SessionGVM)을 가져옵니다.
- 이를 통해 UI에서 gvm.login()을 호출하여 로그인 요청을 보낼 수 있습니다.
3. 입력 필드 구현
CustomAuthTextFormField(
text: 'Username',
controller: _usernameController,
),
- 사용자명과 비밀번호 입력을 위해 **커스텀 입력 필드(CustomAuthTextFormField)**를 사용합니다.
- controller를 설정하여 사용자가 입력한 값을 읽을 수 있도록 합니다.
4. 로그인 버튼 클릭 시 로그인 요청
CustomElevatedButton(
text: '로그인',
click: () {
gvm.login(
_usernameController.text.trim(),
_passwordController.text.trim(),
);
},
),
- 사용자가 로그인 버튼을 클릭하면 gvm.login()을 호출하여 로그인 요청을 보냅니다.
- trim()을 사용하여 공백을 제거한 후 서버로 전송합니다.
5. 회원가입 페이지로 이동 버튼
CustomTextButton(
text: '회원가입 페이지로 이동',
click: () {
Navigator.pushNamed(context, '/join');
},
),
- CustomTextButton을 사용하여 회원가입 페이지로 이동할 수 있도록 네비게이션 처리를 합니다.
📌 전체 로그인 흐름
- 사용자가 Username, Password 입력 필드에 값을 입력합니다.
- 로그인 버튼 클릭 시, gvm.login()을 호출하여 로그인 요청을 보냅니다.
- SessionGVM이 서버로 로그인 요청을 전송하고, 성공 시 JWT 토큰을 저장하고 로그인 상태를 변경합니다.
- 로그인 성공 후, /post/list 페이지로 이동합니다.
- 로그인 실패 시, **ExceptionHandler**를 통해 오류 메시지를 표시합니다.
✅ 마무리
- 로그인 화면 UI(LoginBody) 구성
- CustomAuthTextFormField, CustomElevatedButton, CustomTextButton 사용.
- ConsumerWidget을 사용하여 뷰모델과 연결.
- 뷰모델(SessionGVM)과의 연결
- sessionProvider.notifier를 통해 로그인 요청을 gvm.login()으로 위임.
- 로그인 버튼 클릭 시 실행 흐름
- 서버 요청 → 로그인 성공 시 JWT 토큰 저장 및 상태 변경 → 페이지 이동.
- 로그인 실패 시 예외 처리(ExceptionHandler) 실행.
다음 글에서는 회원가입 UI와 기능을 구현해보겠습니다! 🚀
서버통신과 로그인 기능구현 이전 글이 궁금하다면??
2025.02.08 - [Flutter/App] - "[Flutter] 블로그 만들기 - 서버 통신의 모든 것 (Dio 활용)
'Flutter > App' 카테고리의 다른 글
[Flutter] 블로그 만들기 -자동 로그인 기능 구현 및 UI (0) | 2025.02.10 |
---|---|
[Flutter] 블로그 만들기 - 회원가입 기능 구현 및 UI (1) | 2025.02.09 |
"[Flutter] 블로그 만들기 - 서버 통신의 모든 것 (Dio 활용) (0) | 2025.02.09 |
[Flutter] 블로그 만들기 - 로그인 UI 구성 (0) | 2025.02.08 |
[Flutter] 블로그 만들기 - 기본 프로젝트 설정 (1) | 2025.02.08 |