참고한 코드 내용 출처 :
그림 : (Android Studio에서 git 주소를 통해 다운로드)
서버 : (intellij에서 git 주소를 통해 다운로드) :
내 gitHub 소스 코드
모든 파일 (Android Studio + jar파일로 변경한 서버 포함)
서버 파일 (intellij 서버 파일) :
사전 작업
클릭 시 서버 프로젝트 빌드 및 적용, 서버 실행, 추가 변경 사항 확인 가능
- 서버 프로젝트의 테스트 코드가 모두 성공인지 확인 후 git bash에서 아래를 타이핑 하여 빌드를 진행합니다.
./gradlew clean build
- 빌드가 완료된 되면 build 폴더가 생성되며 build > libs > jar파일을 복사한 뒤,

안드로이드 스튜디오에 내려받은(혹은 만들어놓은) 프로젝트에 새 폴더(blogserver)를 생성한 뒤 복사한 파일을 붙여넣습니다.

- 붙여넣은 파일을 우클릭 > Open In > Terminal 클릭

터미널에서 아래 이미지와 같이 타이핑 하되, 파일이름은 자신이 붙여 넣은 파일이름을 작성합니다.
tip : 앞글자를 조금만 타이핑 하고 tab 키를 이용해 자동 완성할 수 있습니다.

해당 예제 기준 : 만약 안드로이드 스튜디오 프로젝트 파일 안의 my_http.dart 파일 내용 중 기본 url이 자신의 컴퓨터 ip가 아니라면 자신 ip로 변경합니다. (cmd 창에서 ipconfig 로 확인 가능)


추가 변경 사항 :
파일 이름 중 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 객체를 가져옵니다.

- 회원 가입 버튼의 기능을 정의합니다. (유효성 검사 제외함)

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 폴더 안에 아래 사진과 같이 추가적으로 폴더와 파일을 생성하였습니다.

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

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");
}
코드 상세 내용
코드 설명
- 로그인 정보 설정
final body = {
"username": username,
"password": password,
};
username
과 password
를 포함한 맵(Map) 객체 body
를 생성합니다.- 사용자 정보 및 액세스 토큰 가져오기
final (responseBody, accessToken) = await userRepository.findByUsernameAndPassword(body);
userRepository.findByUsernameAndPassword(body)
메서드를 호출하여 사용자 정보와 액세스 토큰을 비동기로 받아옵니다. 이 메서드는 두 가지 값을 반환하며, 하나는 responseBody
(응답 바디)이고 다른 하나는 accessToken
(액세스 토큰)입니다.- 로그인 실패 처리
if (!responseBody["success"]) {
ScaffoldMessenger.of(mContext!).showSnackBar(
SnackBar(content: Text("로그인 실패 : ${responseBody["errorMessage"]}")),
);
return;
}
응답 본문에서
responseBody["success"]
값을 확인하여 로그인 성공 여부를 판단합니다. 성공하지 못했을 경우, 오류 메시지를 표시하는 스낵바(SnackBar)를 띄우고, 함수를 종료합니다 (return
).- 세션 사용자 갱신
Map<String, dynamic> data = responseBody["response"];
state = SessionUser(
id: data["id"],
username: data["username"],
accessToken: accessToken,
isLogin: true);
응답 본문에서
responseBody["response"]
를 추출하여, SessionUser
로 상태를 갱신합니다. 여기서 세션 사용자 객체는 유저 아이디, 유저 이름, 액세스 토큰, 로그인 여부를 포함합니다.- 토큰을 안전한 저장소에 저장
await secureStorage.write(key: "accessToken", value: accessToken);
받아온 액세스 토큰을
secureStorage
에 저장합니다. I/O 작업이므로 await
를 사용하여 완료를 기다립니다.- Dio 클라이언트 헤더 설정
dio.options.headers = {"Authorization": accessToken};
Dio
HTTP 클라이언트의 헤더에 받아온 액세스 토큰을 설정합니다. - 경로 이동
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);
}
상세 코드 내용
코드 설명
- HTTP POST 요청
Response response = await dio.post("/login", data: data);
dio.post
메서드를 사용하여 "/login"
엔드포인트에 HTTP POST 요청을 보내고, 요청 데이터는 data
에 포함됩니다. 요청 후 응답은 response
객체로 받습니다.- 응답 본문 파싱
Map<String, dynamic> body = response.data;
응답 본문 데이터 (
response.data
)를 body
라는 이름의 맵(Map)으로 변환하여 저장합니다. 이 맵에는 서버로부터 받은 응답 데이터가 포함됩니다.- 액세스 토큰 추출
String accessToken = "";
try {
// 로그인에 실패하면 토큰이 없어 null 예외가 발생하기 때문에 try 사용
accessToken = response.headers["Authorization"]![0]; // 헤더에 있는 토큰 값을 가져옴 / 0번지에 토큰이 있다.
} catch (e) {}
액세스 토큰을 헤더에서 추출하기 위해 초기
accessToken
변수를 빈 문자열로 설정합니다. 그 후, try
블록 안에서 response.headers["Authorization"]![0]
를 통해 헤더에서 액세스 토큰을 가져옵니다. 액세스 토큰이 없는 경우 null
예외가 발생할 수 있으므로 try-catch
문을 사용합니다.- 결과 반환
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 객체 가져오는 내용 생략

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