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

화낼거양's avatar
Jan 03, 2025
서버 통신을 활용한 블로그 앱 (사전 작업 + 로그인)
 
 
 

참고한 코드 내용 출처 :

 
그림 : (Android Studio에서 git 주소를 통해 다운로드)
 
 
서버 : (intellij에서 git 주소를 통해 다운로드) :
 

내 gitHub 소스 코드

 
모든 파일 (Android Studio + jar파일로 변경한 서버 포함)
 
 
서버 파일 (intellij 서버 파일) :
 

 
 
 

사전 작업

 

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

  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, };
      usernamepassword를 포함한 맵(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

moohyun