Flutter

블로그 프로젝트 - 사용자 세션(로그인 상태) 관리 하기

whs5758 2025. 9. 1. 09:14

 왜 세션 상태 관리가 필요한가?

실제 앱에서는 이런 요구사항들이 있습니다:
- 사용자가 앱을 껐다 켜도 로그인 상태 유지
- 여러 화면에서 로그인한 사용자 정보 공유  
- 로그인 상태에 따라 다른 화면 표시
- 로그아웃시 모든 사용자 데이터 정리
- 토큰 만료시 자동 처리

이런 복잡한 로직을 체계적으로 관리하기 위해 전용 상태 관리 클래스를 만듭니다.

 

핵심 개념들

  1. 세션(Session): 사용자가 앱에 로그인한 상태를 유지하는 것
  2. 자동 로그인: 앱을 다시 켤 때, 이전 로그인 정보로 자동으로 로그인되는 기능
  3. 토큰 관리: 서버에서 받은 인증 토큰을 저장하고 관리하는 것
  4. 전역 상태 관리: 로그인 상태를 앱의 모든 화면에서 사용할 수 있게 관리

session_notifier 에 로그인 기능을 완성해 보자 (추가 개발 로직들 있음)

문제점 고민해보기 —> 리팩토링

  1. 책임 혼재 : UI 로직 + 비즈니스 로직 + 검증 로직 중복
  2. 테스트 어려움 : UI 와 비즈니스 로직이 동시에 존재해서 관리 어려움
  3. 재사용성 저하 : 다른 화면에서 로그인 로직 재사용 어려움
import 'package:flutter/material.dart'
    show ScaffoldMessenger, Text, SnackBar, Navigator;
import 'package:flutter_blog/_core/utils/my_http.dart';
import 'package:flutter_blog/data/models/repository/user_repository.dart';
import 'package:flutter_blog/data/models/user.dart';
import 'package:flutter_blog/main.dart';
import 'package:flutter_blog/providers/form/join_form_notifier.dart';
import 'package:flutter_blog/providers/form/login_form_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';

// 세션이라는 데이터를 구조화 해보자
// 창고 데이터 구상하기
class SessionModel {
  User? user;
  bool? isLogin;

  SessionModel({this.user, this.isLogin = false});

  @override
  String toString() {
    return 'SessionModel{user: $user, isLogin: $isLogin}';
  }
}

// 창고 매뉴얼
class SessionNotifier extends Notifier<SessionModel> {
  // context 가 없는 환경에서 네비게시션과 알림 처리를 위한 전역 키이다.
  final mContext = navigatorKey.currentContext!;

  @override
  SessionModel build() {
    return SessionModel();
  }

  // 로그인 로직 설계
  Future<void> login(String username, String password) async {
    // 유효성 검증 - 사용자가 입력한 username, password
    bool isValid = ref.read(loginFormProvider.notifier).validate();
    if (isValid == false) {
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text("유호성 검사 실패")));
      return;
    }
    Map<String, dynamic> body =
        await UserRepository().login(username, password);
    if (body["success"] == false) {
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text("로그인 실패")));
      return;
    }

    // 서버에서 받은 사용자 정보를 앱에서 사용할 수 있는 형태로 변환
    User user = User.fromMap(body["response"]);

    // 로컬 저장소에 JWT 토큰을 저장해 두자.
    // Shared... 보안에 강화된 녀석 // yml 라이브러리 선언 됨
    await secureStorage.write(key: "accessToken", value: user.accessToken);

    state = SessionModel(user: user, isLogin: true);

    // 로그인 성공 이후에 서버측에 통신 요청할 때마다 JWT 토큰을 주입해야 된다
    dio.options.headers['Authorization'] = user.accessToken;

    Navigator.pushNamed(mContext, "/post/list");
  }

  // 로그 아웃 기능

  // 자동 로그인 기능

  // 회원 가입 로직 추가하기
  Future<void> join(String username, String email, String password) async {
    Logger()
        .d("username : ${username}, email: ${email}, password : ${password}");

    // 한번 더 유효성 검사
    bool isValid = ref.read(joinProvider.notifier).validate();
    if (isValid == false) {
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text("유호성 검사 실패")));
      return;
    }

    Map<String, dynamic> body =
        await UserRepository().join(username, email, password);

    if (body["success"] == false) {
      ScaffoldMessenger.of(mContext)
          .showSnackBar(SnackBar(content: Text(body["errorMessage"])));
      return;
    }
    //회원 가입 후 로그인 페이지로 이동 처리 (자동 로그인)
    Navigator.pushNamed(mContext, "/login");
  }
} // end of SessionNotifier

// 실제 창고 개설
final sessionProvider =
    NotifierProvider<SessionNotifier, SessionModel>(() => SessionNotifier());

 

LoginForm 에 로그인 기능 완성해보기
import 'package:flutter/material.dart';
import 'package:flutter_blog/providers/form/login_form_notifier.dart';
import 'package:flutter_blog/providers/global/session_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../../../../_core/constants/size.dart';
import '../../../../widgets/custom_auth_text_form_field.dart';
import '../../../../widgets/custom_elavated_button.dart';
import '../../../../widgets/custom_text_button.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    LoginModel loginModel = ref.watch(loginFormProvider);
    LoginFormNotifier loginFormNotifier = ref.read(loginFormProvider.notifier);

    return Form(
      child: Column(
        children: [
          CustomAuthTextFormField(
            title: "Username",
            errorText: loginModel.usernameError,
            onChanged: (value) {
              loginFormNotifier.username(value);
            },
          ),
          const SizedBox(height: mediumGap),
          CustomAuthTextFormField(
            title: "Password",
            errorText: loginModel.passwordError,
            onChanged: (value) {
              loginFormNotifier.password(value);
            },
            obscureText: true,
          ),
          const SizedBox(height: largeGap),
          CustomElevatedButton(
            text: "로그인",
            click: () {
              // 검증
              bool isValid = loginFormNotifier.validate();
              if (isValid) {
                // 전역 상태로 관리되는 세션 프로바이더에게 로그인 요청
                ref.read(sessionProvider.notifier).login(
                      loginModel.username,
                      loginModel.password,
                    );
                // 서버로 로그인 통신 요청
                // 서버로 로그인 요청 성공 한다면 페이지 이동 ...
                // Navigator.popAndPushNamed(context, "/post/list");
              } else {
                ScaffoldMessenger.of(context)
                    .showSnackBar(SnackBar(content: Text("유효성 검사 실패")));
              }
            },
          ),
          CustomTextButton(
            text: "회원가입 페이지로 이동",
            click: () {
              Navigator.pushNamed(context, "/join");
            },
          ),
        ],
      ),
    );
  }
}

 

JoinForm 에 기능 완성해보기
import 'package:flutter/material.dart';
import 'package:flutter_blog/data/models/repository/user_repository.dart';
import 'package:flutter_blog/providers/form/join_form_notifier.dart';
import 'package:flutter_blog/providers/global/session_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../../../../_core/constants/size.dart';
import '../../../../widgets/custom_auth_text_form_field.dart';
import '../../../../widgets/custom_elavated_button.dart';
import '../../../../widgets/custom_text_button.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 상태 값 감시
    JoinModel joinModel = ref.watch(joinProvider);
    JoinFormNotifier formNotifier = ref.read(joinProvider.notifier);
    return Form(
      child: Column(
        children: [
          CustomAuthTextFormField(
            title: "Username",
            errorText: joinModel.usernameError,
            onChanged: (value) {
              // 입력값 변경시 상태 업데이트 + 실시간 검증
              print("value : ${value}");
              formNotifier.username(value);
            },
          ),
          const SizedBox(height: mediumGap),
          CustomAuthTextFormField(
            title: "Email",
            errorText: joinModel.emailError,
            onChanged: (value) {
              formNotifier.email(value);
            },
          ),
          const SizedBox(height: mediumGap),
          CustomAuthTextFormField(
            title: "Password",
            obscureText: true,
            errorText: joinModel.passwordError,
            onChanged: (value) {
              formNotifier.password(value);
            },
          ),
          const SizedBox(height: largeGap),
          CustomElevatedButton(
            text: "회원가입",
            click: () {
              // 최종 검증
              bool isValid = formNotifier.validate();
              if (isValid) {
                ref.read(sessionProvider.notifier).join(
                      joinModel.username,
                      joinModel.email,
                      joinModel.password,
                    );
              } else {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text("유효성 검사 실패입니다")),
                );
              }
            },
          ),
          CustomTextButton(
            text: "로그인 페이지로 이동",
            click: () {
              Navigator.pushNamed(context, "/login");
            },
          ),
        ],
      ),
    );
  }
}

리팩토링 코드

import 'package:flutter/material.dart'
    show ScaffoldMessenger, Text, SnackBar, Navigator;
import 'package:flutter_blog/_core/utils/my_http.dart';
import 'package:flutter_blog/data/models/repository/user_repository.dart';
import 'package:flutter_blog/data/models/user.dart';
import 'package:flutter_blog/main.dart';
import 'package:flutter_blog/providers/form/join_form_notifier.dart';
import 'package:flutter_blog/providers/form/login_form_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';

// 세션이라는 데이터를 구조화 해보자
// 창고 데이터 구상하기
class SessionModel {
  User? user;
  bool? isLogin;

  SessionModel({this.user, this.isLogin = false});

  @override
  String toString() {
    return 'SessionModel{user: $user, isLogin: $isLogin}';
  }
}

// 창고 매뉴얼
class SessionNotifier extends Notifier<SessionModel> {
  @override
  SessionModel build() {
    return SessionModel();
  }

  // 로그인 로직 설계 - 비즈니스 로직에 집중 !
  Future<Map<String, dynamic>> login(String username, String password) async {
    try {
      Map<String, dynamic> body =
          await UserRepository().login(username, password);

      if (body["success"] == false) {
        return body; // 서버에서 내려준 에러 정보를 그대로 반환 --> UI 측으로.
      }

      // 서버에서 받은 사용자 정보를 앱에서 사용할 수 있는 형태로 변환
      User user = User.fromMap(body["response"]);

      // 로컬 저장소에 JWT 토큰을 저장해 두자.
      // Shared... 보안에 강화된 녀석 // yml 라이브러리 선언 됨
      await secureStorage.write(key: "accessToken", value: user.accessToken);

      state = SessionModel(user: user, isLogin: true);

      // 로그인 성공 이후에 서버측에 통신 요청할 때마다 JWT 토큰을 주입해야 된다
      dio.options.headers['Authorization'] = user.accessToken;

      return {"success": true}; // 로그인 성공 정보 반환
    } catch (e) {
      // 네트 워크 장애
      return {"success": false, "errorMessage": "네트워크 오류 발생했습니다"};
    }
  }

  // 로그 아웃 기능
  Future<void> logout() async {
    try {
      await secureStorage.delete(key: "accessToken");
      state = SessionModel();
      dio.options.headers['Authorization'] = "";
      Logger().d("로그아웃 종료");
    } catch (e) {
      Logger().d("로그아웃 중 오류 발생");
      state = SessionModel();
      dio.options.headers['Authorization'] = "";
    }
  }

  // 자동 로그인 기능
  Future<bool> autoLogin() async {
    try {
      // 1단계 : 저장소에서 인증 토큰 확인
      String? accessToken = await secureStorage.read(key: "accessToken");
      if (accessToken == null) {
        return false;
      }
      // 2단계 : UserRepository()에게 자동 로그인 요청
      Map<String, dynamic> body = await UserRepository().autoLogin(accessToken);

      // 3단계 : 서버에서 받은 사용자 정보를 창고에 다시 넣어야 한다
      User user = User.fromMap(body["response"]);
      user.accessToken = accessToken;

      // 4단계 : 창고 데이터에 로그인된 상태로 업데이트 처리
      state = SessionModel(user: user, isLogin: true);

      // 5단계 : 이 후 모든 HTTP 요청에 인증 열쇠 자동 부여 하기
      dio.options.headers["Authorization"] = user.accessToken;
      Logger().d("자동 로그인 성공");
      return true;
    } catch (e) {
      Logger().d("자동 로그인 중 오류 발생 : ${e}");
      return false;
    }
  }

  // 회원 가입 로직 추가하기
  Future<Map<String, dynamic>> join(
      String username, String email, String password) async {
    Logger()
        .d("username : ${username}, email: ${email}, password : ${password}");

    Map<String, dynamic> body =
        await UserRepository().join(username, email, password);

    if (body["success"] == false) {
      return {"success": false, "errorMessage": body["errorMessage"]};
    } else {
      return {"success": true};
    }
  }
} // end of SessionNotifier

// 실제 창고 개설
final sessionProvider =
    NotifierProvider<SessionNotifier, SessionModel>(() => SessionNotifier());