[팀프로젝트]모바일앱 게시판 만들기 - 2편 : 로그인/회원가입 개발하기
이번 글에서는 로그인/회원가입 구현을 위해 Flutter 카카오 연동과 API 서버 연결을 해보겠습니다.
현재까지 추가된 API는 다음과 같습니다.
- [POST] /oauth/kakao : 로그인
- [POST] /user/join : 회원가입
개발 환경을 참고해 주세요.
- Flutter 3.16.5
- Dart 3.2.3
1. 카카오 연동하기
1-1. 애플리케이션 추가
카카오 디벨로퍼스에 접속해 애플리케이션을 추가합니다.
1-2. 앱 키 확인
앱 키를 확인하고 .env 파일에 추가합니다.
# 카카오 API Info
KAKAO_NATIVE_APP_KEY=네이티크 앱 키
KAKAO_JAVASCRIPT_APP_KEY=자바스크립트 앱 키
# 농실농실 API
# 애뮬레이터에서 로컬 서버에 접속하기 위해서 아이피를 입력합니다.
NONGSIL_API_URL=http://192.168.0.99:9090
API 서버 주소도 미리 추가했습니다.
1-3. 플랫폼 등록
안드로이드 키 해시 등록 방법은 이 글을 확인해 주세요.
1-4. 카카오 로그인 활성화
카카오 로그인을 활성화 하고 동의항목에 닉네임과 프로필 사진을 동의하도록 설정합니다.
2. 로그인 추가
2-1. 의존성 추가
flutter pub add flutter_dotenv
flutter pub add flutter_secure_storage
flutter pub add dio
flutter pub add kakao_flutter_sdk
2-2. 플랫폼 설정
카카오 로그인을 사용하기 위해 플랫폼별 설정이 필요합니다.
[Android]
AndroidManifest.xml
<activity
android:name="com.kakao.sdk.flutter.FollowChannelHandlerActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- 카카오 로그인 Redirect URI -->
<data android:scheme="kakao카카오네이티브키입력" android:host="oauth"/>
<!-- Redirect URI: "kakao${NATIVE_APP_KEY}://channel" -->
<data android:scheme="kakao카카오네이티브키입력" android:host="channel"/>
</intent-filter>
</activity>
[iOS]
앱 실행 허용 목록을 설정 : Info 파일 클릭 후 Queried URL Schemes를 추가합니다.
item0에 안드로이드에서 추가한값과 동일한 kakao카카오네이티브키 값을 입력합니다.
item1에 카카오톡 로그인을 위한 kakaokompassauth를 추가합니다.
- kakaokompassauth : 카카오 로그인
- kakaolink : 카카오톡 공유
- kakaoplus : 카카오톡 채널
Info.plist
커스텀 URL 설정
URL Schemes 항목에 kakao카카오네이티브키 값을 등록합니다.
Runner - Runner(TARGETS) - URL Types
2-3. 카카오 초기화
프로젝트에서 환경 변수 관리를 위해 dotenv를 사용하고 있습니다. dotenv를 로드한 후 카카오 초기화를 진행합니다.
main.dart
// 구성 파일 로드
await dotenv.load(".env");
KakaoSdk.init(
nativeAppKey: dotenv.get('KAKAO_NATIVE_APP_KEY'),
javaScriptAppKey: dotenv.get('KAKAO_JAVASCRIPT_APP_KEY'),
);
2-4. ApiService 추가
모든 api는 공통 서비스인 ApiService를 통해 호출됩니다.
HTTP 요청시 유용하게 사용되는 Dio 패키지를 사용했으며 interceptor 옵션을 통해 헤더, 쿠키에 토큰을 추가했습니다.
onResponse는 액세스 토큰이 갱신되었을 때 전달받게 됩니다.
class ApiService {
final String authorizationKey = 'Authorization';
final Dio _dio;
ApiService()
: _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
)) {
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
Token token = await StorageService().getTokenInfo();
options.headers["Authorization"] = 'Bearer ${token.accessToken}';
options.headers['cookie'] = 'refresh-token=${token.refreshToken}';
return handler.next(options);
},
onResponse: (response, handler) {
String accessToken = '';
String refreshToken = '';
/// 액세스 토큰이 만료된 경우 응답 헤더에 액세스 토큰과 리프레쉬 토큰이 반환된다.
/// 리프레쉬 토큰을 통해 액세스 토큰을 갱신하며, 리프레쉬 토큰이 만료되거나 잘못된 경우는
/// 토큰을 반환하지 않는다.
if (response.headers['authorization'] != null) {
String reissuedToken = response.headers['authorization'].toString();
accessToken = reissuedToken.substring(8, reissuedToken.length - 1);
final cookies = response.headers['set-cookie'];
if (cookies != null) {
for (var cookie in cookies) {
var key = cookie.substring(0, cookie.indexOf('='));
if (key == 'refresh-token') {
refreshToken = cookie.substring(key.length + 1);
break;
}
}
}
if (accessToken != '' && refreshToken != '') {
logger.d(':: 토큰 갱신 :: $accessToken / $refreshToken');
StorageService().saveToken(
Token(accessToken: accessToken, refreshToken: refreshToken));
}
}
return handler.next(response);
},
onError: (DioException e, handler) {
return handler.next(e);
},
));
}
Future<Response> callApi(
String methodType,
String url,
Map<String, dynamic> paramMap,
) async {
try {
Response response = await _dio.request(
url,
queryParameters: paramMap,
data: paramMap,
options: Options(method: methodType),
);
return response;
} on DioException catch (e) {
// 토큰이 만료된 경우 사용자 정보를 초기화한다.
if (e.response!.statusCode == 401) {
await StorageService().clearUserInfo();
}
logger.e(e.response);
await Sentry.captureException(e, stackTrace: e.stackTrace);
rethrow;
} catch (e) {
logger.e(e.toString());
await Sentry.captureException(e, stackTrace: e.toString());
rethrow;
}
}
}
이후에 나오지만 사용자 정보는 토큰을 통해 서버 조회 후 Secure storage에 저장합니다.(닉네임, 도매시장, 법인 정보등)
2-5. LoginService 추가
카카오 로그인, 인증 기능을 포함하는 LoginService를 추가합니다.
class LoginService {
final loginApi = '${dotenv.env['NONGSIL_API_URL']}/oauth';
/// 인증 정보 조회
/// profile - 카카오 프로필 정보
/// token - 회원 계정은 액세스,리프레쉬 토큰이 발급되며, 비회원 계정은 빈 값을 반환한다.
Future<Map<String, dynamic>> getAuthInfo() async {
Map<String, dynamic> authMap = {};
KakaoProfile? kakaoProfile = await _getKakaoProfile();
if (kakaoProfile != null) {
Token token = await _kakaoAuthRequest('${kakaoProfile.id}');
authMap['profile'] = kakaoProfile;
authMap['token'] = token;
}
return authMap;
}
/// 카카오 로그인 후 프로필 정보 가져오기
Future<KakaoProfile?> _getKakaoProfile() async {
KakaoProfile? profile;
try {
OAuthToken token = await UserApi.instance.loginWithKakaoTalk();
if (token.accessToken != '') {
User kakaoUser = await UserApi.instance.me();
logger.d(':: 사용자 정보 요청 성공 :: '
'\n회원번호: ${kakaoUser.id}'
'\n닉네임: ${kakaoUser.kakaoAccount?.profile?.nickname}'
'\n프로필: ${kakaoUser.kakaoAccount?.profile?.profileImageUrl}');
profile = KakaoProfile(
id: kakaoUser.id,
nickname: kakaoUser.kakaoAccount?.profile?.nickname,
profileImageUrl: kakaoUser.kakaoAccount?.profile?.profileImageUrl);
}
} catch (e) {
logger.e(':: 카카오톡 로그인 실패 :: $e');
await Sentry.captureException(
e,
stackTrace: e.toString(),
);
// 사용자가 카카오톡 설치 후 디바이스 권한 요청 화면에서 로그인을 취소한 경우,
// 의도적인 로그인 취소로 보고 카카오계정으로 로그인 시도 없이 로그인 취소로 처리 (예: 뒤로 가기)
if (e is PlatformException && e.code == 'CANCELED') {
return profile;
}
}
return profile;
}
/// 카카오 로그인
Future<Token> _kakaoAuthRequest(String userId) async {
Token token = Token(accessToken: '', refreshToken: '');
try {
final response = await ApiService().callApi(
'POST',
'$loginApi/kakao',
{'userId': userId},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
dynamic result = resultData['result'];
logger.d(':: 카카오 로그인 JWT 토큰 조회 :: $result');
if (result != null) {
token = Token.fromMap(result);
}
} else {
throw Exception(":: 카카오 로그인 JWT 토큰 오류 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
await Sentry.captureException(
e,
stackTrace: e.toString(),
);
return token;
}
return token;
}
}
_getKakaoProfile()의 loginWithKakaoTalk() 함수가 호출되면 사용자에게 앱 관련 동의를 구하는 화면이 출력되며, 동의(인가코드 발급) 후 로그인이 완료됩니다.
로그인 후에는 UserApi의 me() 함수를 통해 사용자 정보를 불러올 수 있습니다. 이 때 사용자의 카카오 아이디 값을 받은 후 _kakaoAuthRequest()에서 로그인 API를 실행합니다.
2-6. 카카오 로그인 위젯 추가
로그인을 담당할 카카오 로그인 위젯을 생성합니다.
class KakaoLogin extends ConsumerStatefulWidget {
KakaoLogin({
Key? key,
});
@override
ConsumerState<KakaoLogin> createState() => _KakaoLogin();
}
class _KakaoLogin extends ConsumerState<KakaoLogin> {
late FToast fToast;
void _showToastMessage(String message) {
fToast.showToast(
child: MyPopup(popupText: message),
gravity: ToastGravity.BOTTOM,
toastDuration: const Duration(seconds: 2),
);
}
Future _loginKakao() async {
Users userInfo = Users(userId: '', userName: '');
bool isKakaoTalkSharingAvailable =
await ShareClient.instance.isKakaoTalkSharingAvailable();
if (isKakaoTalkSharingAvailable) {
Map<String, dynamic> authInfo = await LoginService().getAuthInfo();
// 카카오톡 로그인 오류 발생시
if (authInfo['profile'] == null) {
_showToastMessage("로그인에 실패했습니다. 잠시 후 다시 시도해 주세요.");
return userInfo;
}
KakaoProfile kakaoProfile = authInfo['profile'];
Token authToken = authInfo['token'];
if (authToken.accessToken == '' && authToken.refreshToken == '') {
ref.read(menuProvider.notifier).state = "/user/join";
Map<String, String> queryParams = {
'userId': '${kakaoProfile.id}',
'profileImageUrl': kakaoProfile.profileImageUrl ?? '',
};
Uri uri = Uri(
path: '/user/join/',
queryParameters: queryParams,
);
context.go(uri.toString());
} else {
await StorageService().saveToken(authToken);
// 발급된 토큰으로 회원 정보 조회
Users userInfo = await UserService().getUserInfo('${kakaoProfile.id}');
StorageService().saveUserInfo(userInfo);
ref.read(userProvider.notifier).updateCondition(userInfo);
}
} else {
_showToastMessage("카카오톡을 설치해 주세요.");
}
}
@override
void initState() {
fToast = FToast();
fToast.init(context);
super.initState();
}
@override
Widget build(BuildContext context) {
final screen = MediaQuery.of(context).size;
return GestureDetector(
onTap: () async {
await _loginKakao();
},
child: Container(
padding: EdgeInsets.only(
top: screen.height * p5,
bottom: screen.height * p5,
left: 10,
right: 10,
),
decoration: BoxDecoration(
color: const Color(0xffFAE100),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/icons/kakaotalk.png',
width: screen.width * p40,
height: screen.width * p40,
),
MyTextbox(
textField: '카카오로 시작하기',
fontSize: screen.width * f14,
fontColor: const Color(0xff371A1A),
fontWeight: FontWeight.w600,
),
],
),
),
);
}
}
카카오톡이 설치되어 있지 않다면 실행이 되지 않도록 했습니다. iskakaoTalkSharingAvailable() 함수를 통해 체크할 수 있고, 이 부분은 카카오계정으로 로그인으로 앱 브라우저를 통해 로그인할 수 있도록 구현할 수도 있습니다.
로그인 성공시 발급된 토큰으로 회원 정보를 조회하게 되는데, 이 부분은 회원가입 이후에 다시 살펴보겠습니다.
3. 회원가입 페이지 추가
3-1. UserJoin 화면 추가
비회원은 토큰값이 존재하지 않고, 회원가입 페이지로 이동하게 됩니다.
UserJoin 화면으로 이동되면 쿼리파라미터를 통해 아이디와 프로필 이미지를 전달받습니다.
class UserJoin extends ConsumerStatefulWidget {
final String userId;
final String profileImageUrl;
const UserJoin({
super.key,
required this.userId,
required this.profileImageUrl,
});
@override
ConsumerState<UserJoin> createState() => _UserJoin();
}
class _UserJoin extends ConsumerState<UserJoin> {
late FToast fToast;
final TextEditingController _nameController = TextEditingController();
String _marketCode = '', _companyCode = '';
bool _isLoading = false;
void _showToastMessage(String message) {
fToast.showToast(
child: MyPopup(popupText: message),
gravity: ToastGravity.BOTTOM,
toastDuration: const Duration(seconds: 2),
);
}
@override
void initState() {
fToast = FToast();
fToast.init(context);
super.initState();
}
@override
Widget build(BuildContext context) {
final screen = MediaQuery.of(context).size;
final marketList = ref.watch(marketListProvider(""));
final companyList = ref.watch(companyProvider(_marketCode));
return Scaffold(
backgroundColor: Colors.white,
appBar: UserHeader(
height: screen.height,
),
body: SingleChildScrollView(
child: SizedBox(
height: screen.height,
child: Container(
width: screen.width,
decoration: const BoxDecoration(
color: Colors.white,
),
child: Center(
child: Padding(
padding: EdgeInsets.only(
left: screen.width * p30,
right: screen.width * p30,
),
child: IgnorePointer(
ignoring: _isLoading ? true : false,
child: Column(
children: [
Container(
margin: EdgeInsets.only(
top: screen.height * p40,
bottom: screen.height * p20),
child: Image.asset(
'assets/icons/app_logo2.png',
width: screen.width * 0.25,
height: screen.width * 0.25,
),
),
Container(
margin: EdgeInsets.only(bottom: screen.height * p20),
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyTextbox(
textField: '닉네임',
fontSize: screen.width * f16,
),
Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: grayBoxColor,
width: 2.0,
),
),
),
child: TextField(
controller: _nameController,
decoration: InputDecoration(
border: InputBorder.none,
hintStyle: TextStyle(
fontSize: screen.width * f16,
fontWeight: FontWeight.w200,
color: grayFontColor,
),
hintText: '닉네임을 입력해 주세요.',
),
style: TextStyle(
color: basicFontColor,
fontSize: screen.width * f14,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
GestureDetector(
onTap: () async {
setState(() {
_isLoading = true;
});
Users userInfo = Users(
userId: widget.userId,
userName: _nameController.text,
marketCode: _marketCode,
companyCode: _companyCode,
profileImageUrl: widget.profileImageUrl);
// 회원가입
Token authToken =
await UserService().saveUser(userInfo);
if (authToken.accessToken == '' &&
authToken.refreshToken == '') {
setState(() {
_showToastMessage('가입에 실패했습니다.\n관리자에게 문의해 주세요.');
_isLoading = false;
});
} else {
await StorageService().saveToken(authToken);
// 회원가입 후 발급된 토큰으로 회원 정보 조회
Users userInfo =
await UserService().getUserInfo(widget.userId);
StorageService().saveUserInfo(userInfo);
ref
.read(userProvider.notifier)
.updateCondition(userInfo);
ref.read(menuProvider.notifier).state = "/board";
context.go("/board");
}
},
child: Container(
height: screen.height * p30,
margin: EdgeInsets.only(top: screen.height * p5),
decoration: BoxDecoration(
color: widgetMainColor,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_isLoading
? const SpinKitThreeBounce(
color: Colors.white, size: 25)
: MyTextbox(
textField: '가입하기',
fontSize: screen.width * f16,
fontColor: Colors.white,
fontWeight: FontWeight.w600,
),
],
),
),
)
],
),
),
),
),
),
),
),
);
}
}
모든 입력을 마치고 회원가입을 성공하면 토큰을 반환받습니다. 이 토큰을 통해 해당 사용자에 대한 정보를 조회하여 사용자 정보를 Secure storage에 저장합니다. 이 부분은 로그인 성공시와 동일합니다.
3-2. UserService 추가
사용자 정보 조회, 회원가입 기능을 포함하는 UserService를 추가합니다.
class UserService {
final userApi = '${dotenv.env['NONGSIL_API_URL']}/user';
/// 사용자 정보 조회
Future<Users> getUserInfo(String userId) async {
Users users = Users(userId: '', userName: '');
try {
final response = await ApiService().callApi(
'GET',
'$userApi/profile/$userId',
{},
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
dynamic result = resultData['result'];
users = Users.fromMap(result);
logger.d(':: 사용자 정보 조회 :: $result');
} else {
throw Exception(":: 사용자 정보 조회 오류 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
return users;
}
/// 회원가입
Future<Token> saveUser(Users user) async {
Token authToken = Token(accessToken: '', refreshToken: '');
try {
final response = await ApiService().callApi(
'POST',
'$userApi/join',
user.toMap(),
);
if (response.statusCode == 200) {
Map<String, dynamic> resultData = response.data;
if (resultData['message'] == 'success') {
dynamic data = resultData['result'];
authToken = Token.fromMap(data);
logger.d(':: 회원가입 성공 :: $data');
} else {
throw Exception(":: 회원가입 실패 :: $resultData");
}
}
} catch (e) {
logger.e(e.toString());
}
return authToken;
}
3-3. 사용자 정보 API 추가
사용자 정보를 조회할 수 있는 API가 없기 때문에 다시 서버로 돌아가 추가해보겠습니다.
UserController
/**
* 사용자 정보 조회
*
* @param id
* @return
*/
@GetMapping(value = "/profile/{id}")
public ResponseEntity<Message> getUserProfile(
@PathVariable("id") String id
) {
UserInfo userInfo = userService.getUserProfile(id);
return ResponseEntity.ok().body(new Message("success", userInfo));
}
UserRepository
@Query(
"SELECT u1.userId as userId"
+ ", u1.userName as userName"
+ ", m1.marketCode as marketCode"
+ ", m1.marketName as marketName"
+ ", c1.companyCode as companyCode"
+ ", c1.companyName as companyName \n"
+ ", u1.profileImageUrl as profileImageUrl \n"
+ " FROM Users u1\n"
+ " \t LEFT OUTER JOIN Markets m1\n"
+ " \t ON u1.marketCode = m1.marketCode \n"
+ " \t LEFT OUTER JOIN Companies c1\n"
+ " \t ON u1.companyCode = c1.companyCode \n"
+ " WHERE u1.userNo = :userNo ")
UserInfo getUserInfo(@Param("userNo") Long userNo);
UserInfo 인터페이스 추가
사용자 정보 조회시 도매,법인 테이블을 조인하며 네이티브 쿼리를 사용했기 때문에 Getter만 존재하는 인터페이스를 따로 정의합니다.(Dto 맵핑시 오류가 발생합니다)
public interface UserInfo {
String getUserId();
String getUserName();
String getMarketCode();
String getMarketName();
String getCompanyCode();
String getCompanyName();
String getProfileImageUrl();
}
UserService
액세스 토큰 복호화로 조회된 회원 번호를 통해 사용자 정보를 조회합니다.
인자값의 userId와 사용자 정보 아이디가 같지 않다면 잘못된 접근으로 예외를 발생시켰습니다.
public UserInfo getUserProfile(String userId) {
Long userNo = SecurityUtil.getUserNo();
UserInfo userInfo = userRepository.getUserInfo(userNo);
if (!userId.equals(userInfo.getUserId())) {
throw new CustomException("조회 권한이 없습니다.");
}
return userInfo;
}
위 과정을 통해 진행되는 화면을 확인해보겠습니다.
로그인을 통해 회원 체크 후 회원가입(좌) 또는 회원 인증(우)이 진행됩니다.
추가된 API
- [GET] /user/profile/{userId} : 사용자 정보 조회
참고문서
이제 로그인, 회원가입 기능이 마무리 되었습니다.
전체 코드를 공유할 수 없어 핵심이 되는 부분들을 최대한 정리하였습니다. 다음 글에서는 게시판 개발 과정을 알아보겠습니다.