Skip to content

소셜 로그인

모바일 앱에서 사용자 인증은 핵심 기능 중 하나입니다. 소셜 로그인은 사용자가 기존 소셜 미디어 계정을 사용하여 앱에 로그인할 수 있게 해주므로 편의성을 높이고 가입 과정을 간소화합니다. 이 문서에서는 Flutter 앱에서 주요 소셜 로그인(카카오, 네이버, 애플)을 구현하는 방법을 다룹니다.

각 소셜 로그인을 구현하기 전에 공통적으로 필요한 설정을 살펴보겠습니다.

android/app/src/main/AndroidManifest.xml 파일에 인터넷 권한을 추가합니다:

<manifest ...>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 기타 권한 -->
</manifest>

ios/Runner/Info.plist 파일에 URL 스킴 처리를 위한 설정을 추가합니다:

<key>CFBundleURLTypes</key>
<array>
<!-- 각 소셜 로그인별 URL 스킴 설정이 여기에 추가됩니다 -->
</array>

소셜 로그인을 통합적으로 관리하기 위한 인터페이스와 모델을 정의합니다:

// 소셜 로그인 결과 모델
@freezed
class SocialLoginResult with _$SocialLoginResult {
const factory SocialLoginResult.success({
required String accessToken,
required String provider,
String? email,
String? name,
String? profileImage,
}) = _SocialLoginResultSuccess;
const factory SocialLoginResult.error({
required String message,
required String provider,
}) = _SocialLoginResultError;
const factory SocialLoginResult.cancelled({
required String provider,
}) = _SocialLoginResultCancelled;
}
// 소셜 로그인 인터페이스
abstract class SocialLoginProvider {
Future<SocialLoginResult> login();
Future<void> logout();
}

카카오 로그인을 구현하려면 먼저 Kakao Developers에서 애플리케이션을 등록해야 합니다.

pubspec.yaml 파일에 카카오 로그인 패키지를 추가합니다:

dependencies:
kakao_flutter_sdk_user: ^1.9.7+3
  1. android/app/src/main/AndroidManifest.xml 파일에 카카오 관련 설정 추가:
<manifest ...>
<application ...>
<activity ...>
<!-- ... -->
<!-- 카카오 로그인 커스텀 URL 스킴 설정 -->
<intent-filter>
<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}" 형식의 스킴 설정 -->
<data android:scheme="kakao${YOUR_NATIVE_APP_KEY}" android:host="oauth"/>
</intent-filter>
</activity>
</application>
</manifest>
  1. android/app/build.gradle 파일의 defaultConfig 섹션에 매니페스트 플레이스홀더 추가:
defaultConfig {
// ...
manifestPlaceholders += [
'kakaoNativeAppKey': '${YOUR_NATIVE_APP_KEY}'
]
}
  1. ios/Runner/Info.plist 파일에 카카오 설정 추가:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<!-- "kakao${YOUR_NATIVE_APP_KEY}" 형식의 스킴 설정 -->
<string>kakao${YOUR_NATIVE_APP_KEY}</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>kakaokompassauth</string>
<string>kakaolink</string>
</array>
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart';
class KakaoLoginProvider implements SocialLoginProvider {
@override
Future<SocialLoginResult> login() async {
try {
// 카카오톡 설치 여부 확인
if (await isKakaoTalkInstalled()) {
try {
// 카카오톡으로 로그인
await UserApi.instance.loginWithKakaoTalk();
} catch (error) {
// 사용자가 카카오톡 로그인을 취소한 경우 카카오계정으로 로그인 시도
if (error is PlatformException && error.code == 'CANCELED') {
return const SocialLoginResult.cancelled(provider: 'kakao');
}
// 카카오톡에 연결된 카카오계정이 없는 경우, 카카오계정으로 로그인
await UserApi.instance.loginWithKakaoAccount();
}
} else {
// 카카오톡이 설치되어 있지 않은 경우, 카카오계정으로 로그인
await UserApi.instance.loginWithKakaoAccount();
}
// 사용자 정보 요청
User user = await UserApi.instance.me();
// 액세스 토큰 가져오기
OAuthToken token = await TokenManagerProvider.instance.manager.getToken();
return SocialLoginResult.success(
accessToken: token.accessToken,
provider: 'kakao',
email: user.kakaoAccount?.email,
name: user.kakaoAccount?.profile?.nickname,
profileImage: user.kakaoAccount?.profile?.profileImageUrl,
);
} catch (error) {
return SocialLoginResult.error(
message: error.toString(),
provider: 'kakao',
);
}
}
@override
Future<void> logout() async {
await UserApi.instance.logout();
}
}

앱 시작 시 카카오 SDK를 초기화합니다:

void main() {
// 카카오 SDK 초기화
KakaoSdk.init(
nativeAppKey: '${YOUR_NATIVE_APP_KEY}',
javaScriptAppKey: '${YOUR_JAVASCRIPT_APP_KEY}', // 웹 환경에서 필요한 경우
);
runApp(MyApp());
}

네이버 로그인을 구현하려면 먼저 네이버 개발자 센터에서 애플리케이션을 등록해야 합니다.

pubspec.yaml 파일에 네이버 로그인 패키지를 추가합니다:

dependencies:
naver_login_sdk: ^3.0.0

준비중입니다.

애플 로그인은 iOS 13 이상에서 지원되며, iOS 앱에서는 소셜 로그인 옵션으로 애플 로그인을 제공해야 합니다.

pubspec.yaml 파일에 애플 로그인 패키지를 추가합니다:

dependencies:
sign_in_with_apple: ^5.0.0
crypto: ^3.0.3
  1. Xcode에서 Runner 프로젝트를 열고 Signing & Capabilities 탭에서 + Capability 버튼을 클릭하여 Sign in with Apple 기능을 추가합니다.

  2. ios/Runner/Info.plist 파일에 관련 설정 추가:

<key>CFBundleURLTypes</key>
<array>
<!-- 기존 URL 스킴 설정 -->
</array>

Android에서 애플 로그인을 지원하려면 웹 기반 인증 흐름을 사용해야 합니다:

  1. android/app/src/main/AndroidManifest.xml 파일에 인텐트 필터 추가:
<manifest ...>
<application ...>
<activity ...>
<!-- ... -->
<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" android:path="callback" />
</intent-filter>
</activity>
</application>
</manifest>
  1. 웹 서비스에 Apple 개발자 계정에서 서비스 ID와 키를 설정해야 합니다.
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
class AppleLoginProvider implements SocialLoginProvider {
/// Generates a cryptographically secure random nonce, to be included in a
/// credential request.
String _generateNonce([int length = 32]) {
const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._';
final random = Random.secure();
return List.generate(length, (_) => charset[random.nextInt(charset.length)]).join();
}
/// Returns the sha256 hash of [input] in hex notation.
String _sha256ofString(String input) {
final bytes = utf8.encode(input);
final digest = sha256.convert(bytes);
return digest.toString();
}
@override
Future<SocialLoginResult> login() async {
try {
// 보안을 위한 nonce 생성
final rawNonce = _generateNonce();
final nonce = _sha256ofString(rawNonce);
// 애플 로그인 요청
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [
AppleIDAuthorizationScopes.email,
AppleIDAuthorizationScopes.fullName,
],
nonce: nonce,
webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'your.app.bundle.id.service',
redirectUri: Uri.parse(
'https://your-redirect-uri.example.com/callbacks/sign_in_with_apple',
),
),
);
// 사용자 이름 생성 (애플은 첫 로그인 후에만 이름 정보 제공)
final name = [
credential.givenName,
credential.familyName,
].where((name) => name != null && name.isNotEmpty).join(' ');
return SocialLoginResult.success(
accessToken: credential.identityToken ?? '',
provider: 'apple',
email: credential.email,
name: name.isNotEmpty ? name : null,
profileImage: null, // 애플은 프로필 이미지를 제공하지 않음
);
} catch (error) {
if (error is SignInWithAppleAuthorizationException) {
if (error.code == AuthorizationErrorCode.canceled) {
return const SocialLoginResult.cancelled(provider: 'apple');
}
}
return SocialLoginResult.error(
message: error.toString(),
provider: 'apple',
);
}
}
@override
Future<void> logout() async {
// 애플은 클라이언트 측에서 직접 로그아웃 기능을 제공하지 않음
// 필요한 경우 서버 측에서 토큰 무효화 처리
}
}

여러 소셜 로그인 방식을 통합적으로 관리하기 위한 서비스를 구현합니다:

class SocialLoginService {
final KakaoLoginProvider _kakaoLoginProvider = KakaoLoginProvider();
final NaverLoginProvider _naverLoginProvider = NaverLoginProvider();
final AppleLoginProvider _appleLoginProvider = AppleLoginProvider();
Future<SocialLoginResult> loginWithKakao() async {
return await _kakaoLoginProvider.login();
}
Future<SocialLoginResult> loginWithNaver() async {
return await _naverLoginProvider.login();
}
Future<SocialLoginResult> loginWithApple() async {
return await _appleLoginProvider.login();
}
Future<void> logoutFrom(String provider) async {
switch (provider.toLowerCase()) {
case 'kakao':
await _kakaoLoginProvider.logout();
break;
case 'naver':
await _naverLoginProvider.logout();
break;
case 'apple':
// 애플 로그아웃은 서버에서 처리
break;
}
}
// 모든 소셜 계정 로그아웃
Future<void> logoutAll() async {
await _kakaoLoginProvider.logout();
await _naverLoginProvider.logout();
// 애플 로그아웃은 서버에서 처리
}
}

Riverpod을 활용한 인증 상태 관리

Section titled “Riverpod을 활용한 인증 상태 관리”

Riverpod을 사용하여 소셜 로그인 상태를 관리하는 예제입니다:

// 사용자 상태 모델
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated({
required String accessToken,
required String provider,
String? email,
String? name,
String? profileImage,
}) = _Authenticated;
const factory AuthState.error(String message) = _Error;
}
// 인증 제공자
@riverpod
class Auth extends _$Auth {
final SocialLoginService _socialLoginService = SocialLoginService();
@override
AuthState build() {
return const AuthState.initial();
}
Future<void> loginWithKakao() async {
state = const AuthState.loading();
final result = await _socialLoginService.loginWithKakao();
state = result.when(
success: (accessToken, provider, email, name, profileImage) {
return AuthState.authenticated(
accessToken: accessToken,
provider: provider,
email: email,
name: name,
profileImage: profileImage,
);
},
error: (message, provider) {
return AuthState.error(message);
},
cancelled: (provider) {
return const AuthState.initial();
},
);
}
Future<void> loginWithNaver() async {
state = const AuthState.loading();
final result = await _socialLoginService.loginWithNaver();
state = result.when(
success: (accessToken, provider, email, name, profileImage) {
return AuthState.authenticated(
accessToken: accessToken,
provider: provider,
email: email,
name: name,
profileImage: profileImage,
);
},
error: (message, provider) {
return AuthState.error(message);
},
cancelled: (provider) {
return const AuthState.initial();
},
);
}
Future<void> loginWithApple() async {
state = const AuthState.loading();
final result = await _socialLoginService.loginWithApple();
state = result.when(
success: (accessToken, provider, email, name, profileImage) {
return AuthState.authenticated(
accessToken: accessToken,
provider: provider,
email: email,
name: name,
profileImage: profileImage,
);
},
error: (message, provider) {
return AuthState.error(message);
},
cancelled: (provider) {
return const AuthState.initial();
},
);
}
Future<void> logout() async {
if (state is _Authenticated) {
final provider = (state as _Authenticated).provider;
await _socialLoginService.logoutFrom(provider);
}
state = const AuthState.initial();
}
}

소셜 로그인 버튼을 포함한 로그인 화면 예제입니다:

class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
return Scaffold(
appBar: AppBar(title: const Text('로그인')),
body: Center(
child: authState.when(
initial: () => _buildLoginButtons(ref),
loading: () => const CircularProgressIndicator(),
authenticated: (token, provider, email, name, profileImage) {
return _buildUserInfo(ref, name, email, profileImage);
},
error: (message) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('오류: $message', style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
_buildLoginButtons(ref),
],
),
),
),
);
}
Widget _buildLoginButtons(WidgetRef ref) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 카카오 로그인 버튼
ElevatedButton(
onPressed: () => ref.read(authProvider.notifier).loginWithKakao(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFEE500),
foregroundColor: Colors.black87,
minimumSize: const Size(250, 50),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble, color: Colors.black87),
SizedBox(width: 8),
Text('카카오 로그인'),
],
),
),
const SizedBox(height: 16),
// 네이버 로그인 버튼
ElevatedButton(
onPressed: () => ref.read(authProvider.notifier).loginWithNaver(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF03C75A),
foregroundColor: Colors.white,
minimumSize: const Size(250, 50),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('N', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
SizedBox(width: 8),
Text('네이버 로그인'),
],
),
),
const SizedBox(height: 16),
// 애플 로그인 버튼
if (Platform.isIOS)
ElevatedButton(
onPressed: () => ref.read(authProvider.notifier).loginWithApple(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
minimumSize: const Size(250, 50),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.apple),
SizedBox(width: 8),
Text('Apple로 로그인'),
],
),
),
],
);
}
Widget _buildUserInfo(WidgetRef ref, String? name, String? email, String? profileImage) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (profileImage != null)
CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(profileImage),
),
const SizedBox(height: 16),
Text('이름: ${name ?? '정보 없음'}', style: const TextStyle(fontSize: 18)),
const SizedBox(height: 8),
Text('이메일: ${email ?? '정보 없음'}', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => ref.read(authProvider.notifier).logout(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('로그아웃'),
),
],
);
}
}

소셜 로그인 구현 시 고려해야 할 보안 측면:

  1. 토큰 관리

    • 액세스 토큰은 안전하게 저장해야 합니다(flutter_secure_storage 사용 권장).
    • 앱 내에서 토큰 유효성 검사 메커니즘 구현.
  2. 백엔드 인증

    • 소셜 로그인 토큰을 백엔드로 전송하여 검증 후 자체 JWT 토큰 발급.
    • 서버 측에서 OAuth 토큰 갱신 및 관리.
  3. 개인정보 처리

    • 사용자 정보는 필요한 최소한으로 요청.
    • 개인정보 처리방침에 소셜 로그인을 통해 수집되는 정보 명시.
  4. 사용자 식별

    • 동일 사용자가 다른 소셜 계정으로 로그인할 경우 처리 방안 마련.
    • 이메일 주소를 기준으로 계정 연동 기능 구현 고려.

Flutter 앱에서 카카오, 네이버, 애플 소셜 로그인을 구현하는 방법을 살펴보았습니다. 각 플랫폼마다 설정 방법이 다르므로 공식 문서를 참조하여 최신 정보를 확인하는 것이 중요합니다. 소셜 로그인은 사용자 경험을 개선하고 가입 과정을 간소화하는 데 큰 도움이 되지만, 보안과 개인정보 보호에도 각별한 주의가 필요합니다.

각 소셜 로그인 패키지는 지속적으로 업데이트되므로, 항상 최신 버전의 패키지와 공식 문서를 확인하십시오.