토이프로젝트

[팀프로젝트]모바일앱 게시판 만들기 - 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() 함수가 호출되면 사용자에게 앱 관련 동의를 구하는 화면이 출력되며, 동의(인가코드 발급) 후 로그인이 완료됩니다.

로그인 후에는 UserApime() 함수를 통해 사용자 정보를 불러올 수 있습니다.  이 때 사용자의 카카오 아이디 값을 받은 후 _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} : 사용자 정보 조회

 

참고문서
 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com


이제 로그인, 회원가입 기능이 마무리 되었습니다.

전체 코드를 공유할 수 없어 핵심이 되는 부분들을 최대한 정리하였습니다. 다음 글에서는 게시판 개발 과정을 알아보겠습니다.

반응형
Contents

포스팅 주소를 복사했습니다.

이 글이 도움이 되었다면 공감 부탁드립니다.