Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2c03a14
feat: 로그인 화면 UI 구현
stopstone Aug 28, 2025
bd6ac87
feat: DataStore 라이브러리 추가
stopstone Aug 28, 2025
6114ae3
feat: 카카오 SDK 연동
stopstone Aug 28, 2025
22e4d3a
feat: 로그인 화면 수정 및 네비게이션 적용
stopstone Aug 28, 2025
0d70567
feat: 카카오 소셜 로그인 서버 API 요청
stopstone Aug 28, 2025
b92b913
feat: 카카오 로그인 서버 API 연동
stopstone Aug 28, 2025
e410519
feat: 카카오 로그인 datasource 계층 분리 및 확장 가능한 코드로 변경
stopstone Aug 29, 2025
8836162
refactor: 소셜 로그인 데이터 소스 파일 경로 변경
stopstone Aug 29, 2025
78237ca
Refactor: DataStoreModule 분리
stopstone Aug 29, 2025
fb89c57
feat: 토큰 관리 및 인증 로직 구현
stopstone Aug 29, 2025
0c08868
feat: 토큰 갱신 및 만료 처리 로직 개선
stopstone Sep 1, 2025
737b9c3
refactor: 토큰 갱신 로직 삭제
stopstone Sep 1, 2025
9f03d40
refactor: 로그인 백그라운드 색상 변경
stopstone Sep 1, 2025
0f844a8
chore: 카카오 네이티브키 CI 설정
stopstone Sep 1, 2025
877388d
feat: 토큰 갱신 로직 추가
stopstone Sep 1, 2025
131cb47
feat: TokenManager 추가 및 토큰 관리 로직 개선
stopstone Sep 1, 2025
0800e55
refactor: 카카오 로그인 로직 수정
stopstone Sep 1, 2025
cc27edc
refactor: 토큰 유효성 검사 로직 수정
stopstone Sep 1, 2025
c3edc18
refactor: logout 로직 수정
stopstone Sep 1, 2025
75855d3
refactor: 토큰 만료 시간 계산 로직 변경
stopstone Sep 1, 2025
7bcd976
refactor: 소셜 로그인 결과 처리 방식 변경
stopstone Sep 2, 2025
2d3062d
Refactor: TokenInterceptor에서 AuthEndpoint 분리
stopstone Sep 2, 2025
9f3475a
refactor: KakaoDataSource 바인딩 DI 모듈 뷰ㅜㄴ리
stopstone Sep 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/android-pull-request-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
run: |
echo "TEMP_TOKEN=\"TEMP_TOKEN\"" >> local.properties

- name: Access Kakao KAKAO_NATIVE_APP_KEY
env:
KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
run: |
echo "KAKAO_NATIVE_APP_KEY=\"$KAKAO_NATIVE_APP_KEY\"" >> local.properties

- name: Grant execute permission for gradlew
run: chmod +x gradlew

Expand Down
10 changes: 10 additions & 0 deletions Near/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ android {
)
buildConfigField("String", "NEAR_URL", getProperty("NEAR_PROD_URL"))
buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요
buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY"))
manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "")
}
debug {
buildConfigField("String", "NEAR_URL", getProperty("NEAR_DEV_URL"))
buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요
buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY"))
manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "")
}
}
compileOptions {
Expand Down Expand Up @@ -88,6 +92,12 @@ dependencies {
implementation(libs.navigation.compose)
// Serialization
implementation(libs.kotlin.serialization.json)
// DataStore
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore.core)

// Kakao Module
implementation(libs.v2.all)
}

fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey)
19 changes: 19 additions & 0 deletions Near/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@
android:supportsRtl="true"
android:theme="@style/Theme.Near"
tools:targetApi="31">

<!-- Kakao SDK -->
<meta-data
android:name="com.kakao.sdk.AppKey"
android:value="${kakaoAppKey}" />

<!-- 카카오톡 로그인 콜백을 위한 Activity -->
<activity
android:name="com.kakao.sdk.auth.AuthCodeHandlerActivity"
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:host="oauth"
android:scheme="kakao${kakaoAppKey}" />
</intent-filter>
</activity>

<activity
android:name=".presentation.feature.main.MainActivity"
android:exported="true"
Expand Down
11 changes: 10 additions & 1 deletion Near/app/src/main/java/com/alarmy/near/NearApplication.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
package com.alarmy.near

import android.app.Application
import com.kakao.sdk.common.KakaoSdk
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class NearApplication : Application()
class NearApplication : Application() {

override fun onCreate() {
super.onCreate()

// 카카오 SDK 초기화
KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.alarmy.near.data.datasource

import android.content.Context
import com.alarmy.near.model.ProviderType
import com.kakao.sdk.auth.model.OAuthToken
import com.kakao.sdk.common.model.ClientError
import com.kakao.sdk.common.model.ClientErrorCause
import com.kakao.sdk.user.UserApiClient
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume

/**
* 카카오 로그인 데이터 소스
* 단일 책임: 카카오 SDK만 처리
*/
@Singleton
class KakaoDataSource
@Inject
constructor(
@ApplicationContext private val context: Context,
) : SocialLoginDataSource {
override val supportedType: ProviderType = ProviderType.KAKAO

override suspend fun login(): Result<String> =
try {
val token =
if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
loginWithKakaoTalk()
} else {
loginWithKakaoAccount()
}

if (token.isNotEmpty()) {
Result.success(token)
} else {
Result.failure(Exception("사용자가 로그인을 취소했습니다"))
}
} catch (exception: Exception) {
Result.failure(exception)
}

private suspend fun loginWithKakaoTalk(): String =
suspendCancellableCoroutine { continuation ->
UserApiClient.instance.loginWithKakaoTalk(context) { token, error ->
when {
error != null -> {
if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
continuation.resume("")
} else {
UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError ->
handleLoginResult(retryToken, retryError, continuation)
}
}
}
token != null -> continuation.resume(token.accessToken)
else -> continuation.resume("")
}
}
}

private suspend fun loginWithKakaoAccount(): String =
suspendCancellableCoroutine { continuation ->
UserApiClient.instance.loginWithKakaoAccount(context) { token, error ->
handleLoginResult(token, error, continuation)
}
}

private fun handleLoginResult(
token: OAuthToken?,
error: Throwable?,
continuation: CancellableContinuation<String>,
) {
when {
error != null -> {
if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
continuation.resume("")
} else {
continuation.resumeWith(Result.failure(error))
}
}
token != null -> continuation.resume(token.accessToken)
else -> continuation.resume("")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.alarmy.near.data.datasource

import com.alarmy.near.model.ProviderType

/**
* 소셜 로그인 데이터 소스 인터페이스
* Strategy 패턴으로 각 소셜 플랫폼별로 구현
*/
interface SocialLoginDataSource {
/**
* 지원하는 소셜 로그인 타입
*/
val supportedType: ProviderType

/**
* 소셜 로그인 수행
* Context는 생성자에서 주입받아 사용
*/
suspend fun login(): Result<String>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.alarmy.near.data.datasource


import com.alarmy.near.model.ProviderType
import javax.inject.Inject
import javax.inject.Singleton

/**
* 소셜 로그인 프로세서
* Strategy 패턴으로 동적으로 로그인 방식 선택
*/
@Singleton
class SocialLoginProcessor
@Inject
constructor(
private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set과 JvmSuppressWildcards를 적용주신 이유가 궁금합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Set을 적용한 이유
현재는 카카오 로그인 뿐이지만 추후 다른 소셜 로그인을 확장하기 쉽게 socialLoginDataSources를 구성했습니다!
이때 List보다는 중복을 방지할 수 있다는 차원에서도 Set이 명확할 것 같아 Set으로 반환하였습니다

2. JvmSuppressWildcards를 적용한 이유
// Kotlin에서 이렇게 쓰면
val dataSources: Set

// JVM 바이트코드에서는 이렇게 변환돼요
Set<? extends SocialLoginDataSource>

여기서 ?는 wildcard로, "정확한 타입을 모르지만 SocialLoginDataSource의 하위 타입들"이라는 의미입니다.
이는 Kotlin의 타입 안전성을 위한 공변성 때문인데,
Dagger와 Hilt는 Java로 작성된 라이브러리라서 이런 wildcard 타입을 제대로 처리하지 못합니다.

바인딩할 때 "어? 이게 정확히 어떤 타입인지 모르겠네?" 하면서 에러가 발생할 가능성이 있어,
@JvmSuppressWildcards 어노테이션을 사용해 wildcard 생성을 억제하여 Set로 그대로 변환되도록 했습니다.
이렇게 하면 Dagger가 정확한 타입을 인식하고 올바르게 의존성 주입을 할 수 있어요
어노테이션을 사용하지 않으면 의존성 에러가 발생하더라구요!!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이해가 쏙쏙 되네요! 상세한 답변 감사합니다!

) {
/**
* 소셜 로그인 처리
* @param providerType 로그인 제공자 타입
* @return 로그인 결과
*/
suspend fun processLogin(
providerType: ProviderType,
): Result<String> {
val dataSource =
socialLoginDataSources.find { it.supportedType == providerType }
?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}"))

return dataSource.login()
}
}
17 changes: 17 additions & 0 deletions Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.alarmy.near.data.di

import com.alarmy.near.data.datasource.KakaoDataSource
import com.alarmy.near.data.datasource.SocialLoginDataSource
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet

@Module
@InstallIn(SingletonComponent::class)
interface DataSourceModule {
@Binds
@IntoSet
abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource
}
25 changes: 25 additions & 0 deletions Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.alarmy.near.data.di

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

// DataStore 확장 프로퍼티
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.dataStore
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.alarmy.near.data.di

import com.alarmy.near.data.repository.AuthRepository
import com.alarmy.near.data.repository.AuthRepositoryImpl
import com.alarmy.near.data.repository.DefaultFriendRepository
import com.alarmy.near.data.repository.ExampleRepository
import com.alarmy.near.data.repository.ExampleRepositoryImpl
Expand All @@ -20,4 +22,8 @@ interface RepositoryModule {
@Binds
@Singleton
abstract fun bindFriendRepository(friendRepository: DefaultFriendRepository): FriendRepository

@Binds
@Singleton
abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
}
Loading