inblog logo
|
moohyun
    플러터

    서버 통신을 활용한 블로그 앱 (사전 작업 + 로그인)

    화낼거양's avatar
    화낼거양
    Jan 03, 2025
    서버 통신을 활용한 블로그 앱 (사전 작업 + 로그인)
    Contents
    사전 작업회원가입로그인로그 아웃자동 로그인
     
     
     

    참고한 코드 내용 출처 :

     
    그림 : (Android Studio에서 git 주소를 통해 다운로드)
    주소 : https://github.com/kdit-2024-08month/flutter-blog-riverpod-start
     
     
    서버 : (intellij에서 git 주소를 통해 다운로드) :
    주소 : https://github.com/kdit-2024-08month/spring-blog-restapi.git
     

    내 gitHub 소스 코드

     
    모든 파일 (Android Studio + jar파일로 변경한 서버 포함)
    주소 : https://github.com/MooHyunPark/spring-blog-server-plus-flutter
     
     
    서버 파일 (intellij 서버 파일) :
    주소 : https://github.com/MooHyunPark/spring-blog-server-only
     

     
     
     

    사전 작업

     

    클릭 시 서버 프로젝트 빌드 및 적용, 서버 실행, 추가 변경 사항 확인 가능

    1. 서버 프로젝트의 테스트 코드가 모두 성공인지 확인 후 git bash에서 아래를 타이핑 하여 빌드를 진행합니다.
    ./gradlew clean build
     
     
    1. 빌드가 완료된 되면 build 폴더가 생성되며 build > libs > jar파일을 복사한 뒤,
    notion image
     
     
     
    안드로이드 스튜디오에 내려받은(혹은 만들어놓은) 프로젝트에 새 폴더(blogserver)를 생성한 뒤 복사한 파일을 붙여넣습니다.
    notion image
     
     
     
     
    1. 붙여넣은 파일을 우클릭 > Open In > Terminal 클릭
    notion image
     
     
     
    터미널에서 아래 이미지와 같이 타이핑 하되, 파일이름은 자신이 붙여 넣은 파일이름을 작성합니다.
    tip : 앞글자를 조금만 타이핑 하고 tab 키를 이용해 자동 완성할 수 있습니다.
    notion image
     
     
     
     
     
    해당 예제 기준 : 만약 안드로이드 스튜디오 프로젝트 파일 안의 my_http.dart 파일 내용 중 기본 url이 자신의 컴퓨터 ip가 아니라면 자신 ip로 변경합니다. (cmd 창에서 ipconfig 로 확인 가능)
    notion image
    notion image
     
     
    💡
    추가 변경 사항 :
    파일 이름 중 gm > gvm으로 변경
    CorsFilter 파일 삭제
    Webconfig 파일에 아래 내용을 추가하고 FilterConfig와 테스트 파일의 CorsFilter 관련 내용 삭제
     
    @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*") // 모든 포트를 허용 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("Authorization", "Content-Type") .exposedHeaders("Authorization") .allowCredentials(true) // 인증 정보 허용 .maxAge(3600); }
     
     
     
     
     
     

    회원가입

    View (join_body.dart 파일 내용)

     
    • WidgetRef를 사용하기 위해 ConsumerWidget으로 변경합니다.
    • ref를 통해 SessionGVM 객체를 가져옵니다.
    notion image
     
     
    • 회원 가입 버튼의 기능을 정의합니다. (유효성 검사 제외함)
    notion image
     
     
     

    ViewModel (session_gvm.dart 파일 내용)

    Future<void> join(String username, String email, String password) async { final body = { "username": username, "email": email, "password": password, }; Map<String, dynamic> responseBody = await userRepository.save(body); if (!responseBody["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("회원가입 실패 : ${responseBody["errorMessage"]}")), ); } Navigator.pushNamed(mContext, "/login"); }
     
    코드 상세 설명
    • 참가자 함수 정의 (join 함수):
      • Future<void> join(String username, String email, String password) async
      • 입력 파라미터로 username, email, password를 받습니다.
      • async 키워드를 사용하여 비동기 함수로 정의하였습니다. 이는 코드가 동시에 여러 작업을 처리할 수 있도록 합니다.
    • 요청 본문 생성 (body):
      • final body = { "username": username, "email": email, "password": password, };
      • username, email, password를 포함하는 JSON 형식의 요청 본문을 생성합니다.
    • 저장소 호출 (userRepository.save 메서드):
      • Map<String, dynamic> responseBody = await userRepository.save(body);
      • userRepository.save 메서드를 호출하여 서버에 body 데이터를 전송하고, 응답을 responseBody 변수에 저장합니다.
      • await 키워드를 사용하여 이 작업이 완료될 때까지 기다립니다.
    • 응답 처리:
      • if (!responseBody["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("회원가입 실패 : ${responseBody["errorMessage"]}")), ); }
      • 응답이 성공적인지 검사하고, 실패했을 경우 오류 메시지를 표시합니다.
      • ScaffoldMessenger를 사용하여 현재 화면에 스낵바를 통해 응답의 errorMessage를 출력합니다.
    • 네비게이터 호출 (Navigator.pushNamed):
      • Navigator.pushNamed(mContext, "/login");
      • Navigator.pushNamed를 호출하여 로그인 화면 /login으로 네비게이트합니다.
     
     
     

    Repository

     
    • data 폴더 안에 아래 사진과 같이 추가적으로 폴더와 파일을 생성하였습니다.
    notion image
     
     
    생성한 dart 파일 내용 :
    import 'package:dio/dio.dart'; import '../../_core/utils/my_http.dart'; class UserRepository { const UserRepository(); Future<Map<String, dynamic>> save(Map<String, dynamic> data) async { Response response = await dio.post("/join", data: data); Map<String, dynamic> body = response.data; //Logger().d(body); // test 코드 작성 직접해보기 return body; } }
     
    코드 상세 설명
     
    주요 기능들:
     
    1. 클래스 정의:
      1. class UserRepository { const UserRepository(); }
        • const UserRepository()을 사용하여 UserRepository 클래스의 인스턴스를 생성합니다. 이 클래스는 사용자 데이터를 저장하기 위한 메서드를 포함하고 있습니다.
    1. save 메서드:
      1. Future<Map<String, dynamic>> save(Map<String, dynamic> data) async
        • 이 메서드는 비동기 함수를 정의하며, 입력 파라미터로 data라는 이름의 Map<String, dynamic> 타입의 데이터를 받습니다.
    1. HTTP POST 요청:
      1. Response response = await dio.post("/join", data: data);
        • Dio 라이브러리를 사용하여 "/join" 엔드포인트로 data를 전송하는 HTTP POST 요청을 보냅니다.
        • await 키워드를 사용하여 이 작업이 완료될 때까지 기다립니다.
    1. 응답 데이터 처리:
      1. Map<String, dynamic> body = response.data;
        • 서버에서 돌아온 응답 데이터를 response 변수에 저장하고, 이를 다시 body 변수에 저장합니다.
    1. 테스트 코드 (주석 처리됨):
      1. //Logger().d(body);
        • Logger 패키지를 사용하여 body 내용을 디버그 로그에 출력하는 코드가 주석 처리되어 있습니다.
        • 이는 개발 과정에서 테스트 목적으로 사용될 수 있습니다.
    1. 결과 반환:
      1. return body;
        • 추출된 응답 데이터를 반환합니다. 반환 타입은 Map<String, dynamic>입니다.
     
     
     
     
     
     

    로그인

    View (login_body.dart 파일 내용)

     
    • 로그인 버튼 기능을 아래 이미지와 같이 정의 합니다.
    • ConsumerWidget 변경 및 SessionGVM 객체 가져오는 내용 생략
    notion image
     
     
     
     

    ViewModel (session_gvm.dart 파일 내용)

     
    Future<void> login(String username, String password) async { final body = { "username": username, "password": password, }; // final 대신 var도 사용 가능 final (responseBody, accessToken) = await userRepository.findByUsernameAndPassword(body); if (!responseBody["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("로그인 실패 : ${responseBody["errorMessage"]}")), ); return; } // 1. SessionUser 갱신 Map<String, dynamic> data = responseBody["response"]; state = SessionUser( id: data["id"], username: data["username"], accessToken: accessToken, isLogin: true); // 2. 토큰을 Storage 저장. 오래걸리기 때문에 await 필수 await secureStorage.write(key: "accessToken", value: accessToken); // I/O // 3. Dio 토큰 세팅 dio.options.headers = {"Autohrization": accessToken} Navigator.popAndPushNamed(mContext, "/post/list"); }
     
    코드 상세 내용
     

    코드 설명

    1. 로그인 정보 설정
      1. final body = { "username": username, "password": password, };
        username과 password를 포함한 맵(Map) 객체 body를 생성합니다.
    1. 사용자 정보 및 액세스 토큰 가져오기
      1. final (responseBody, accessToken) = await userRepository.findByUsernameAndPassword(body);
        userRepository.findByUsernameAndPassword(body) 메서드를 호출하여 사용자 정보와 액세스 토큰을 비동기로 받아옵니다. 이 메서드는 두 가지 값을 반환하며, 하나는 responseBody (응답 바디)이고 다른 하나는 accessToken (액세스 토큰)입니다.
    1. 로그인 실패 처리
      1. if (!responseBody["success"]) { ScaffoldMessenger.of(mContext!).showSnackBar( SnackBar(content: Text("로그인 실패 : ${responseBody["errorMessage"]}")), ); return; }
        응답 본문에서 responseBody["success"] 값을 확인하여 로그인 성공 여부를 판단합니다. 성공하지 못했을 경우, 오류 메시지를 표시하는 스낵바(SnackBar)를 띄우고, 함수를 종료합니다 (return).
    1. 세션 사용자 갱신
      1. Map<String, dynamic> data = responseBody["response"]; state = SessionUser( id: data["id"], username: data["username"], accessToken: accessToken, isLogin: true);
        응답 본문에서 responseBody["response"]를 추출하여, SessionUser로 상태를 갱신합니다. 여기서 세션 사용자 객체는 유저 아이디, 유저 이름, 액세스 토큰, 로그인 여부를 포함합니다.
    1. 토큰을 안전한 저장소에 저장
      1. await secureStorage.write(key: "accessToken", value: accessToken);
        받아온 액세스 토큰을 secureStorage에 저장합니다. I/O 작업이므로 await를 사용하여 완료를 기다립니다.
    1. Dio 클라이언트 헤더 설정
      1. dio.options.headers = {"Authorization": accessToken};
        Dio HTTP 클라이언트의 헤더에 받아온 액세스 토큰을 설정합니다.
    1. 경로 이동
      1. Navigator.popAndPushNamed(mContext, "/post/list");
        로그인이 성공적이라면, 해당 경로 (/post/list)로 네비게이션을 이동시킵니다.
     
     

    Repository

     
    Future<(Map<String, dynamic>, String)> findByUsernameAndPassword( Map<String, dynamic> data) async { Response response = await dio.post("/login", data: data); Map<String, dynamic> body = response.data; //Logger().d(body); // test 코드 작성 직접해보기 String accessToken = ""; try { // 로그인에 실패하면 토큰이 없어 null 예외가 발생하기 때문에 try 사용 accessToken = response .headers["Authorization"]![0]; // 헤더에 있는 토큰 값을 가져옴 / 0번지에 토큰이 있다. } catch (e) {} //Logger().d(accessToken); return (body, accessToken); }
     
    상세 코드 내용
     

    코드 설명

    1. HTTP POST 요청
      1. Response response = await dio.post("/login", data: data);
        dio.post 메서드를 사용하여 "/login" 엔드포인트에 HTTP POST 요청을 보내고, 요청 데이터는 data에 포함됩니다. 요청 후 응답은 response 객체로 받습니다.
    1. 응답 본문 파싱
      1. Map<String, dynamic> body = response.data;
        응답 본문 데이터 (response.data)를 body라는 이름의 맵(Map)으로 변환하여 저장합니다. 이 맵에는 서버로부터 받은 응답 데이터가 포함됩니다.
    1. 액세스 토큰 추출
      1. String accessToken = ""; try { // 로그인에 실패하면 토큰이 없어 null 예외가 발생하기 때문에 try 사용 accessToken = response.headers["Authorization"]![0]; // 헤더에 있는 토큰 값을 가져옴 / 0번지에 토큰이 있다. } catch (e) {}
        액세스 토큰을 헤더에서 추출하기 위해 초기 accessToken 변수를 빈 문자열로 설정합니다. 그 후, try 블록 안에서 response.headers["Authorization"]![0]를 통해 헤더에서 액세스 토큰을 가져옵니다. 액세스 토큰이 없는 경우 null 예외가 발생할 수 있으므로 try-catch 문을 사용합니다.
    1. 결과 반환
      1. return (body, accessToken);
        응답 본문 (body)과 액세스 토큰 (accessToken)을 튜플 형태로 반환합니다. 이 함수는 두 개의 값을 반환하여 login 함수에서 처리될 것입니다.
         
         

         

        함수에서 다중 값을 반환하는 방법 두가지 (dart 문법)

         
        (String, int) hello(){ return ("ssar", 1234); } void main() { var (username, password) = hello(); print(username); print(password); } ///////////////////////////////// 위 또는 아래를 사용하면 여러 값을 return 할 수 있다. ({String username, int password}) hello(){ return (username:"ssar", password:1234); } void main() { var n = hello(); print(n.username); print(n.password); }
     
     
     
     

    로그 아웃

    View (custom_navigator.dart 파일 내용)

     
    • 로그 아웃 버튼의 기능을 정의합니다
    • ConsumerWidget 변경 및 SessionGVM 객체 가져오는 내용 생략
    notion image
     
     
     
     

    ViewModel (session_gvm.dart 파일 내용)

     
    Future<void> logout() async { // 1. 디바이스 토큰 삭제 await secureStorage.delete(key: "accessToken"); // 2. 상태 갱신 state = SessionUser(); // 3. dio 갱신 dio.options.headers["Authorization"] = ""; // 4. 모든 화면을 파괴하고 로그인 페이지로 이동 Navigator.pushNamedAndRemoveUntil(mContext, "/login", (route) => false); }
     
    상세 코드 설명

    코드 설명

     
    • 디바이스 토큰 삭제
      • await secureStorage.delete(key: "accessToken"); // I/O 작업
      • 이 줄은 안전한 저장소(secure storage)에서 저장된 액세스 토큰(access token)을 삭제합니다. 액세스 토큰을 삭제함으로써 사용자는 보안적으로 로그아웃 상태가 됩니다. 이 작업은 비동기적(asynchronous)이며, 완료될 때까지 다음 코드를 실행하지 않습니다(await 사용).
    • 상태 갱신
      • state = SessionUser(isLogin: false);
      • 이 줄은 세션 상태를 갱신하여 사용자가 로그아웃 상태임을 나타냅니다. SessionUser 객체의 isLogin 속성을 false로 설정함으로써 현재 로그인된 유저가 없음을 반영합니다.
    • Dio 헤더 초기화
      • dio.options.headers["Authorization"] = "";
      • Dio는 HTTP 요청 클라이언트입니다. 이 줄은 Dio의 옵션에서 Authorization 헤더를 초기화합니다. 이렇게 함으로써 이후의 네트워크 요청에 사용자 인증 정보가 포함되지 않게 합니다.
    • 로그인 페이지로 이동
      • Navigator.pushNamedAndRemoveUntil(mContext, "/login", (route) => false);
      • 이 줄은 사용자 로그인 페이지로 네비게이션 하되, 기존에 쌓인 모든 스택을 제거하고 로그인 페이지로 이동합니다.
     
     
     
     

    자동 로그인

    View (splash_page.dart 파일 내용)

    💡
    기존 파일 내용과 변경점은 없습니다.
    splash_page.dart 파일 전체 내용
    import 'package:flutter/material.dart'; import 'package:flutter_blog/data/gvm/session_gvm.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class SplashPage extends ConsumerWidget { const SplashPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { ref.read(sessionProvider.notifier).autoLogin(); return Scaffold( body: Center( child: Image.asset( 'assets/splash.gif', width: double.infinity, height: double.infinity, fit: BoxFit.cover, ), ), ); } }
     
     

    ViewModel (session_gvm.dart 파일 내용)

    // 1. 절대 SessionUser가 있을 수 없다. Future<void> autoLogin() async { // 1-1. 토큰을 디바이스에서 가져오기 String? accessToken = await secureStorage.read(key: "accessToken"); // 토큰이 없으면 로그인 페이지로 if (accessToken == null) { Navigator.popAndPushNamed(mContext, "/login"); return; } // 토큰을 검증한 뒤 유효한지 검사 Map<String, dynamic> responseBody = await userRepository.autoLogin(accessToken); // 토큰이 유효하지 않으면 로그인 페이지로 if (!responseBody["success"]) { Navigator.popAndPushNamed(mContext, "/login"); return; } // 상태 갱신 Map<String, dynamic> data = responseBody["response"]; state = SessionUser( id: data["id"], username: data["username"], accessToken: accessToken, isLogin: true); // Dio 토큰 세팅 dio.options.headers = {"Autohrization": accessToken}; // 갱신이 끝난 뒤 메인 화면으로 이동 Navigator.popAndPushNamed(mContext, "/post/list"); }
     
    상세 코드 설명

    코드 설명

    1. 토큰 가져오기
      1. String? accessToken = await secureStorage.read(key: "accessToken");
        secureStorage에서 accessToken을 읽어옵니다. 이 액세스 토큰은 사용자 로그인 상태를 유지하는 데 필요합니다. 존재하지 않는 경우 null이 반환됩니다.
    1. 로그인 페이지로 이동
      1. if (accessToken == null) { Navigator.popAndPushNamed(mContext, "/login"); return; }
        액세스 토큰이 없다면, 사용자를 로그인 페이지로 이동시키고 함수를 종료합니다. 즉, 이 단계에서는 세션이 유효하지 않은 경우 로그인 화면으로 리디렉션합니다.
    1. 토큰 검증 및 유효성 검사
      1. Map<String, dynamic> responseBody = await userRepository.autoLogin(accessToken);
        저장된 액세스 토큰을 서버에 전송하여 유효성을 검증합니다. 서버는 토큰의 유효성 여부에 대한 응답(responseBody)을 반환합니다.
    1. 유효하지 않은 토큰 처리
      1. if (!responseBody["success"]) { Navigator.popAndPushNamed(mContext, "/login"); return; }
        서버의 응답에서 success 플래그를 확인합니다. 유효하지 않은 토큰일 경우, 로그인 페이지로 이동시키고 함수를 종료합니다.
    1. 상태 갱신
      1. Map<String, dynamic> data = responseBody["response"]; state = SessionUser( id: data["id"], username: data["username"], accessToken: accessToken, isLogin: true);
        서버에서 받은 응답 데이터를 사용해 SessionUser 객체를 갱신합니다. 상태가 최신 유저 정보 및 액세스 토큰으로 업데이트된 것을 반영합니다.
    1. Dio 클라이언트 헤더 설정
      1. dio.options.headers = {"Authorization": accessToken};
        Dio HTTP 클라이언트의 헤더에 액세스 토큰을 설정합니다. 이를 통해 이후 요청 시 유효한 인증 정보를 포함하게 됩니다.
    1. 메인 화면으로 이동
      1. Navigator.popAndPushNamed(mContext, "/post/list");
        상태가 갱신된 후, 사용자가 메인 화면(/post/list)으로 이동하도록 설정합니다.
     
     
     

    Repository

    Future<Map<String, dynamic>> autoLogin(String accessToken) async { Response response = await dio.post("/auto/login", options: Options(headers: {"Authorization": accessToken})); Map<String, dynamic> body = response.data; return body; }
     
    상세 코드 설명

    코드 설명

    1. HTTP POST 요청 구성
      1. Response response = await dio.post("/auto/login", options: Options(headers: {"Authorization": accessToken}));
        dio.post 메서드를 사용하여 /auto/login 엔드포인트에 HTTP POST 요청을 보냅니다. 요청 옵션의 헤더에 accessToken 값을 넣어 인증을 수행합니다.
    1. 응답 본문 파싱
      1. Map<String, dynamic> body = response.data;
        서버에서 반환된 응답 데이터를 body라는 이름으로 저장합니다. 이 데이터는 맵(Map) 형태로, 서버의 응답 본문이 포함됩니다.
    1. 결과 반환
      1. return body;
        파싱된 응답 데이터를 반환합니다. 이 데이터를 통해 호출되는 코드에서 서버의 응답을 처리할 수 있게 됩니다.
     
     
     
     
     
     
     
     
     
    Share article
    Contents
    사전 작업회원가입로그인로그 아웃자동 로그인

    moohyun

    RSS·Powered by Inblog