[Flutter] 스나이퍼팩토리 7주차 주간평가 : 비밀듣는 고양이 업데이트

KWANWOO·2023년 3월 11일
1
post-thumbnail

스나이퍼팩토리 플러터 7주차 주간평가

  1. 비밀듣는 고양이 로그인 정보 저장 기능 구현
  2. 비밀듣는 고양이 비밀 업로드 시 내 이름 공개하기 기능 추가
  3. Singleton 디자인 패턴
  4. 비밀듣는 고양이의 Dio를 Singleton으로 생성

7주차 주간 평가에서는 [Flutter] 스나이퍼팩토리 33일차에서 만든 비밀듣는 고양이 앱(최종)을 업데이트 하여 몇 가지 기능을 추가하고자 한다.

해당 포스팅에서는 비밀듣는 고양이 앱의 모든 코드를 작성하지 않고 수정하거나 추가한 부분의 코드만 작성하여 설명한다. 업데이트 전의 자세한 전체 코드는 [Flutter] 스나이퍼팩토리 33일차에서 확인 가능하며 업데이트 후 전체 코드는 아래 깃허브 링크를 첨부했다.

Github - Kim-kwan-woo/secret_app

1. 비밀듣는 고양이 로그인 정보 저장 기능 구현

로그인 페이지에서 [로그인 정보 저장] 체크 박스를 체크했을 때, 자동 로그인을 하는 기능을 추가하고자 한다.

요구사항

  • 체크박스를 누르고 로그인을 진행하면, 앱을 재시작(새로고침)해도, 자동으로 로그인이 진행되도록 한다.
  • 이 때, 로그인 시 얻은 token 값을 저장해야 하며, 아래 API를 사용하여 유저 정보를 가져올 수 있다.
  • 해당 API의 header에 Authorization 값으로 "Bearer $token"을 보내면 다음과 같은 결과를 얻을 수 있다.
// 성공시 Response
{
  "token": "JWT_TOKEN",
  "record": {
    "id": "RECORD_ID",
    "collectionId": "_pb_users_auth_",
    "collectionName": "users",
    "created": "2022-01-01 01:00:00Z",
    "updated": "2022-01-01 23:59:59Z",
    "username": "username123",
    "verified": false,
    "emailVisibility": true,
    "email": "test@example.com",
    "name": "test",
    "avatar": "filename.jpg"
  }
}

코드 작성

  • lib/util/api_routes.dart
class ApiRoutes {
  static const user = 'api/collections/users/records?sort=-created'; //GET
  static const authWithPassword =
      'api/collections/users/auth-with-password'; //POST
  static const authRefresh = 'api/collections/users/auth-refresh'; //POST
  static const signup = 'api/collections/users/records'; //POST
  static const getSecrets =
      'api/collections/secrets/records?sort=-created'; //GET
  static const uploadSecret = 'api/collections/secrets/records'; //POST
}

자동로그인을 하기 위한 API URL을ApiRoutesauthRefresh로 저장했다.

  • lib/page/login_page.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/login_controller.dart';
import 'package:secret_app/util/app_routes.dart';
import 'package:secret_app/view/widget/app_logo.dart';
import 'package:secret_app/view/widget/custom_button.dart';
import 'package:secret_app/view/widget/custom_text_field.dart';

class LoginPage extends GetView<LoginController> {
  const LoginPage({super.key});
  static const route = '/login';

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const AppLogo(),
          const SizedBox(height: 16),
          //아이디 텍스트 필드
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: CustomTextField(
              hintText: '아이디',
              controller: controller.idController,
            ),
          ),
          //패스워드 텍스트 필드
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: CustomTextField(
              hintText: '비밀번호',
              controller: controller.pwController,
            ),
          ),
          //로그인 정보 저장 체크박스
          Row(
            children: [
              Obx(
                () => Checkbox(
                  activeColor: Colors.redAccent,
                  value: controller.isLoginInfoSave.value,
                  onChanged: controller.checkLoginInfoSave,
                ),
              ),
              const Text('로그인 정보 저장'),
            ],
          ),
          CustomButton(
            margin: const EdgeInsets.all(8.0),
            text: '로그인',
            onPressed: () {
              controller.login();
            },
          ),
          //회원가입 페이지 이동
          TextButton(
            onPressed: () => Get.toNamed(AppRoutes.signup),
            style: TextButton.styleFrom(
              foregroundColor: Colors.redAccent,
            ),
            child: const Text('회원가입'),
          ),
        ],
      ),
    );
  }
}

우선 기존 LoginPage에서 로그인 정보 저장 체크 박스를 만들었다. 해당 위젯은 Obx 위젯으로 감싸 체크의 변화를 그려주었다. 체크를 판단하는 변수는 LoginControllerisLoginInfoSave로 만들었다.

  • lib/controller/login_controller.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';

class LoginController extends GetxController {
  var idController = TextEditingController(); //id 텍스트 필드 컨트롤러
  var pwController = TextEditingController(); //패스워드 텍스트 필트 컨트롤러
  RxBool isLoginInfoSave = false.obs; //로그인 정보 저장 여부

  //AuthController의 login()을 사용해 로그인 수행
  login() {
    Get.find<AuthController>()
        .login(idController.text, pwController.text, isLoginInfoSave.value);
  }

  //로그인 정보 저장 체크박스 핸들러
  checkLoginInfoSave(bool? value) {
    if (value != null) {
      isLoginInfoSave(value);
    }
  }
}

LoginController에서 isLoginInSave 변수를 만들었다. 해당 변수는 로그인 정보 저장의 여부를 저장하며 Observable하게 RxBool 타입으로 생성했다.

checkLoginInfoSave()LoginPage에서 체크박스를 눌렀을 때 값을 isLoginInSave에 저장한다.

  • lib/controller/auth_controller.dart
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import 'package:secret_app/model/user.dart';
import 'package:secret_app/util/api_routes.dart';
import 'package:secret_app/util/app_routes.dart';
import 'package:shared_preferences/shared_preferences.dart';

class AuthController extends GetxController {
  final Rxn<User> _user = Rxn(); //유저 정보
  String? _token; //토큰 값
  final Dio dio = Dio(BaseOptions(
      baseUrl: 'http://52.79.115.43:8090/')); //dio 객체

  User? get user => _user.value; //user 정보 읽기

  //로그인
  login(String id, String pw, bool isLoginInfoSave) async {
    try {
      var res = await dio.post(
        ApiRoutes.authWithPassword,
        data: {
          'identity': id,
          'password': pw,
        },
      );

      if (res.statusCode == 200) {
        //토큰 값 저장
        if (isLoginInfoSave) {
          _token = res.data['token'];
          final prefs = await SharedPreferences.getInstance();
          prefs.setString('token', _token!); //로컬에 토큰 값 저장
        }
        var user = User.fromMap(res.data['record']);
        _user(user); //유저 정보 저장
      }
    } on DioError catch (e) {
      print(e.message);
    }
  }

  //로그 아웃
  logout() async {
    _user.value = null; //유저 정보 삭제
    final prefs = await SharedPreferences.getInstance();
    prefs.remove('token'); //자동 로그인 토큰 삭제
  }

  //회원 가입
  signup(
    String email,
    String password,
    String passwordConfirm,
    String username,
  ) async {
    try {
      await dio.post(
        ApiRoutes.signup,
        data: {
          'email': email,
          'password': password,
          'passwordConfirm': passwordConfirm,
          'username': username,
        },
      );
    } on DioError catch (e) {
      print(e);
    }
  }

  //유저 정보에 따른 페이지 이동
  _handleAuthChanged(User? data) {
    //유저 정보가 있으면 메인페이지로 이동
    if (data != null) {
      Get.offNamed(AppRoutes.main);
      return;
    }
    //유저 정보가 없으면 로그인 페이지로 이동
    Get.offAllNamed(AppRoutes.login);
    return;
  }

  //로컬에 저장된 토큰을 가져옴
  _autoLogin() async {
    final prefs = await SharedPreferences.getInstance();
    _token = prefs.getString('token'); //로컬의 토큰 값을 가져와 저장

    //토큰이 있는 경우
    if (_token != null) {
      try {
        //로그인 요청
        var res = await dio.post(
          ApiRoutes.authRefresh,
          options: Options(headers: {"Authorization": 'Bearer $_token'}),
        );

        if (res.statusCode == 200) {
          var user = User.fromMap(res.data['record']);
          _user(user); //유저 정보 저장
        }
      } on DioError catch (e) {
        print(e.message);
      }
    } else {
      //토큰이 없으면 로그인 페이지로 이동
      Get.offNamed(AppRoutes.login);
    }
  }

  
  void onInit() {
    super.onInit();
    //splash 화면을 2초동안 보여줌
    Future.delayed(const Duration(seconds: 2), () {
      _autoLogin();
    });
    //유저 정보를 관찰하여 변경된 경우 실행
    ever(_user, _handleAuthChanged);
  }
}

AuthController에서 먼저 토큰 값을 멤버 변수로 만들었다. (dio 객체는 원래 Network 클래스를 따로 만들어 관리했었지만 과제 4번의 Singleton을 사용해보기 위해 우선 컨트롤러에서 객체를 생성해 보았다.)

로그인을 수행하는 login 메서드에서 매개변수에 추가로 로그인 정보 저장 여부를 받고 이 값이 true일 경우 SharedPreferences에 토큰 값을 저장했다.

logout 메서드에서는 SharedPreferences에 저장된 토큰 값을 삭제했다.

_autoLogin 메서드를 만들어 토큰 값이 SharedPreferences에 있을 경우 자동 로그인을 수행했다. 기존의 코드에서 자동 로그인이 되면 로그인 화면이 떴다가 메인으로 넘어가는데 이를 방지하기 위해 onInit()에서 딜레이를 걸고, 스플래시 화면을 먼저 띄웠다.

  • lib/page/splash_page.dart
import 'package:flutter/material.dart';
import 'package:secret_app/view/widget/app_logo.dart';
import 'package:animate_do/animate_do.dart';

class SplashPage extends StatelessWidget {
  const SplashPage({super.key});
  static const route = '/splash';

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: FadeIn(
        child: const Center(
          child: AppLogo(),
        ),
      ),
    );
  }
}

스플래시 화면은 간단하게 화면 가운데에 앱 로고를 그렸다.

  • lib/util/app_routes.dart
import 'package:secret_app/view/page/login_page.dart';
import 'package:secret_app/view/page/main_page.dart';
import 'package:secret_app/view/page/secret_page.dart';
import 'package:secret_app/view/page/setting_page.dart';
import 'package:secret_app/view/page/signup_page.dart';
import 'package:secret_app/view/page/splash_page.dart';
import 'package:secret_app/view/page/upload_page.dart';

class AppRoutes {
  static const main = MainPage.route; //메인페이지
  static const login = LoginPage.route; //로그인 페이지
  static const signup = SignupPage.route; //회원가입 페이지
  static const secret = SecretPage.route; //비밀 페이지
  static const upload = UploadPage.route; //비밀 업로드 페이지
  static const setting = SettingPage.route; //설정 페이지
  static const splash = SplashPage.route; //스플래시 페이지
}

AppRoutes에 스플래시 페이지를 추가했다.

  • lib/util/app_pages.dart
import 'package:get/get.dart';
import 'package:secret_app/view/page/login_page.dart';
import 'package:secret_app/view/page/main_page.dart';
import 'package:secret_app/view/page/secret_page.dart';
import 'package:secret_app/view/page/setting_page.dart';
import 'package:secret_app/view/page/signup_page.dart';
import 'package:secret_app/view/page/splash_page.dart';
import 'package:secret_app/view/page/upload_page.dart';

import 'app_routes.dart';

class AppPages {
  //페이지 라우팅
  static final pages = [
    GetPage(name: AppRoutes.main, page: () => const MainPage()),
    GetPage(name: AppRoutes.login, page: () => const LoginPage()),
    GetPage(name: AppRoutes.signup, page: () => const SignupPage()),
    GetPage(name: AppRoutes.secret, page: () => const SecretPage()),
    GetPage(name: AppRoutes.upload, page: () => const UploadPage()),
    GetPage(name: AppRoutes.setting, page: () => const SettingPage()),
    GetPage(name: AppRoutes.splash, page: () => const SplashPage()),
  ];
}

AppPages에 스플래시 화면 이동을 추가했다.

  • lib/main.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
import 'package:secret_app/controller/login_controller.dart';
import 'package:secret_app/controller/secret_controller.dart';
import 'package:secret_app/controller/signup_controller.dart';
import 'package:secret_app/controller/upload_controller.dart';
import 'package:secret_app/util/app_pages.dart';
import 'package:secret_app/util/app_routes.dart';

void main() {
  runApp(const SecretApp());
}

class SecretApp extends StatelessWidget {
  const SecretApp({super.key});

  
  Widget build(BuildContext context) {
    return GetMaterialApp(
      //컨트롤러 바인딩
      initialBinding: BindingsBuilder(() {
        Get.put(AuthController());
        Get.lazyPut(() => LoginController(), fenix: true);
        Get.lazyPut(() => SignupController(), fenix: true);
        Get.lazyPut(() => SecretController(), fenix: true);
        Get.lazyPut(() => UploadController(), fenix: true);
      }),
      getPages: AppPages.pages,
      initialRoute: AppRoutes.splash, //스플래시 화면 출력
    );
  }
}

main.dart에서 초기 라우트를 스플래시 화면으로 설정했다.

결과

2. 비밀듣는 고양이 비밀 업로드 시 내 이름 공개하기 기능 추가

비밀 업로드시 내 이름을 공개하는 기능은 "비밀듣는 고양이 앱(최종)"을 만들 때 익명으로 할지를 선택하는 방식으로 구현했었다. 이를 체크 박스를 반대로 설정하면 기본을 익명으로 하고 선택 했을 때 실명으로 하는 기능을 적용할 수 있다.

요구사항

  • [내 이름 공개하기] 체크 버튼이 활성화 된 상태라면 현재 사용자의 이름을 함께 남길 수 있도록 한다.
  • 다음은 이 때 필요한 Upload API이다.(33일차 과제와 동일)
    -
{
  "id": "RECORD_ID",
  "collectionId": "5647cebjvtwtcu1",
  "collectionName": "secrets",
  "created": "2022-01-01 01:00:00Z",
  "updated": "2022-01-01 23:59:59Z",
  "secret": "test",
  "author": "RELATION_RECORD_ID",
	"authorName":"test"
}

코드 작성

  • lib/page/upload_page.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/upload_controller.dart';
import 'package:secret_app/view/widget/app_logo.dart';
import 'package:secret_app/view/widget/custom_button.dart';
import 'package:secret_app/view/widget/custom_text_field.dart';

class UploadPage extends GetView<UploadController> {
  const UploadPage({super.key});
  static const route = '/upload';

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const AppLogo(), //앱 로고
            const SizedBox(height: 16),
            //비밀 입력 텍스트 필드
            CustomTextField(
              maxLines: 6,
              hintText: '비밀을 입력하세요.',
              controller: controller.textController,
            ),
            //익명 체크박스
            Row(
              children: [
                Obx(
                  () => Checkbox(
                    activeColor: Colors.redAccent,
                    value: controller.isNameRevealed.value,
                    onChanged: controller.checkNameRevealed,
                  ),
                ),
                const Text('내 이름 공개하기'),
              ],
            ),
            //비밀 업로드 버튼
            CustomButton(
              text: '비밀 업로드',
              onPressed: () async {
                await controller.uploadSecret();
                if (controller.textController.text != '') {
                  Get.back();
                  Get.snackbar('비밀 업로드', controller.resultMsg);
                }
              },
            ),
          ],
        ),
      ),
    );
  }
}

기존의 UploadPage의 익명 체크 박스의 텍스트를 "내 이름 공개하기"로 변경하고, 연결한 변수를 컨트롤러의 isNameRevealed로 설정했다.

  • lib/controller/upload_controller.dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
import 'package:secret_app/util/api_routes.dart';

class UploadController extends GetxController {
  var textController = TextEditingController();
  String resultMsg = '';
  RxBool isNameRevealed = false.obs;
  final Dio dio = Dio(BaseOptions(
      baseUrl: 'http://52.79.115.43:8090/'));

  //비밀 업로드
  uploadSecret() async {
    if (textController.text == '') return;

    var controller = Get.find<AuthController>();
    try {
      var res = await dio.post(
        ApiRoutes.uploadSecret,
        data: {
          'secret': textController.text,
          'author': controller.user!.id,
          'authorName': isNameRevealed.value ? controller.user!.username : ''
        },
      );

      if (res.statusCode == 200) {
        resultMsg = '비밀을 성공적으로 업로드했습니다.';
      }
    } on DioError catch (e) {
      resultMsg = '[ERROR]비밀 업로드에 실패하였습니다.';
      print(e);
    }
  }

  //내 이름 공개하기 체크박스 onChanged 핸들러
  checkNameRevealed(bool? value) {
    if (value != null) {
      isNameRevealed(value);
    }
  }
}

UploadController에서는 내 이름 공개하기의 값을 저장할 isNamedRevealed를 생성하고, 해당 값을 변경할 핸들러인 checkNameRevealed() 메소드를 만들었다.

비밀을 업로드하는 uploadSecret() 메서드는 내 이름 공개하기가 true일 때, authorName을 같이 보내 저장했다.

결과

3. Singleton 디자인 패턴

싱글톤(Singleton) 패턴이란 객체의 인스턴스가 오직 1개만 생성되는 패턴을 의미한다.

이러한 방식으로 인스턴스를 오직 한 개만 생성하면 메모리 측면에서 이점이 있다. 최초로 생성된 객체가 고정됨 메모리 영역을 사용하기 때문에 추후 해당 객체에 접근할 때 메모리 낭비를 방지할 수 있다.

또한 이미 생성된 인스턴스를 사용하기 때문에 속도도 빠르다.

그리고 데이터 공유가 쉽다는 장점도 있다. 싱글톤 인스턴스가 전역으로 사용되는 인스턴스이기 때문에 다른 클래스의 인스턴스들이 접근하여 사용할 수 있다.

인스턴스가 한 개만 존재하는 것을 보증하고 싶은 경우에 사용하기도 한다.

하지만 여러 클래스의 인스턴스에서 싱글톤 인스턴스의 데이터에 동시에 접근하게 되면 동시성 문제가 생길 수도 있으니 주의해야 한다.

또한 자원을 공유하기 때문에 격리된 환경에서 테스트를 수행해야 할 경우 매번 인스턴스를 초기화해야 한다.

싱글톤 패턴은 유지 보수나, 테스트 이외에도 많은 문제가 있을 수 있기 때문에 정말 필요한 상황에서만 사용해야 한다.

Dart 싱글톤

Dart에서는 싱글톤 패턴을 factory 생성자를 사용해 만들 수 있다.

팩토리 생성자는 아래 두 포스팅에서 자세한 내용을 확인할 수 있다.

Dart에서 싱글톤은 아래와 같은 형식으로 작성할 수 있다.

class Singleton{
  static final Singleton _instance = Singleton._internal();
  
  factory Singleton(){
    return _instance;
  }
  
  Singleton._internal(){ // 클래스가 최초 생성될 때, 1회 발생
    // 초기화 코드  
  }
}

Dart 싱글톤 예시

아래 코드는 Dart에서 싱글톤 패턴을 생성하는 예시 코드이다. 테스트를 위해 싱글톤 객체를 생성하는 Singleton 클래스와 기존의 일반 클래스인 Normal을 작성했다.

class Singleton{
  static final Singleton _instance = Singleton._internal();
  int count=0;
  
  factory Singleton(){
    return _instance;
  }
  
  Singleton._internal(){
    //초기화 코드
    print('Created Singleton Class!');
    count = 0;
  }
}

class Normal{
  int count=0;
  Normal(){
    // 초기화 코드
    print('Created Normal Class!');
    count = 0;
  }
}

위의 클래스 객체를 생성하여 아래와 같이 테스트를 수행하는 main함수를 작성했다.

void main() {
  var normalOne = Normal(); //첫번째 일반 클래스 생성
  var normalTwo = Normal(); //두번째 일반 클래스 생성
  
  var singleOne = Singleton(); //첫번째 싱글톤 클래스 생성
  var singleTwo = Singleton(); //두번쨰 싱글톤 클래스 생성, 이미 클래스가 생성되었기 때문에
                               //인스턴스만 넘겨주며, 초기화 코드가 실행되지 않는다.
  
  print('singleton One: ${singleOne.count}, Two : ${singleTwo.count}');
  print('normal One : ${normalOne.count}, Two : ${normalTwo.count}');
  
  //각 클래스의 count 값 1씩 증가
  normalOne.count++;
  normalTwo.count++;
  
  singleOne.count++;
  singleTwo.count++;
  
  print('\n각 클래스 count값 1씩 증가 후\n');
  print('singleton One: ${singleOne.count}, Two : ${singleTwo.count}');
  print('normal One : ${normalOne.count}, Two : ${normalTwo.count}');
}

위의 코드를 실행하면 아래와 같은 결과를 얻을 수 있다.

결과를 보면 싱글톤 객체는 한 번만 생성된 것을 알 수 있다. 또한 각 count의 값을 증가시키면 싱글톤은 객체를 공유하기 때문에 값이 2로 증가한 것을 알 수 있다.

결론적으로 변경이 없거나, 전역으로 변수를 공유하는 객체를 싱글톤으로 생성하면 메모리나, 속도 측면에서 이점이 있기 때문에 유용하게 사용할 수 있다.

4. 비밀듣는 고양이의 Dio를 Singleton으로 생성

기존의 비밀듣는 고양이의 앱은 각 컨트롤러에서 네트워크 통신이 필요하면 서로 다른 Dio 객체를 생성했다.

싱글톤으로 단일 객체를 사용하기 위해 Dio를 싱글톤을 통해 사용할 수 있는 방법에 대한 코드를 ChatGPT를 활용해 생성하고자 한다.

요구사항

  • 클래스 이름은 CustomDio로 정의한다.
  • 얻은 코드를 사용하여 비밀듣는 고양이 앱에 사용되는 컨트롤러에 각각 적용하고 과정을 설명하시오.
    • AuthController의 Login, Signup 기능
    • SecretController의 비밀 읽기 기능
    • UploadController의 비밀 업로드 기능
  • 이 때, Dio를 사용한 CustomDio를 만들어 싱글톤으로 사용하면 어떤 이점이 있는지 정리하시오.

ChatGPT에서 Dio Singleton 생성

ChatGPT에 Flutter에서 사용할 수 있는 Dio Singleton 코드를 요청해 보았다. 아래와 같은 결과를 얻을 수 있었다.

DioSingleton 클래스의 코드를 얻을 수 있었고, 사용 방법까지 알 수 있었다. 이를 사용해 비밀듣는 고양이의 코드를 수정해 보자.

코드 작성

기존의 Network 클래스에서 관리하던 Dio는 삭제하고 새로 custom_dio.dart 파일을 아래와 같이 작성했다.

  • /lib/model/custom_dio.dart
import 'package:dio/dio.dart';

class CustomDio {
  static CustomDio? _instance;
  static Dio? _dio;

  CustomDio._internal() {
    // Initialize Dio instance
    _dio = Dio();
  }

  factory CustomDio() {
    if (_instance == null) {
      _instance = CustomDio._internal();
    }
    return _instance!;
  }

  Dio get dioInstance => _dio!;
}

ChatGPT에서 얻은 코드를 그대로 사용하면 null에 대한 체크가 제대로 수행되지 않아서 오류가 발생한다. 따라서 _instance_dio는 nullable로 수정하고, 팩토리 생성자의 리턴되는 값은 _instance!로 변경했다. 또한 dioInstance에서 리턴되는 dio객체도 _dio!로 작성했다.

이제 AuthControllerLogin, Signup 기능 SecretController의 비밀 읽기 기능 UploadController의 비밀 업로드 기능에 사용하는 dio 객체를 CustomDio를 통해 싱글톤으로 생성하도록 코드를 수정하면 된다.

세 컨트롤러에서 원래는 아래와 같은 방식으로 dio 객체를 생성했다.

final Dio dio = Dio(BaseOptions(baseUrl: 'http://52.79.115.43:8090/'));

해당 코드를 모두 아래와 같이 수정하면 된다.

final Dio dio = CustomDio().dioInstance;

이를 모두 적용한 세 가지 컨트롤러의 전체 코드는 아래와 같다.

  • lib/controller/auth_controller.dart
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import 'package:secret_app/model/custom_dio.dart';
import 'package:secret_app/model/user.dart';
import 'package:secret_app/util/api_routes.dart';
import 'package:secret_app/util/app_routes.dart';
import 'package:shared_preferences/shared_preferences.dart';

class AuthController extends GetxController {
  final Rxn<User> _user = Rxn(); //유저 정보
  String? _token; //토큰 값
  final Dio dio = CustomDio().dioInstance; //싱글톤 dio객체

  User? get user => _user.value; //user 정보 읽기

  //로그인
  login(String id, String pw, bool isLoginInfoSave) async {
    try {
      var res = await dio.post(
        ApiRoutes.authWithPassword,
        data: {
          'identity': id,
          'password': pw,
        },
      );

      if (res.statusCode == 200) {
        //토큰 값 저장
        if (isLoginInfoSave) {
          _token = res.data['token'];
          final prefs = await SharedPreferences.getInstance();
          prefs.setString('token', _token!); //로컬에 토큰 값 저장
        }
        var user = User.fromMap(res.data['record']);
        _user(user); //유저 정보 저장
      }
    } on DioError catch (e) {
      print(e.message);
    }
  }

  //로그 아웃
  logout() async {
    _user.value = null; //유저 정보 삭제
    final prefs = await SharedPreferences.getInstance();
    prefs.remove('token'); //자동 로그인 토큰 삭제
  }

  //회원 가입
  signup(
    String email,
    String password,
    String passwordConfirm,
    String username,
  ) async {
    try {
      await dio.post(
        ApiRoutes.signup,
        data: {
          'email': email,
          'password': password,
          'passwordConfirm': passwordConfirm,
          'username': username,
        },
      );
    } on DioError catch (e) {
      print(e);
    }
  }

  //유저 정보에 따른 페이지 이동
  _handleAuthChanged(User? data) {
    //유저 정보가 있으면 메인페이지로 이동
    if (data != null) {
      Get.offNamed(AppRoutes.main);
      return;
    }
    //유저 정보가 없으면 로그인 페이지로 이동
    Get.offAllNamed(AppRoutes.login);
    return;
  }

  //로컬에 저장된 토큰을 가져옴
  _autoLogin() async {
    final prefs = await SharedPreferences.getInstance();
    _token = prefs.getString('token'); //로컬의 토큰 값을 가져와 저장

    //토큰이 있는 경우
    if (_token != null) {
      try {
        //로그인 요청
        var res = await dio.post(
          ApiRoutes.authRefresh,
          options: Options(headers: {"Authorization": 'Bearer $_token'}),
        );

        if (res.statusCode == 200) {
          var user = User.fromMap(res.data['record']);
          _user(user); //유저 정보 저장
        }
      } on DioError catch (e) {
        print(e);
      }
    } else {
      //토큰이 없으면 로그인 페이지로 이동
      Get.offNamed(AppRoutes.login);
    }
  }

  
  void onInit() {
    super.onInit();
    //splash 화면을 2초동안 보여줌
    Future.delayed(const Duration(seconds: 2), () {
      _autoLogin();
    });
    //유저 정보를 관찰하여 변경된 경우 실행
    ever(_user, _handleAuthChanged);
  }
}
  • lib/controller/secret_controller.dart
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import 'package:secret_app/model/custom_dio.dart';
import 'package:secret_app/model/secret.dart';
import 'package:secret_app/util/api_routes.dart';

class SecretController extends GetxController {
  RxList<Secret> secrets = RxList();
  final Dio dio = CustomDio().dioInstance; //싱글톤 dio 객체

  readSecrets() async {
    try {
      //네트워크 데이터 요청(비밀 리스트)
      var res = await dio.get(ApiRoutes.getSecrets);

      if (res.statusCode == 200) {
        List<Map<String, dynamic>> data =
            List<Map<String, dynamic>>.from(res.data['items']);

        //data serialization 후 저장
        secrets.addAll(data.map((e) => Secret.fromMap(e)).toList());
      }
    } on DioError catch (e) {
      print(e);
    }
  }

  
  void onInit() {
    super.onInit();
    readSecrets(); //비밀 데이터 가져오기
  }
}
  • lib/controller/upload_controller.dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
import 'package:secret_app/model/custom_dio.dart';
import 'package:secret_app/util/api_routes.dart';

class UploadController extends GetxController {
  var textController = TextEditingController();
  String resultMsg = '';
  RxBool isNameRevealed = false.obs;
  final Dio dio = CustomDio().dioInstance; //싱글톤 dio 객체

  //비밀 업로드
  uploadSecret() async {
    if (textController.text == '') return;

    var controller = Get.find<AuthController>();
    try {
      var res = await dio.post(
        ApiRoutes.uploadSecret,
        data: {
          'secret': textController.text,
          'author': controller.user!.id,
          'authorName': isNameRevealed.value ? controller.user!.username : ''
        },
      );

      if (res.statusCode == 200) {
        resultMsg = '비밀을 성공적으로 업로드했습니다.';
      }
    } on DioError catch (e) {
      resultMsg = '[ERROR]비밀 업로드에 실패하였습니다.';
      print(e);
    }
  }

  //내 이름 공개하기 체크박스 onChanged 핸들러
  checkNameRevealed(bool? value) {
    if (value != null) {
      isNameRevealed(value);
    }
  }
}

앱이 실행될 때 AuthController에서 가장 먼저 dio 객체를 생성하기 때문에 이 때, 처음으로 CustomDio가 생성되고, 비밀을 요청하거나 업로드를 수행할 때는 기존에 생성되었던 dio 객체를 사용한다.

이 방식으로 수행하면 기존의 앱과 같은 기능을 수행하면서 dio로 네트워크에 요청을 보낼 때마다 객체를 생성하지 않아도 되기 때문에 메모리가 절약되고, 속도가 빨라질 수 있다.

ChatGPT가 생성한 코드를 사용하지 않고 CustomDio 작성

ChatGPT가 생성하준 싱글톤 dio 클래스 코드는 바로 사용할 수 없고 약간의 수정이 필요했다. 또한 그렇게 깔끔하게 작성된 코드가 아니라는 생각이 들었다.

따라서 과제 3번에서 학습한 싱글톤 코드의 방식으로 CustomDio를 수정해 보았다.

import 'package:dio/dio.dart';

class CustomDio {
  static final CustomDio _instance = CustomDio._internal(); //인스턴스
  static Dio? _dio; //dio 객체

  CustomDio._internal() {
    // Initialize Dio instance
    _dio = Dio(BaseOptions(baseUrl: 'http://52.79.115.43:8090/'));
  }

  //CustomDio 팩토리 생성자
  factory CustomDio() {
    return _instance;
  }

  //dio 객체 리턴
  Dio get dioInstance => _dio!;
}

코드가 크게 다른 점은 없지만 위와 같이 작성했을 때 좀 더 깔끔한 느낌이 들었다. 기능상의 차이는 없기 때문에 자신이 원하는 방식대로 코드를 작성하면 될 것 같다.


7주차 주간과제

7주차의 주간과제를 끝냈다. 어제 했던 비밀듣는 고양이 앱 제작에서 업데이트를 하는 내용이었다. 크게 어려운 점은 없었지만 포스팅 작성이 엄청 길어졌다. ㅋㅋㅋㅋ 4번 과제를 수행하면서 ChatGPT를 사용했는데 솔직히 좀 놀랐다. 싱글톤으로 Dio 객체를 생성하는 코드를 요청했는데 바로 작성해주다니...🫢🫢 물론 약간의 수정이 필요하긴 했지만 진짜 대단한것 같다. 근데 마지막 과제에서 CustomDio를 model 폴더에 넣었는데 맞게 분류를 한 건지 모르겠다...ㅠㅠ(util 폴더로 넣는게 더 맞았을라나..?) 어쨌든 과제는 여기서 끝! 이번주도 도전과제는 없습니다.

(원래 추가 학습을 해서 포스팅에 정리하는데 변명 같지만 오늘은 새벽이기도 하고...포스팅도 너무 길어져서...생략하겠습니다ㅋㅋㅋㅋ)

📄Reference

profile
관우로그

0개의 댓글

관련 채용 정보