모바일 앱을 개발할 때 로그인 기능은 거의 필수로 구현해야할 기능 중 하나입니다.
로그인의 거부감을 없애고 사용자에게 편리함, 친숙함을 제공하기 위해 구글, 카카오, 네이버, 애플 등과 같은 OAuth 기반의 소셜 로그인은 가장 자주 활용되는 로그인 수단 중 하나입니다.
최근 CMP로 사이드 프로젝트를 진행하던 중, 이러한 소셜 로그인이 필요하게 되었습니다.
하지만 소셜 로그인들은 KMP 프로젝트를 위한 SDK를 제공하지 않고 Android, iOS와 같은 각 플랫폼의 고유한 SDK를 제공하고 있습니다.
따라서 CMP 프로젝트에서 소셜 로그인을 구현하기 위해서는 Android, iOS 네이티브 영역의 SDK에서 그 기능을 구현하고 공통 코드 영역에서 이러한 기능을 사용할 수 있어야 합니다.
KMP 프로젝트는 Cocoapods를 사용할 수 있기 때문에 SDK를 사용할 수는 있을 것입니다.
하지만 (제가 알기로) iOS 에서는 Cocoapods의 사용을 지양하고 SPM(Swift Package Manager)를 사용하는 추세이다 보니 SPM으로 구성하는 방법이 궁금해져 찾아본 결과, 아직은 공식적으로 제공하고 있지 않은 상태였습니다.
https://youtrack.jetbrains.com/issue/KT-53877/Support-Swift-Package-Manager-in-Kotlin-Multiplatform
Support Swift Package Manager in Kotlin Multiplatform : KT-53877
There is a gradle plugin for Cocoapods package manager integration: https://kotlinlang.org/docs/native-cocoapods.html The plugin allows: - use 3d-party pods as dependencies in Kotlin projects - integrate Kotlin framework to Xcode project via Cocoapods - pu
youtrack.jetbrains.com
위 글의 마지막 댓글을 보시면 SPM을 사용할 수 있도록 플러그인을 개발해주신 아주 고마운 분이 계십니다.
https://frankois944.github.io/spm4Kmp/
SPM For KMP documentation
An alternative to the dying CocoaPods with custom Kotlin/Swift bridge for KMP.
frankois944.github.io
오늘은 이 플러그인을 활용해서 CMP 프로젝트에서 네이버, 카카오 로그인을 구현하는 방법을 알아보겠습니다.
(참고) 이 포스팅에서 사용된 예시는 다음 Github에서 모든 코드를 확인할 수 있습니다.
https://github.com/duitddu/template-cmp-circuit
GitHub - duitddu/template-cmp-circuit: Kotlin Multiplatform Mobile(Android, iOS) + Compose + Circuit
Kotlin Multiplatform Mobile(Android, iOS) + Compose + Circuit - duitddu/template-cmp-circuit
github.com
Swift 코드를 Kotlin 코드에서 사용하려면?
네이버, 카카오 로그인 SDK는 각 네이티브 영역에서 제공되고 있습니다. 따라서 Android의 경우 Kotlin 코드로 네이티브 영역에서 기능을 구현하고 공통 영역에서 사용해야 하며, iOS는 Swift 코드로 네이티브 영역에서 기능을 구현하고 공통 영역에서 사용해야 합니다.
Android는 Kotlin 코드로 작성되어 있기 때문에 문제가 없지만, iOS는 Swift 코드를 Kotlin 코드로 사용해야 하기 때문에 별도의 작업이 필요합니다.
https://kotlinlang.org/docs/native-objc-interop.html
Interoperability with Swift/Objective-C | Kotlin
kotlinlang.org
Swift 코드를 Kotlin 코드에서 사용하기 위해 위와 같은 공식문서에서 가이드 하고 있습니다.
내용을 요약하자면 다음과 같습니다.
첫번째, Swift 코드는 KMP에서 사용할 수 없고, Objective-C 코드만 사용할 수 있습니다.
두번째, Swift 코드를 사용하고 싶다면 @objc, @objcMembers 를 사용할 수 있습니다.
세번째, Objective-C 코드를 Kotlin에서 사용하려면 C Interop를 통해 사용할 수 있습니다.
그런데 문제는 Objective-C를 Kotlin에서 사용하기 위해 별도로 생성해야 하는 코드나 설정법이 여간 귀찮다는거죠.
무튼 이 문제가 SPM을 아직 공식적으로 지원하지 않기 때문에 발생합니다.
이를 해결해주는 플러그인을 개발해주신 분께 감사하게 생각하고 공식적으로 지원되길 바래봅시다.
SPM 사용을 위한 환경 설정
먼저 네이버, 카카오 로그인 SDK 공식 문서를 따라서 Android, iOS 각각에 의존성 설정을 해줍시다.
Android 설정
kotlin {
...
androidMain.dependencies {
...
implementation(libs.kakao.user.v2)
implementation(libs.naver.login)
}
...
}
Android는 toml 파일에 의존성을 선언하고 androidMain 영역에 의존성을 추가하면 됩니다.
iOS 설정
iOS는 Project > Package Dependencies에서 네이버, 카카오 로그인 SDK를 추가해줍시다.
iOS도 어려울 것 없고 마우스만 딸깍 하면 됩니다. 화면을 참고합시다.
플러그인 설정
앞에 말씀드렸던 플러그인을 추가해줍니다. 이것도 어렵지 않습니다.
// libs.versions.toml
[versions]
...
kmp-spm = "0.3.3"
[libraries]
...
[plugins]
...
kmp-spm = { id = "io.github.frankois944.spmForKmp", version.ref = "kmp-spm" }
// build.gradle.kts
plugins {
...
alias(libs.plugins.kmp.spm)
}
플러그인 환경 설정
SPM을 위해 추가한 플러그인은 빌드 타임에 SPM을 위한 코드들을 생성해줍니다.
코드 생성을 위해 몇 가지 환경 설정이 필요합니다. 어려운 내용은 없습니다.
// gradle.properties
...
kotlin.mpp.enableCInteropCommonization=true
// build.gradle.kts
kotlin {
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
...
iosTarget.compilations {
val main by getting {
cinterops.create("nativeIosShared")
}
}
}
}
swiftPackageConfig {
create("nativeIosShared") {
// 이 경로는 원하는 대로 변경해도 됩니다.
customPackageSourcePath = "../iosApp"
// 이 버전은 원하는 대로 변경해도 됩니다.
minIos = "15.0"
dependency(
SwiftDependency.Package.Remote.Version(
url = URI("https://github.com/kakao/kakao-ios-sdk"),
version = "2.23.0",
products = {
add("KakaoSDK")
}
),
SwiftDependency.Package.Remote.Version(
url = URI("https://github.com/naver/naveridlogin-sdk-ios-swift"),
version = "5.0.0",
products = {
add("NidThirdPartyLogin")
}
),
)
}
}
iOS 타겟에 C Interop를 위한 설정이 추가되고, SPM의 브릿지를 위한 패키지 경로, iOS의 최소 버전, 의존성 등을 선언해야 합니다.
여기서 customPackageSourcePath를 별도로 지정했는데, 지정하지 않으면 cmoposeApp/src 하위가 디폴트 경로가 됩니다.
저는 XCode 프로젝트에서 Swift 코드를 작성하기 편하도록 iosApp 하위로 지정하였습니다.
이렇게 지정된 경로 하위에 "nativeIosShared" 라는 폴더에서 Swift 브릿지 코드를 작성하게 됩니다.
Swift 브릿지 코드 작성
SPM 코드를 Kotlin에서 사용할 수 있도록 브릿지 코드가 필요합니다. 네이버, 카카오 로그인을 위한 Swift 브릿지 코드를 작성해봅시다.
예시는 아래와 같습니다.
// iosApp/nativeIosShared
// 카카오 로그인
@objcMembers public class KakaoLoginBridge: NSObject {
public func request(
success: @escaping (String) -> Void,
failure: @escaping () -> Void,
cancel: @escaping () -> Void
) {
if UserApi.isKakaoTalkLoginAvailable() {
UserApi.shared.loginWithKakaoTalk { [weak self] (oauthToken, error) in
self?.handle(oauthToken: oauthToken, error: error, success: success, failure: failure, cancel: cancel)
}
} else {
UserApi.shared.loginWithKakaoAccount { [weak self] (oauthToken, error) in
self?.handle(oauthToken: oauthToken, error: error, success: success, failure: failure, cancel: cancel)
}
}
}
...
}
@objcMembers public class NaverLoginBridge: NSObject {
public func request(
success: @escaping (String) -> Void,
failure: @escaping () -> Void,
cancel: @escaping () -> Void
) {
NidOAuth.shared.requestLogin { ret in
...
}
}
}
내용은 일부 생략했습니다. 중요한 것은 @objc 또는 @objcMemebers와 NSObject를 사용해서 Swift 코드를 Objective-C 코드에서 사용할 수 있게 하였습니다.
내부 구현은 네이버, 카카오 SDK를 통해 로그인을 하고 결과를 콜백처리로 되어 있습니다.
이제 Gradle Sync 또는 빌드를 하면 플러그인에서 이 브릿지 코드를 Kotlin에서 사용할 수 있도록 코드를 생성해줍니다.
소셜 로그인 코드 작성
이제 각 네이티브 영역에서 네이버, 카카오 로그인을 구현해야 합니다.
이를 위해 공통 영역에서 인터페이스를 만들고 각 네이티브 영역에서 인터페이스를 구현해 구현체를 주입하는 방식으로 구현됩니다.
공통 영역 코드 작성
네이티브 영역의 코드를 공통 영역에서 사용해야 하니 공통영역에서 인터페이스를 먼저 선언합니다.
// commonMain
sealed interface SocialAuthResult<out T> {
data class Success<T>(val data: T) : SocialAuthResult<T>
data object UserCancelled : SocialAuthResult<Nothing>
data object Error : SocialAuthResult<Nothing>
}
interface SocialAuthProvider<T> {
@Composable
fun get(): SocialAuthenticator<T>
}
interface SocialAuthenticator<T> {
suspend fun authenticate(): SocialAuthResult<T>
}
소셜 로그인을 위해 제공되는 모듈은 위 인터페이스를 따라야 합니다.
일부 소셜 로그인은 안드로이드에서 Activity Context를 요구하기 때문에 Composable 함수 내의 Context가 필요합니다. 따라서 소셜 로그인 기능을 제공하는 SocialAuthProvider 인터페이스와 실제 인증을 실행하는 SocialAuthenticator 인터페이스를 만들고, SocialAuthProvider의 함수를 Composable 함수로 만들어 Activity Context에 접근할 수 있도록 합시다.
이 때 소셜 로그인은 각 플랫폼마다 반환하는 토큰 등이 달라지므로 Generic을 활용합니다.
// commonMain
data class NaverUser(
val accessToken: String
)
interface NaverAuthProvider : SocialAuthProvider<NaverUser>
data class KakaoUser(
val accessToken: String
)
interface KakaoAuthProvider : SocialAuthProvider<KakaoUser>
이 인터페이스에 따라 네이버, 카카오를 위한 응답 모델과 각 네이티브 영역에서 구현해야할 인터페이스를 선언합니다.
인터페이스 구현체가 네이티브 영역에서의 구현체가 주입될 것입니다.
안드로이드 네이티브 영역 코드 작성
// androidMain
internal class KakaoAuthProviderImpl(
private val appKey: String
) : KakaoAuthProvider {
@Composable
override fun get(): SocialAuthenticator<KakaoUser> {
val context = LocalContext.current
return KakaoAuthenticator(context, appKey)
}
}
private class KakaoAuthenticator(
private val context: Context,
appKey: String
) : SocialAuthenticator<KakaoUser> {
init {
KakaoSdk.init(context, appKey)
}
override suspend fun authenticate(): SocialAuthResult<KakaoUser> = suspendCancellableCoroutine { cont ->
if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
UserApiClient.instance.loginWithKakaoTalk(context) { token, error ->
cont.handle(token, error)
}
} else {
UserApiClient.instance.loginWithKakaoAccount(context) { token, error ->
cont.handle(token, error)
}
}
}
}
internal class NaverAuthProviderImpl(
private val oauthClientId: String,
private val oauthClientSecret: String,
private val oauthClientName: String
) : NaverAuthProvider {
@Composable
override fun get(): SocialAuthenticator<NaverUser> {
val context = LocalContext.current
return NaverAuthenticator(context, oauthClientId, oauthClientSecret, oauthClientName)
}
}
private class NaverAuthenticator(
private val context: Context,
oauthClientId: String,
oauthClientSecret: String,
oauthClientName: String
): SocialAuthenticator<NaverUser> {
init {
NaverIdLoginSDK.initialize(context, oauthClientId, oauthClientSecret, oauthClientName)
}
override suspend fun authenticate(): SocialAuthResult<NaverUser> = suspendCancellableCoroutine { cont ->
val callback = object : OAuthLoginCallback {
...
}
NaverIdLoginSDK.authenticate(context, callback)
}
}
소셜 로그인 코드는 콜백인 경우가 많습니다. suspendCacellableCoroutine을 활용해서 코루틴을 활용할 수 있도록 만들어줍니다.
각 소셜 로그인의 SDK의 사용법에 따라 구현하면 됩니다.
iOS 네이티브 영역 코드 작성
// iosMain
@OptIn(ExperimentalForeignApi::class)
internal class KakaoAuthProviderImpl(
private val bridge: KakaoLoginBridge
) : KakaoAuthProvider {
@Composable
override fun get(): SocialAuthenticator<KakaoUser> {
return KakaoAuthenticatorImpl(bridge)
}
}
@OptIn(ExperimentalForeignApi::class)
private class KakaoAuthenticatorImpl(
private val bridge: KakaoLoginBridge
) : SocialAuthenticator<KakaoUser> {
override suspend fun authenticate(): SocialAuthResult<KakaoUser> = suspendCoroutine { cont ->
bridge.requestWithSuccess(
success = {
...
},
failure = {
...
},
cancel = {
...
}
)
}
}
@OptIn(ExperimentalForeignApi::class)
internal class NaverAuthProviderImpl(
private val bridge: NaverLoginBridge
) : NaverAuthProvider {
@Composable
override fun get(): SocialAuthenticator<NaverUser> {
return NaverAuthenticatorImpl(bridge)
}
}
@OptIn(ExperimentalForeignApi::class)
private class NaverAuthenticatorImpl(
private val bridge: NaverLoginBridge
) : SocialAuthenticator<NaverUser> {
override suspend fun authenticate(): SocialAuthResult<NaverUser> = suspendCoroutine { cont ->
bridge.requestWithSuccess(
success = {
...
},
failure = {
...
},
cancel = {
...
},
)
}
}
앞서 만든 브릿지 코드는 각각 Swift로 작성된 클래스의 이름을 따라 NaverLoginBridge, KakaoLoginBridge 라는 Kotlin 클래스가 생성되어 있으며, 이를 사용할 수 있습니다.
마찬가지로 Swift 코드를 콜백 형태로 구현했으므로 suspendCoroutine을 사용해서 코루틴으로 바꿔줍시다.
(Android처럼 suspendCacellableCoroutine을 사용할 수 있으면 좋은데, 메서드를 찾을 수 없었습니다. 아마 플랫폼 차이가 아닐까 합니다.)
이제 이 구현체를 Koin 등을 활용해 구현체를 주입하고 공통 영역에서 사용하면 됩니다.
공통 영역 코드 작성
공통코드에서 사용 예시는 다음과 같습니다. 이 예시에서는 Koin을 사용해 주입합니다.
// commonMain
enum class SocialAuthType {
NAVER,
KAKAO
}
@Composable
fun Login(
...
) {
val scope = rememberCoroutineScope()
val kakaoAuthenticator = koinInject<KakaoAuthProvider>().get()
val naverAuthenticator = koinInject<NaverAuthProvider>().get()
val socialAuthLaunch: (SocialAuthType) -> Unit = remember {
{ type ->
scope.launch {
when (type) {
SocialAuthType.NAVER -> {
val ret = naverAuthenticator.authenticate()
// handle result
}
SocialAuthType.KAKAO -> {
val ret = kakaoAuthenticator.authenticate()
// handle result
}
}
}
}
}
SocialAuthButtons { type ->
socialAuthLaunch.invoke(type)
}
}
구현 예시는 다를 수 있습니다만, Android에서 Activity Context의 주입을 위해 위와 같이 Composable 함수에서 각 소셜에 맞는 Provider를 통해 실제 소셜 로그인을 수행하는 클래스를 획득해야 하는 제약이 있습니다. (다른 구현이 있다면 알려주세요.)
이번 예시는 소셜 로그인이었지만, 이것을 활용하면 그 어떤 네이티브 영역의 코드든 충분히 CMP에서 활용할 수 있을 것 같습니다.
플러그인 개발자분에게 감사한 마음과 SPM이 공식적으로 지원되길 바라면서 포스팅을 마치겠습니다.
'개발 > Android' 카테고리의 다른 글
[Android] Compose 성능 개선 - Compose Compiler Metrics Report와 Restartable, Skippable (0) | 2025.02.17 |
---|---|
[Android] 의존성 주입 - Starting with Koin Annotations (feat. Compose, KMP, Circuit) (2) | 2025.02.14 |
[Android] 안드로이드 메모리 누수에 관하여 (0) | 2025.02.04 |
[Android] Jetpack Compose - Layout으로 커스텀 레이아웃 만들기 (0) | 2025.02.02 |
Kotlin Coroutines, 에러 처리와 SupervisorJob (0) | 2025.01.29 |