SNS 로그인은 정말 떼려야 뗄 수 없는 부분 중 하나다. 회원가입이 세상 어려웠던 어릴적과 달리 이제 회원가입 정도야 누워서 떡먹기임. 그렇지만 성가신 부분임에는 틀림이 없다. 회원가입이 어렵지는 않지만 귀찮아서... 하려다 말고 취소하는 일도 나는 빈번하다. 그래서 "우리 서비스"에 대한 "접근성"을 높이기 위한 방편으로 SNS 로그인은 그냥 필수 기능이 되었다.
나는 회사에서 앱을 두개를 만들었고 SNS 로그인도 당연히 구현했다. 우리 앱에서는 이메일 로그인 외에 카카오 로그인, 네이버 로그인, 구글 로그인, 애플 로그인을 제공한다.
각 플랫폼에서 제공하는 문서를 참고하고, pub dev를 참고하고, 기술블로그들을 참고해서 개발을 했다. 한번 작업하면 다시 들여다볼 일이 없을 거 같아서 그냥 넘어갔었는데, 급하게 앱을 하나 더 만들게 되면서 그냥 하나 정리를 해놔야겠다는 생각을 했다.
아, 참고로 처음에는 웹뷰를 경유해서 로그인하도록 했으나 SDK를 사용하는 방식으로 수정했다. SDK 로그인을 성공한 후에 로그인 시 백에 토큰(혹은 id token)을 전달하도록 했다.
카카오 개발자 콘솔에서 설정합니다.
애플리케이션 생성
카카오 로그인 활성화
Redirect URI 추가
kakao{YOUR_NATIVE_APP_KEY}://oauth 형식으로 추가안드로이드 플랫폼 추가:
kr.co.{회사명})iOS 플랫폼 추가:
kr.co.{회사명})네이버 개발자 센터에서 설정합니다.
애플리케이션 생성
기본 정보 입력
로그인 오픈 API 서비스 환경 설정
안드로이드 추가:
kr.co.{회사명})iOS 추가:
naverlogin)콜백 URL 작성
naverlogin://oauth구글 로그인은 Google Cloud Platform과 Firebase Console 두 곳에서 설정해야 합니다.
프로젝트 생성
사용자 인증 정보 설정
OAuth 2.0 클라이언트 ID 생성
iOS 클라이언트 ID:
Android 클라이언트 ID:
프로젝트 생성
Android 앱 추가
google-services.json 다운로드 후 android/app/ 폴더에 추가iOS 앱 추가
GoogleService-Info.plist 다운로드 후 ios/Runner/ 폴더에 추가Apple Developer에서 설정합니다.
Certificate - 인증서 생성
+ 버튼 클릭Identifiers - 번들 ID 생성
+ 버튼 클릭Profiles - 프로비저닝 프로파일 생성
+ 버튼 클릭Keys - 키 생성
+ 버튼 클릭android/app/src/main/AndroidManifest.xml에 다음 내용을 추가합니다.
<!-- 카카오 로그인 -->
<activity
android:name="com.kakao.sdk.flutter.AuthCodeCustomTabsActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- "kakao{YOUR_NATIVE_APP_KEY}://oauth" 형식의 앱 실행 스킴 설정 -->
<data android:scheme="kakao{YOUR_NATIVE_APP_KEY}" android:host="oauth"/>
</intent-filter>
</activity>
<!-- 애플 로그인 -->
<activity
android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback"
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" />
<data android:scheme="signinwithapple" />
<data android:path="callback" />
</intent-filter>
</activity>
ios/Runner/Info.plist에 다음 내용을 추가합니다.
<!-- URL 스킴 설정 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>{앱이름}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{앱이름}</string>
<string>com.googleusercontent.apps.{구글제공}</string>
<string>naverlogin</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>kakao{YOUR_NATIVE_APP_KEY}</string>
</array>
</dict>
</array>
<!-- 네이버 로그인 설정 -->
<key>naverConsumerKey</key>
<string>{네이버키}</string>
<key>naverConsumerSecret</key>
<string>{네이버시크릿}</string>
<key>naverServiceAppName</key>
<string>{앱이름}</string>
<key>naverServiceAppUrlScheme</key>
<string>naverlogin</string>
<!-- 앱 쿼리 스킴 (다른 앱 호출을 위한 설정) -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>naversearchapp</string>
<string>naversearchthirdlogin</string>
<string>kakaokompassauth</string>
</array>
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart';
class KakaoLoginService {
void init(String nativeAppKey) {
KakaoSdk.init(nativeAppKey: nativeAppKey);
}
Future<String> login() async {
OAuthToken token;
try {
// 카카오톡 앱 설치 여부 확인
final isInstalled = await isKakaoTalkInstalled();
if (isInstalled) {
// 카카오톡 앱으로 로그인
token = await UserApi.instance.loginWithKakaoTalk();
} else {
// 웹으로 로그인
token = await UserApi.instance.loginWithKakaoAccount();
}
return token.accessToken;
} on KakaoException catch (e) {
debugPrint('카카오 로그인 실패: ${e.toString()}');
rethrow;
}
}
}
import 'package:flutter_naver_login/flutter_naver_login.dart';
class NaverLoginService {
Future<String> login() async {
try {
final result = await FlutterNaverLogin.logIn();
if (result.status == NaverLoginStatus.loggedIn) {
return result.accessToken.accessToken;
}
throw Exception('네이버 로그인 실패: ${result.status}');
} catch (e) {
debugPrint('네이버 로그인 실패: $e');
rethrow;
}
}
}
import 'package:google_sign_in/google_sign_in.dart';
class GoogleLoginService {
final GoogleSignIn _googleSignIn = GoogleSignIn(
scopes: ['email', 'profile'],
);
Future<String> signIn() async {
try {
final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();
if (googleUser == null) {
throw Exception('구글 로그인 취소');
}
final GoogleSignInAuthentication? googleAuth =
await googleUser.authentication;
if (googleAuth?.idToken == null) {
throw Exception('구글 ID 토큰을 가져올 수 없습니다');
}
return googleAuth!.idToken!;
} catch (e) {
debugPrint('구글 로그인 실패: $e');
rethrow;
}
}
}
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
class AppleLoginService {
Future<String> login() async {
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [
AppleIDAuthorizationScopes.email,
AppleIDAuthorizationScopes.fullName,
],
);
if (credential.identityToken == null) {
throw Exception('애플 ID 토큰을 가져올 수 없습니다');
}
return credential.identityToken!;
} on SignInWithAppleAuthorizationException catch (e) {
debugPrint('애플 로그인 실패: ${e.code} - ${e.message}');
rethrow;
} catch (e) {
debugPrint('애플 로그인 실패: $e');
rethrow;
}
}
}
카카오 같은 경우 runApp 하기 전에 초기화가 필요합니다.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 카카오 SDK 초기화
KakaoSdk.init(nativeAppKey: 'YOUR_NATIVE_APP_KEY');
runApp(MyApp());
}
Enum을 만들어서 버튼에 따라 주입받도록 했고, 그럼 해당 값에 따라 switch문을 활용해 필요한 작업을 하도록 만들었다. snsLoginSDK는 SDK에서 뱉는 토큰을 얻기 위함이고, 이 토큰은 우리 서버로 enum값과 함께 토큰을 전달한다.
enum LoginType {
kakao,
naver,
google,
apple,
email,
}
class Login extends _$Login {
LoginState build() => LoginState.init();
Future<dynamic> snsLogin(LoginType sns) async {
try {
// 1. SDK를 통해 토큰 획득
final token = await snsLoginSDK(sns: sns);
if (token == null) {
throw Exception('토큰을 가져올 수 없습니다');
}
// 2. 서버에 토큰 전달하여 로그인
return await snsLoginApi(sns: sns, token: token);
} catch (e, stackTrace) {
await ref.read(crashlyticsServiceProvider).recordError(
e,
stackTrace,
reason: 'SNS 로그인 실패',
information: ['로그인 타입: $sns'],
);
rethrow;
}
}
/// SDK를 통해 각 플랫폼의 토큰 획득
Future<String?> snsLoginSDK({
required LoginType sns,
}) async {
switch (sns) {
case LoginType.kakao:
return await ref.read(kakaoLoginServiceProvider).login();
case LoginType.naver:
return await ref.read(naverLoginServiceProvider).login();
case LoginType.google:
return await ref.read(googleLoginServiceProvider).signIn();
case LoginType.apple:
return await ref.read(appleLoginServiceProvider).login();
default:
return null;
}
}
/// 서버에 토큰 전달하여 로그인
Future<dynamic> snsLoginApi({
required LoginType sns,
required String token,
}) async {
try {
final url = AuthApi.snsLoginApi(sns);
// 구글은 id_token, 나머지는 access_token 사용
final data = sns == LoginType.google
? {"id_token": token}
: {"access_token": token};
final res = await _dio.post(
url,
data: data,
);
if (res.statusCode != 200) {
throw Exception('서버 로그인 실패: ${res.statusCode}');
}
return res.data['data']['tokens'];
} on DioException catch (e) {
debugPrint('SNS 로그인 API 실패: ${e.response?.data}');
throw Exception(e.response?.data['error']['message'] ?? '로그인 실패');
} catch (e, stackTrace) {
await ref.read(crashlyticsServiceProvider).recordError(
e,
stackTrace,
reason: 'SNS 로그인 API 실패',
information: [
'로그인 타입: $sns',
'토큰: ${token.substring(0, 20)}...',
],
);
rethrow;
}
}
}
// UI에서 사용
ElevatedButton(
onPressed: () async {
try {
final loginNotifier = ref.read(loginProvider.notifier);
final tokens = await loginNotifier.snsLogin(LoginType.kakao);
// 토큰 저장 및 로그인 처리
await _saveTokens(tokens);
context.go('/home');
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('로그인 실패: $e')),
);
}
},
child: Text('카카오 로그인'),
)
테스트 버전으로는 잘 되다가 릴리즈 버전으로 빌드했을 때 갑자기 로그인이 안될 수 있다. 특히 안드로이드에서! 암/복호화가 되고 하면서 문제가 생기는 것 같다.
이때는 android/app/proguard-rules.pro에서 해결을 위한 코드를 추가하면 된다.
# SNS 로그인 관련 클래스 유지
-keep class com.kakao.sdk.**.model.* { <fields>; }
-keep public class com.nhn.android.naverlogin.** { public protected *; }
-keep public class com.navercorp.nid.** { public *; }
-keep class com.google.googlesignin.** { <fields>; }
-keep class * extends com.google.gson.TypeAdapter
# Retrofit 관련 (사용하지 않더라도 에러 발생 가능)
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
카카오 로그인 설정 시 키해시가 필요한데, 앱 실행해서 콘솔에 찍어보는 게 가장 정확하고 빠르다.
// main.dart에서 키해시 출력
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isAndroid) {
// Android 키해시 출력
final keyHash = await _getKeyHash();
debugPrint('Key Hash: $keyHash');
}
runApp(MyApp());
}
Future<String> _getKeyHash() async {
// 키해시 가져오는 로직
// 실제 구현은 플랫폼별로 다름
}
애플 로그인을 사용하려면 Xcode에서 Capabilities를 설정해야 합니다.
+ Capability 클릭네이버 로그인 설정 시 콜백 URL을 정확히 입력해야 합니다. Info.plist에 설정한 URL Scheme과 일치해야 합니다.
예: naverlogin://oauth
구글 로그인 설정 시 Debug용과 Release용 SHA-1을 각각 등록해야 합니다.
Debug용 SHA-1 확인:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
Release용 SHA-1 확인:
keytool -list -v -keystore {your-release-keystore} -alias {your-alias}
애플 로그인은 실제 기기에서만 테스트할 수 있습니다. 시뮬레이터에서는 작동하지 않습니다.
증상: Debug 빌드에서는 잘 되는데 Release 빌드에서 카카오 로그인이 실패
원인: ProGuard가 카카오 SDK 클래스를 난독화하면서 문제 발생
해결: proguard-rules.pro에 카카오 SDK 관련 클래스 유지 규칙 추가
증상: 네이버 로그인 후 앱으로 돌아오지 않음
원인: Info.plist의 URL Scheme과 네이버 개발자 센터의 콜백 URL이 일치하지 않음
해결: 두 곳의 URL Scheme을 정확히 일치시킴
증상: 구글 로그인 시 DEVELOPER_ERROR 발생
원인: SHA-1 인증서 지문이 Google Cloud Console에 등록되지 않았거나 잘못 등록됨
해결: Debug용과 Release용 SHA-1을 각각 확인하고 등록
증상: 시뮬레이터에서 애플 로그인 버튼을 눌러도 반응이 없음
원인: 애플 로그인은 실제 기기에서만 작동함
해결: 실제 iOS 기기에서 테스트
SNS 로그인을 구현하면서 가장 많이 느낀 점은 각 플랫폼별 설정의 복잡성이었습니다. 특히 처음 설정할 때는 여러 곳에서 설정해야 해서 헷갈릴 수 있지만, 한 번 설정해두면 다시 들여다볼 일이 거의 없습니다.
특히:
다음에는 소셜 로그인 연동 해제 기능도 추가해볼 예정입니다.