[Flutter] 서버 중심의 OAuth2.0 소셜 로그인 구현하기


이슈

SWM 과정에서 서비스를 개발 중 소셜 로그인 구현이 필요했다. 해당 프로젝트는 모바일 앱 서비스로 flutter로 작성된 프론트 엔드와 스프링 부트로 작성된 백엔드 서버를 두고 있었다. 본 프로젝트에서는 네이버와 카카오 소셜 로그인을 지원하기로 했고, 구글링을 통해 자료를 찾아본 결과 네이버의 경우에는 개인 개발자가 제작한 네아로 공식 SDK 기반의 소셜 로그인 라이브러리가 있었고, 카카오의 경우에는 아예 공식적으로 개발된 Flutter용 카카오 API 연동 라이브러리를 지원하고 있었다. 그런데 이렇게 SDK를 사용할 경우 네이버나 카카오와 같은 플랫폼에서 제공해주는 API KEY프론트 단에서 관리한다는 점과, 회원 정보를 불러오는 API 요청 또한 프론트에서 수행되므로 자체 백엔드 서버 중심의 회원 관리가 이루어지지 않았다.


배경 지식

세션 로그인 vs 토큰 로그인

로그인을 통한 인증에는 그 방식에 따라 크게 2가지가 있는데, 세션 로그인토큰 로그인이 그것이다. 둘의 작동 방식은 다음과 같다.

세션 로그인

session

사용자가 클라이언트(브라우저)에서 서버로 로그인 요청을 하면 서버에서 세션을 생성하고 세션 ID를 클라이언트로 보낸 후 클라이언트는 쿠키를 통해 세션을 유지하는 방식이다. 하지만 네이티브 앱에는 세션이 없기 때문에 이와 같은 방식의 로그인을 구현할 수 없다. 만약 하이브리드 앱이라면 방법이 아주 없는 것만은 아니다. 웹뷰를 사용해서 100% 웹 앱의 형태를 지녔다면 웹뷰의 세션을 통해 이러한 로그인 방식을 구현할 수 있다. 하지만 세션은 IP 단위로 유지되기 때문에 와이파이 사용 등 IP 변경이 잦은 모바일 기기에서는 세션이 자주 풀리고, 웹뷰 내부에는 세션이 있더라도 REST API 호출 시에는 그 세션 정보가 없다. 따라서 네이티브하이브리드 상관없이 모바일에서는 세션 로그인이 권장되지 않거나 불가능하다. 그렇다면 토큰 로그인 방식을 살펴보자.

토큰 로그인

token

토큰 로그인의 경우에는 방식이 조금 더 간단하다. 클라이언트에서 서버로 로그인 요청을 하면 회원 정보를 확인한 후 AccessToken을 발급한다. 추후 클라이언트는 서버와 통신 시에 이 토큰을 같이 담아 요청하기만 하면 된다. 하지만 이와 같은 방식에는 보안 이슈가 있는데, 바로 AccessToken을 탈취당하면 대처법이 없다는 것이다. 이를 해결한 방법으로는 AccessToken에 만료 기간을 두고 RefreshToken을 통해 이를 지속적으로 갱신해주는 것이 있다.

토큰 로그인 with Refresh Token

refreshtoken

이러한 방식을 사용하게 되면 AccessToken을 탈취 당하더라도, 기간이 만료됨에 따라 재사용이 불가능하고 서버에서 사용자 인증을 통해 발급받은 RefreshToken을 통해서만 갱신할 수 있기 때문에 안전하다. 물론 이 RefreshToken 또한 지속적으로 갱신해서 클라이언트에게 발급한다. 하지만 클라이언트와 서버 쪽 모두 개발 과정이 더 복잡해진다는 점이 있다.

이와 같은 토큰 로그인 방식은 Web이나 네이티브 앱 상관없이 모두 REST API를 통해 이용할 수 있는 방법이다. 따라서 모바일 기기에서의 로그인은 토큰 로그인 방식을 사용하며, 현재 Web에서도 토크 로그인이 세션 로그인에 비해 갖는 특징 때문에 토큰 로그인을 사용하는 추세라고 한다.

두 가지 로그인 방식에 대한 더 자세한 차이점은 여기에서 확인할 수 있다.

OAuth2.0

OAuth는 외부서비스의 인증 및 권한부여를 관리하는 범용적인 프로토콜이며, 현재는 2.0 버전이 사용되고 있다. 그리고 거의 대부분의 소셜 로그인은 모두 OAuth2.0 방식을 사용하고 있다. 그리고 이는 내부적으로 앞서 설명한 AccessTokenRefreshToken을 이용한 토큰 인증 방식에 기반한다.

OAuth 시퀀스 다이어그램

oauth

위 사진에서는 RefreshToken이 빠져있는데, AccessToken 발급 시에 같이 발급된다고 보면 된다. 보다시피 OAuth2.0 방식의 구성 요소에는 두 가지 서버가 있다.

  • 인증 서버(Authorization Server)
    • Client로부터 로그인 요청을 받는 주체
    • Client로 로그인 페이지 제공
    • Client로 Authorization Code(인증 코드) 발급
    • Client로 인증 코드를 통해 AccessToken 및 RefreshToken 발급
  • 리소스 서버(Resource Server)
    • Client로부터 API 요청을 받는 주체
    • Client의 AccessToken 검증을 통해 API 요청 승인
    • Client로 API 서비스 제공

여기서 인증 서버와 리소스 서버는 소셜 로그인에 이용하려는 플랫폼에 따라 달라지게 된다. 예를 들어 구글 로그인을 구현한다면 구글에서 제공하는 인증 서버와 리소스 서버에 요청하게 된다. 물론 개발자 입장에서 방식은 모두 동일하므로 플랫폼에 따라 개발 방식이 거의 유사하다.

OAuth에 대한 더 자세한 정보는 여기에서 참고할 수 있다.


Native SDK vs REST API

다시 글 맨 앞에서 언급했던 이슈로 돌아와보자. 만약 Native SDK 사용하면 어떻게 될까? 이 방식을 사용하면 위에서 제시했던 OAuth2.0 시퀀스 다이어그램에서 서비스 Client네이티브 앱 자체가 되버린다. 즉, 로그인 과정과 회원 관리에서 우리가 자체 개발한 백엔드 서버가 낄 자리가 없어지고, 회원을 인증하고 정보를 가져오는 주체가 백엔드가 아닌 프론트엔드가 되버린다. 그래서 이러한 경우에는 OAuth2.0 인증을 백엔드에서 처리하고 유저 정보를 DB에서 따로 관리해야 한다. 필자가 했던 프로젝트에서는 다음과 같은 방식으로 해결하였다.

서버 중심의 소셜 로그인

login

백엔드에서 프론트로부터 받을 로그인 요청 API를 따로 개발하고, 프론트에서 요청이 들어오면 백엔드에서 소셜 로그인을 진행한다. 그 다음 백엔드에서 리소스 서버로부터 유저의 정보를 받아오고, 이를 DB 에 저장 및 갱신한다. 이후에 이 유저 정보로 서버에서 자체적인 JWT 를 생성하고 (여기서는 AccessTokenRefreshToken) 이를 프론트에 발급한다.

이와 같은 방식이 Native SDK에 비해 갖는 차이점은 다음과 같다.

  • 프론트에서 백엔드로 로그인 API 요청만 보내면 백엔드 서버에서 OAuth2.0을 통한 소셜 로그인 및 회원 관리를 수행하고 백엔드에서 자체적으로 발급한 토큰을 프론트가 전달받는다.
  • Flutter 앱 자체에서 로그인을 하지 않고 모바일 웹에서 로그인한 후 결과를 Redirect URI를 통해 앱으로 리턴받는다.

한마디로, 서버 입장에서 클라이언트를 앱이 아닌 웹으로 판단하겠다는 것이다. 따라서 소셜 로그인을 구현하려는 플랫폼에서의 개발자 설정에서도 서비스 플랫폼을 AndroidiOS가 아닌 Mobile Web으로 설정해주어야 한다.


Redirect URI

그렇다면 이렇게 모바일 웹 브라우저에서 수행한 로그인 결과를 어떻게 으로 전달할 수 있을까? 이 때 바로 Redirect URI가 사용된다.

Redirect URI란 말 그대로 앱이 갖는 고유한 주소이며, 웹페이지에서 특정 앱으로 데이터를 redirect 하고자 할 때 사용된다. 따라서 모바일 앱 서비스 개발 중 REST API 방식을 통해 OAuth 소셜 로그인을 구현한다면 Redirect URI는 필수적인 존재이다. 모바일 웹에서 로그인을 진행하므로 로그인에 대한 결괏값도 모바일 웹을 통해 전달되기 때문이다. 따라서 서버로부터 최종적인 AccessTokenRefreshToken을 이 Redirect URI를 통해 전달받게 된다.

그렇다면 flutter에서 이를 어떻게 구현할까? 다행히 이것을 도와주는 간편한 라이브러리가 존재한다.

flutter web auth

본 라이브러리를 이용하면 손쉽게 백엔드 서버로부터 받은 로그인 페이지로 로그인을 수행하고, 이를 통해 발급받은 tokenredirect URI 통해 받아올 수 있다. Redirect URI는 고유한 값을 가지는 것이 안전하기 때문에 보통 앱의 패키지명으로 지정한다. 간단한 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import 'package:flutter_web_auth/flutter_web_auth.dart';

Future<void> signIn() async {
    
    // 고유한 redirect uri
    const APP_REDIRECT_URI = "com.example.yjyoon";
    
    // 백엔드에서 미리 작성된 API 호출
    final url = Uri.parse('/login/naver?redirect-uri=$APP_REDIRECT_URI');
    
    // 백엔드가 제공한 로그인 페이지에서 로그인 후 callback 데이터 반환
    final result = await FlutterWebAuth.authenticate(
        url: url.toString(), callbackUrlScheme: APP_REDIRECT_URI);
    
    // 백엔드에서 redirect한 callback 데이터 파싱
    final accessToken = Uri.parse(result).queryParameters['access-token'];
    final refreshToken = Uri.parse(result).queryParameters['refresh-token'];
    
    // . . .
    // FlutterSecureStorage 또는 SharedPreferences 를 통한
    // Token 저장 및 관리
    // . . .
    
}

결국 프론트 상에서는 위 코드만으로 소셜 로그인이 끝난다.

나머지는 앞서 제시한 서버 중심의 소셜 로그인 시퀀스 다이어그램처럼 백엔드에서 개발한 로그인 API에 달렸다. 따라서 SDK를 사용할 때와는 달리 프론트에서 소셜 플랫폼 API KEY를 관리하거나, 플랫폼 리소스 서버에 직접 API 요청을 보내는 일이 없기 때문에 프론트가 완전히 소셜 로그인과 분리된다.

이를 통해 프론트에서는 플랫폼에 상관없이 동일한 과정으로 소셜 로그인을 요청할 수 있고 백엔드에서는 프론트와 독립적으로 로그인 과정과 회원 정보를 관리할 수 있다.


참고 자료

0%