From 2ad303cc59b9dc7d4fd51ab4474562a655c37206 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 17 Apr 2026 20:08:39 +0900 Subject: [PATCH 01/63] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B0=8F=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=84=9C=EB=B2=84=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 인증 관련 네트워크 데이터 소스 및 API 구현** * `AuthService`: `login`, `logout`, `reissueToken`, `withdraw` 등 인증 관련 Ktorfit API 엔드포인트 정의 * `AuthRemoteDataSourceImpl`: 서버 API 호출 및 인증 토큰 응답 로깅 로직 구현 * `LoginRequest`, `LoginResponse` 등 인증 관련 데이터 모델(DTO) 추가 * **feat: 도메인 및 데이터 레이어 내 AuthRepository 추가** * `core:domain` 모듈에 `AuthRepository` 인터페이스를 정의하고, `core:data`에 `AuthRepositoryImpl` 구현체를 추가했습니다. * Hilt를 이용한 `RepositoryModule` 및 `DataSourceModule` 의존성 주입 설정을 완료했습니다. * **feat: Kakao 로그인 idToken 연동 및 서버 로그인 로직 추가** * `KakaoAuthClient`: 로그인 성공 시 SDK로부터 받은 `idToken`을 `AuthResult.Success`에 포함하여 반환하도록 수정했습니다. * `LoginViewModel`: 소셜 로그인 성공 후 받은 `idToken`을 `AuthRepository.login`을 통해 서버에 전달하고 인증을 완료하는 흐름을 구현했습니다. * `LoginUiState` 및 `Intent`: 로그인 요청 중인 제공자(`pendingProvider`) 상태를 관리하도록 개선했습니다. * **refactor: 네트워크 보안 설정 및 UI 컴포넌트 개선** * `network_security_config.xml`: 개발 서버 도메인(`prezel.p-e.kr`)에 대해 명시적으로 Cleartext 트래픽 허용 설정을 추가했습니다. * `StatusView`: `StatusLottie`의 `modifier` 적용 방식을 개선하여 크기 지정 로직을 수정했습니다. * **build: 모듈 구조 변경 및 의존성 추가** * `core:domain` 모듈을 신규 생성하고 `settings.gradle.kts`에 등록했습니다. * `feature:login:impl`, `core:data` 등 주요 모듈에 `core:domain` 및 인증 관련 의존성을 추가했습니다. * `.gitignore`: `.kotlin`, `.DS_Store` 등 불필요한 파일 제외 설정을 추가했습니다. --- .gitignore | 4 ++ Prezel/build.gradle.kts | 1 + .../team/prezel/core/auth/KakaoAuthClient.kt | 19 +++++- .../team/prezel/core/auth/model/AuthResult.kt | 4 +- Prezel/core/data/build.gradle.kts | 3 + .../prezel/core/data/di/RepositoryModule.kt | 17 +++++ .../data/repository/AuthRepositoryImpl.kt | 20 ++++++ Prezel/core/domain/.gitignore | 1 + Prezel/core/domain/build.gradle.kts | 9 +++ .../team/prezel/core/domain/AuthRepository.kt | 9 +++ .../datasource/AuthRemoteDataSource.kt | 18 +++++ .../datasource/AuthRemoteDataSourceImpl.kt | 65 ++++++++++++++++++ .../core/network/di/AuthNetworkModule.kt | 18 +++++ .../core/network/di/DataSourceModule.kt | 17 +++++ .../core/network/model/auth/LoginRequest.kt | 10 +++ .../core/network/service/AuthService.kt | 34 ++++++++++ .../com/team/prezel/core/ui/StatusView.kt | 4 +- Prezel/feature/login/impl/build.gradle.kts | 1 + .../feature/login/impl/landing/LoginScreen.kt | 7 +- .../login/impl/landing/LoginViewModel.kt | 66 ++++++++++++++----- .../impl/landing/contract/LoginUiIntent.kt | 1 + .../impl/landing/contract/LoginUiState.kt | 2 + Prezel/gradle/libs.versions.toml | 2 + Prezel/settings.gradle.kts | 1 + 24 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt create mode 100644 Prezel/core/domain/.gitignore create mode 100644 Prezel/core/domain/build.gradle.kts create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginRequest.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt diff --git a/.gitignore b/.gitignore index e5cbb641..e577c5cc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ output-metadata.json # IntelliJ *.iml .idea/ +.kotlin/ misc.xml deploymentTargetDropDown.xml render.experimental.xml @@ -32,3 +33,6 @@ google-services.json # Android Profiling *.hprof + +# macOS Finder +.DS_Store diff --git a/Prezel/build.gradle.kts b/Prezel/build.gradle.kts index 80ba2255..21ba2ac9 100644 --- a/Prezel/build.gradle.kts +++ b/Prezel/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.detekt) apply true alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.jvm) apply false } subprojects { diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt index 1ec4bc6e..1d2d0d31 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt @@ -7,6 +7,7 @@ import com.kakao.sdk.common.model.AuthErrorCause import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause import com.kakao.sdk.user.UserApiClient +import com.team.prezel.core.auth.BuildConfig import com.team.prezel.core.auth.model.AuthResult import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine @@ -75,8 +76,22 @@ class KakaoAuthClient } token != null -> { - Timber.d("$loginType 로그인에 성공했습니다.") - resume(AuthResult.Success) + if (BuildConfig.DEBUG) { + Timber.tag("AuthToken").d( + "Kakao SDK token access=%s refresh=%s id=%s", + token.accessToken, + token.refreshToken, + token.idToken, + ) + } + val idToken = token.idToken + if (idToken.isNullOrBlank()) { + Timber.e("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") + resume(AuthResult.Failure.Unknown) + } else { + Timber.d("$loginType 로그인에 성공했습니다.") + resume(AuthResult.Success(idToken = idToken)) + } } else -> { diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt index 7c608e7b..4ae9583c 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt @@ -1,7 +1,9 @@ package com.team.prezel.core.auth.model sealed interface AuthResult { - data object Success : AuthResult + data class Success( + val idToken: String, + ) : AuthResult data object Cancelled : AuthResult diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index 84e9bb72..15e72cbe 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -9,6 +9,9 @@ android { } dependencies { + implementation(projects.coreDomain) + implementation(projects.coreModel) implementation(projects.coreNetwork) + implementation(libs.kotlinx.coroutines.core) } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt new file mode 100644 index 00000000..189528eb --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.data.di + +import com.team.prezel.core.data.repository.AuthRepositoryImpl +import com.team.prezel.core.domain.AuthRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 00000000..fb972469 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import com.team.prezel.core.network.model.ApiResponse +import javax.inject.Inject + +internal class AuthRepositoryImpl @Inject constructor( + private val authRemoteDataSource: AuthRemoteDataSource, +) : AuthRepository { + override suspend fun login( + provider: String, + idToken: String, + ): Result = + when (val response = authRemoteDataSource.login(idToken = idToken)) { + is ApiResponse.Success -> Result.success(Unit) + is ApiResponse.Failure.HttpError -> Result.failure(response.throwable) + is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + } +} diff --git a/Prezel/core/domain/.gitignore b/Prezel/core/domain/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/Prezel/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Prezel/core/domain/build.gradle.kts b/Prezel/core/domain/build.gradle.kts new file mode 100644 index 00000000..bfa0805f --- /dev/null +++ b/Prezel/core/domain/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.prezel.jvm.library) +} + +dependencies { + implementation(projects.coreModel) + implementation(libs.javax.inject) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt new file mode 100644 index 00000000..d3cbb5bb --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.domain + + +interface AuthRepository { + suspend fun login( + provider: String, + idToken: String, + ): Result +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt new file mode 100644 index 00000000..f4dc126b --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt @@ -0,0 +1,18 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.auth.LoginResponse + +interface AuthRemoteDataSource { + suspend fun reissueToken(refreshToken: String): ApiResponse + + suspend fun logout(accessToken: String): ApiResponse + + suspend fun login(idToken: String): ApiResponse + + suspend fun withdraw( + accessToken: String, + reasonCategory: String, + reasonText: String, + ): ApiResponse +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt new file mode 100644 index 00000000..99e23c84 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -0,0 +1,65 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.auth.LoginRequest +import com.team.prezel.core.network.model.auth.LoginResponse +import com.team.prezel.core.network.model.auth.ReissueTokenRequest +import com.team.prezel.core.network.model.auth.WithdrawRequest +import com.team.prezel.core.network.service.AuthService +import timber.log.Timber +import javax.inject.Inject + +internal class AuthRemoteDataSourceImpl @Inject constructor( + private val authService: AuthService, +) : AuthRemoteDataSource { + override suspend fun reissueToken(refreshToken: String): ApiResponse = + authService + .reissueToken( + request = ReissueTokenRequest(refreshToken = refreshToken), + ).also(::logTokenResponse) + + override suspend fun logout(accessToken: String): ApiResponse = + authService.logout( + authorization = "Bearer $accessToken", + ) + + override suspend fun login(idToken: String): ApiResponse = + LoginRequest(idToken = idToken) + .let { request -> + authService.login(request = request) + }.also(::logTokenResponse) + + override suspend fun withdraw( + accessToken: String, + reasonCategory: String, + reasonText: String, + ): ApiResponse = + authService.withdraw( + authorization = "Bearer $accessToken", + request = WithdrawRequest( + reasonCategory = reasonCategory, + reasonText = reasonText, + ), + ) + + private fun logTokenResponse(response: ApiResponse) { + if (!BuildConfig.DEBUG) return + + when (response) { + is ApiResponse.Success -> { + Timber.tag("AuthToken").d("Server accessToken=%s", response.data.accessToken) + Timber.tag("AuthToken").d("Server refreshToken=%s", response.data.refreshToken) + } + + is ApiResponse.Failure.HttpError -> { + Timber.tag("AuthToken").e(response.throwable, "Server login failed: http error") + } + + is ApiResponse.Failure.NetworkError -> { + Timber.tag("AuthToken").e(response.throwable, "Server login failed: network error") + } + } + } + +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt new file mode 100644 index 00000000..9f8fb2b5 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt @@ -0,0 +1,18 @@ +package com.team.prezel.core.network.di + +import com.team.prezel.core.network.service.AuthService +import com.team.prezel.core.network.service.createAuthService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.jensklingenberg.ktorfit.Ktorfit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AuthNetworkModule { + @Provides + @Singleton + fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt new file mode 100644 index 00000000..573e58bd --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.network.di + +import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import com.team.prezel.core.network.datasource.AuthRemoteDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class DataSourceModule { + @Binds + @Singleton + abstract fun bindAuthRemoteDataSource(impl: AuthRemoteDataSourceImpl): AuthRemoteDataSource +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginRequest.kt new file mode 100644 index 00000000..f403f9da --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginRequest.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.network.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class LoginRequest( + @SerialName("idToken") + val idToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt new file mode 100644 index 00000000..b24e5451 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -0,0 +1,34 @@ +package com.team.prezel.core.network.service + +import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.auth.LoginRequest +import com.team.prezel.core.network.model.auth.LoginResponse +import com.team.prezel.core.network.model.auth.ReissueTokenRequest +import com.team.prezel.core.network.model.auth.WithdrawRequest +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.Header +import de.jensklingenberg.ktorfit.http.POST + +internal interface AuthService { + @POST("auth/reissue") + suspend fun reissueToken( + @Body request: ReissueTokenRequest, + ): ApiResponse + + @POST("auth/logout") + suspend fun logout( + @Header("Authorization") authorization: String, + ): ApiResponse + + @POST("auth/login") + suspend fun login( + @Body request: LoginRequest, + ): ApiResponse + + @DELETE("auth/withdraw") + suspend fun withdraw( + @Header("Authorization") authorization: String, + @Body request: WithdrawRequest, + ): ApiResponse +} diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/StatusView.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/StatusView.kt index 4d210788..26f05a32 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/StatusView.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/StatusView.kt @@ -79,7 +79,7 @@ fun StatusView( @Composable fun StatusLottie( @RawRes lottieJsonResId: Int, - modifier: Modifier = Modifier.size(80.dp), + modifier: Modifier = Modifier, ) { val composition by rememberLottieComposition( LottieCompositionSpec.RawRes(lottieJsonResId), @@ -92,7 +92,7 @@ fun StatusLottie( LottieAnimation( composition = composition, progress = { progress }, - modifier = modifier, + modifier = modifier.size(80.dp), ) } diff --git a/Prezel/feature/login/impl/build.gradle.kts b/Prezel/feature/login/impl/build.gradle.kts index 5a113b2e..0ef78c83 100644 --- a/Prezel/feature/login/impl/build.gradle.kts +++ b/Prezel/feature/login/impl/build.gradle.kts @@ -19,6 +19,7 @@ android { dependencies { implementation(projects.coreAuth) + implementation(projects.coreDomain) implementation(projects.featureLoginApi) implementation(projects.featureHomeApi) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index bb6ebecc..01a73437 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -69,7 +69,12 @@ internal fun SharedTransitionScope.LoginScreen( when (effect) { is LoginUiEffect.LaunchLogin -> { authManager.login(context = context, provider = effect.provider).also { result -> - viewModel.onIntent(LoginUiIntent.OnLoginResult(result = result)) + viewModel.onIntent( + LoginUiIntent.OnLoginResult( + provider = effect.provider, + result = result, + ), + ) } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 22418472..ebfcd654 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -3,8 +3,8 @@ package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult +import com.team.prezel.core.domain.AuthRepository import com.team.prezel.core.ui.BaseViewModel -import com.team.prezel.feature.login.impl.BuildConfig import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent import com.team.prezel.feature.login.impl.landing.contract.LoginUiState @@ -14,11 +14,16 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -internal class LoginViewModel @Inject constructor() : BaseViewModel(LoginUiState()) { +internal class LoginViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : BaseViewModel(LoginUiState()) { override fun onIntent(intent: LoginUiIntent) { when (intent) { is LoginUiIntent.OnClickLogin -> handleClickLogin(provider = intent.provider) - is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) + is LoginUiIntent.OnLoginResult -> handleLoginResult( + provider = intent.provider, + result = intent.result, + ) } } @@ -26,30 +31,57 @@ internal class LoginViewModel @Inject constructor() : BaseViewModel sendEffect(LoginUiEffect.NavigateToTerms) - AuthResult.Cancelled -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) - is AuthResult.Failure -> sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) + is AuthResult.Success -> handleServerLogin( + provider = provider, + idToken = result.idToken, + ) + AuthResult.Cancelled -> { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) + } + is AuthResult.Failure -> { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) + } } } } + private suspend fun handleServerLogin( + provider: AuthProvider, + idToken: String, + ) { + authRepository.login( + provider = provider.name, + idToken = idToken, + ).fold( + onSuccess = { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.NavigateToTerms) + }, + onFailure = { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginFailedUnknown)) + }, + ) + } + private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = when (this) { AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt index c0d8f2fb..08465502 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt @@ -10,6 +10,7 @@ internal sealed interface LoginUiIntent : UiIntent { ) : LoginUiIntent data class OnLoginResult( + val provider: AuthProvider, val result: AuthResult, ) : LoginUiIntent } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt index 0a7c3b41..ffec68aa 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt @@ -1,9 +1,11 @@ package com.team.prezel.feature.login.impl.landing.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.ui.UiState @Immutable internal data class LoginUiState( val isLoading: Boolean = false, + val pendingProvider: AuthProvider? = null, ) : UiState diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 4493ca8d..7ee08173 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.13.2" kotlin = "2.3.0" coil = "2.7.0" coreKtx = "1.17.0" +javaxInject = "1" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" @@ -51,6 +52,7 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } kotlin-metadata-jvm = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInject" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 9bc7cba1..7beec85e 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -42,6 +42,7 @@ includeAuto( "core:auth", ":core:data", ":core:designsystem", + ":core:domain", ":core:model", ":core:network", ":core:navigation", From bd5df98ee19ef8cdfcd90ac32346248c7edb6b2a Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 17 Apr 2026 20:21:21 +0900 Subject: [PATCH 02/63] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20UseCase=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20AuthRepo?= =?UTF-8?q?sitory=20=EB=A1=9C=EC=A7=81=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 인증 관련 UseCase 및 도메인 모델 추가** * 인증 상태를 관리하기 위한 `AuthToken` 및 `WithdrawReason` 도메인 모델을 `core:model`에 추가했습니다. * `LoginUseCase`, `LogoutUseCase`, `ReissueTokenUseCase`, `WithdrawUseCase`를 추가하여 인증 관련 비즈니스 로직을 구현했습니다. * **feat: 네트워크 응답 모델 추가** * 서버 통신을 위한 `LoginResponse`, `ReissueTokenRequest`, `WithdrawRequest` DTO를 `core:network`에 추가했습니다. * **refactor: AuthRepository 및 구현체 개선** * `AuthRepository` 인터페이스에 `reissueToken`, `logout`, `withdraw` 메서드를 추가하고, `login`의 반환 타입을 `Result`으로 변경했습니다. * `AuthRepositoryImpl`에서 `WithdrawReason`을 서버 카테고리 문자열로 변환하는 매핑 로직을 구현했습니다. * `ApiResponse`를 `Result`로 변환하는 공통 확장 함수 `toResult`를 `core:data`에 추가하여 코드 중복을 제거했습니다. * **refactor: LoginViewModel 내 로그인 호출 로직 수정** * 서버 로그인 요청 시 불필요한 `provider` 파라미터를 제거하고 `idToken`만 전달하도록 수정했습니다. * **build: 모듈 의존성 및 플러그인 설정 업데이트** * `feature:login:impl`에 `core:model` 의존성을 추가했습니다. * `AndroidFeatureImplConventionPlugin`에 `timber` 라이브러리 의존성을 추가했습니다. --- .../AndroidFeatureImplConventionPlugin.kt | 1 + .../team/prezel/core/data/ApiResponseExt.kt | 10 ++++ .../data/repository/AuthRepositoryImpl.kt | 49 ++++++++++++++++--- .../team/prezel/core/domain/AuthRepository.kt | 15 ++++-- .../core/domain/usecase/LoginUseCase.kt | 20 ++++++++ .../core/domain/usecase/LogoutUseCase.kt | 19 +++++++ .../domain/usecase/ReissueTokenUseCase.kt | 20 ++++++++ .../core/domain/usecase/WithdrawUseCase.kt | 28 +++++++++++ .../team/prezel/core/model/auth/AuthToken.kt | 6 +++ .../prezel/core/model/auth/WithdrawReason.kt | 17 +++++++ .../core/network/model/auth/LoginResponse.kt | 12 +++++ .../network/model/auth/ReissueTokenRequest.kt | 10 ++++ .../network/model/auth/WithdrawRequest.kt | 12 +++++ Prezel/feature/login/impl/build.gradle.kts | 2 + .../login/impl/landing/LoginViewModel.kt | 11 +---- 15 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LoginUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/ReissueTokenUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/ReissueTokenRequest.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt diff --git a/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt b/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt index c83a80a9..f23c4a3c 100644 --- a/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt +++ b/Prezel/build-logic/convention/src/main/java/com/team/prezel/buildlogic/convention/plugin/AndroidFeatureImplConventionPlugin.kt @@ -25,6 +25,7 @@ class AndroidFeatureImplConventionPlugin : Plugin { "implementation"(project(":core-navigation")) "implementation"(libs.findLibrary("androidx.navigation3.ui").get()) "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewmodel.compose").get()) + "implementation"(libs.findLibrary("timber").get()) } } } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt new file mode 100644 index 00000000..895484f9 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.data + +import com.team.prezel.core.network.model.ApiResponse + +internal inline fun ApiResponse.toResult(transform: (T) -> R): Result = + when (this) { + is ApiResponse.Success -> Result.success(transform(data)) + is ApiResponse.Failure.HttpError -> Result.failure(throwable) + is ApiResponse.Failure.NetworkError -> Result.failure(throwable) + } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index fb972469..855012d3 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -1,20 +1,53 @@ package com.team.prezel.core.data.repository +import com.team.prezel.core.data.toResult import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.model.auth.AuthToken +import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.datasource.AuthRemoteDataSource -import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.auth.LoginResponse import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, ) : AuthRepository { - override suspend fun login( - provider: String, - idToken: String, + override suspend fun reissueToken(refreshToken: String): Result = + authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult { it.toAuthToken() } + + override suspend fun logout(accessToken: String): Result = authRemoteDataSource.logout(accessToken = accessToken).toResult { Unit } + + override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult { it.toAuthToken() } + + override suspend fun withdraw( + accessToken: String, + reason: WithdrawReason, ): Result = - when (val response = authRemoteDataSource.login(idToken = idToken)) { - is ApiResponse.Success -> Result.success(Unit) - is ApiResponse.Failure.HttpError -> Result.failure(response.throwable) - is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + authRemoteDataSource + .withdraw( + accessToken = accessToken, + reasonCategory = reason.category, + reasonText = reason.reasonText, + ).toResult { Unit } + + private fun LoginResponse.toAuthToken(): AuthToken = + AuthToken( + accessToken = accessToken, + refreshToken = refreshToken, + ) + + private val WithdrawReason.category: String + get() = when (this) { + WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" + WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" + WithdrawReason.TooDifficultOrComplex -> "TOO_DIFFICULT_OR_COMPLEX" + WithdrawReason.AnalysisResultInaccurate -> "ANALYSIS_RESULT_INACCURATE" + WithdrawReason.TooManyErrors -> "TOO_MANY_ERRORS" + is WithdrawReason.Other -> "OTHER" + } + + private val WithdrawReason.reasonText: String + get() = when (this) { + is WithdrawReason.Other -> text + else -> "" } } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt index d3cbb5bb..cc7e08dc 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt @@ -1,9 +1,18 @@ package com.team.prezel.core.domain +import com.team.prezel.core.model.auth.AuthToken +import com.team.prezel.core.model.auth.WithdrawReason + interface AuthRepository { - suspend fun login( - provider: String, - idToken: String, + suspend fun reissueToken(refreshToken: String): Result + + suspend fun logout(accessToken: String): Result + + suspend fun login(idToken: String): Result + + suspend fun withdraw( + accessToken: String, + reason: WithdrawReason, ): Result } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LoginUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LoginUseCase.kt new file mode 100644 index 00000000..40aff91d --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LoginUseCase.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.domain.usecase + +import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.model.auth.AuthToken +import javax.inject.Inject + +/** + * 소셜 로그인에 사용되는 ID 토큰으로 서버 로그인을 수행하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 ID 토큰을 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.login]을 호출하여 서버 로그인 요청을 수행합니다. + * 3. 로그인 결과에 따라 [AuthToken] 또는 예외를 포함한 [Result]를 반환합니다. + * + */ +class LoginUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(idToken: String): Result = authRepository.login(idToken = idToken) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt new file mode 100644 index 00000000..f125ba4e --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase + +import com.team.prezel.core.domain.AuthRepository +import javax.inject.Inject + +/** + * 현재 로그인 세션의 로그아웃을 요청하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 액세스 토큰을 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출하여 서버에 로그아웃을 요청합니다. + * 3. 요청 결과에 따라 성공 여부를 [Result]로 반환합니다. + * + */ +class LogoutUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(accessToken: String): Result = authRepository.logout(accessToken = accessToken) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/ReissueTokenUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/ReissueTokenUseCase.kt new file mode 100644 index 00000000..253a5171 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/ReissueTokenUseCase.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.domain.usecase + +import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.model.auth.AuthToken +import javax.inject.Inject + +/** + * 리프레시 토큰을 기반으로 인증 토큰 재발급을 요청하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 리프레시 토큰을 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출하여 서버에 토큰 재발급을 요청합니다. + * 3. 재발급 결과에 따라 새로운 [AuthToken] 또는 예외를 포함한 [Result]를 반환합니다. + * + */ +class ReissueTokenUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(refreshToken: String): Result = authRepository.reissueToken(refreshToken = refreshToken) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt new file mode 100644 index 00000000..33202163 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt @@ -0,0 +1,28 @@ +package com.team.prezel.core.domain.usecase + +import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.model.auth.WithdrawReason +import javax.inject.Inject + +/** + * 회원 탈퇴 사유와 함께 탈퇴 요청을 수행하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 액세스 토큰과 [WithdrawReason]을 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출하여 서버에 회원 탈퇴를 요청합니다. + * 3. 요청 결과에 따라 성공 여부를 [Result]로 반환합니다. + * + */ +class WithdrawUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke( + accessToken: String, + reason: WithdrawReason, + ): Result = + authRepository.withdraw( + accessToken = accessToken, + reason = reason, + ) +} + diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt new file mode 100644 index 00000000..c505e6d4 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt @@ -0,0 +1,6 @@ +package com.team.prezel.core.model.auth + +data class AuthToken( + val accessToken: String, + val refreshToken: String, +) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt new file mode 100644 index 00000000..0505c618 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.model.auth + +sealed interface WithdrawReason { + data object NotUsedOften : WithdrawReason + + data object NoLongerNeeded : WithdrawReason + + data object TooDifficultOrComplex : WithdrawReason + + data object AnalysisResultInaccurate : WithdrawReason + + data object TooManyErrors : WithdrawReason + + data class Other( + val text: String, + ) : WithdrawReason +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginResponse.kt new file mode 100644 index 00000000..bd6c07b1 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginResponse.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/ReissueTokenRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/ReissueTokenRequest.kt new file mode 100644 index 00000000..a5d45f79 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/ReissueTokenRequest.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.network.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReissueTokenRequest( + @SerialName("refresh-token") + val refreshToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt new file mode 100644 index 00000000..4f6fb74a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WithdrawRequest( + @SerialName("reasonCategory") + val reasonCategory: String, + @SerialName("reasonText") + val reasonText: String, +) diff --git a/Prezel/feature/login/impl/build.gradle.kts b/Prezel/feature/login/impl/build.gradle.kts index 0ef78c83..e7d9e0c3 100644 --- a/Prezel/feature/login/impl/build.gradle.kts +++ b/Prezel/feature/login/impl/build.gradle.kts @@ -20,6 +20,8 @@ android { dependencies { implementation(projects.coreAuth) implementation(projects.coreDomain) + implementation(projects.coreModel) + implementation(projects.featureLoginApi) implementation(projects.featureHomeApi) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index ebfcd654..63beffa0 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -47,10 +47,7 @@ internal class LoginViewModel @Inject constructor( ) { viewModelScope.launch { when (result) { - is AuthResult.Success -> handleServerLogin( - provider = provider, - idToken = result.idToken, - ) + is AuthResult.Success -> handleServerLogin(idToken = result.idToken) AuthResult.Cancelled -> { updateState { copy(isLoading = false, pendingProvider = null) } sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) @@ -63,12 +60,8 @@ internal class LoginViewModel @Inject constructor( } } - private suspend fun handleServerLogin( - provider: AuthProvider, - idToken: String, - ) { + private suspend fun handleServerLogin(idToken: String) { authRepository.login( - provider = provider.name, idToken = idToken, ).fold( onSuccess = { From c98eb9642ee91cdc7d78c20cd070f191ed774a48 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 17 Apr 2026 20:28:52 +0900 Subject: [PATCH 03/63] =?UTF-8?q?feat:=20DataStore=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9D=98=20=EC=9D=B8=EC=A6=9D=20=ED=86=A0=ED=81=B0=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=9E=90=EB=8F=99=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?(Refresh)=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthTokenStore` 인터페이스 및 DataStore 기반 구현체 추가** * 인증 토큰(Access/Refresh Token)을 안전하게 저장하고 관리하기 위한 구조를 설계했습니다. * `AuthTokenStore`: 토큰 조회, 저장, 삭제를 위한 인터페이스 정의 * `DataStoreAuthTokenStore`: `Jetpack DataStore`를 사용하여 로컬에 토큰을 영구 저장하는 구현체 추가 (메모리 캐싱 및 비동기 저장 로직 포함) * `AuthTokenModule`: Hilt를 이용한 `AuthTokenStore` 의존성 주입 설정 * `libs.versions.toml` 및 `core:network` 모듈에 `datastore-preferences` 의존성 추가 * **feat: 토큰 자동 재발급 및 인증 로직 구현** * 네트워크 요청 시 인증 상태를 관리하고, 401 오류 발생 시 토큰을 자동으로 갱신하는 로직을 추가했습니다. * `AuthTokenRefresher`: Refresh Token을 사용하여 새로운 Access Token을 발급받는 기능 구현 (Mutex를 이용한 중복 요청 방지 및 실패 시 토큰 삭제) * `TokenRefreshAuthenticator`: OkHttp `Authenticator`를 구현하여 401 Unauthorized 발생 시 토큰 재발급 및 재시도 로직 처리 * `requiresAuthorization()`: 로그인, 토큰 재발급 등 인증 헤더가 불필요한 경로를 판별하는 확장 함수 추가 * **refactor: `NetworkModule` 구조 개선 및 인증 인터셉터 적용** * 인증이 필요한 요청과 토큰 재발급을 위한 클라이언트를 분리하고 공통 설정을 통합했습니다. * `provideHttpClient`: `TokenRefreshAuthenticator`를 등록하고, 요청 헤더에 `Authorization` 토큰을 자동으로 추가하도록 개선 * `provideRefreshHttpClient`: 토큰 재발급 시 무한 루프를 방지하기 위해 별도의 `@Named("refresh")` 클라이언트 추가 * `configureBaseClient`: 공통적인 Ktor 클라이언트 설정(ContentNegotiation, Logging 등)을 별도 함수로 추출 * **refactor: `AuthRepositoryImpl` 내 토큰 관리 로직 연동** * 인증 관련 API 호출 결과에 따라 로컬 토큰 상태를 동기화하도록 수정했습니다. * `login`, `reissueToken` 성공 시 발급된 토큰을 `AuthTokenStore`에 저장하도록 `saveTokens` 도우미 함수 추가 * `logout`, `withdraw` 성공 시 저장된 토큰 정보를 삭제(`clear`)하도록 변경 --- .../data/repository/AuthRepositoryImpl.kt | 25 +++++- .../team/prezel/core/domain/AuthRepository.kt | 1 - .../core/domain/usecase/WithdrawUseCase.kt | 1 - Prezel/core/network/build.gradle.kts | 1 + .../core/network/auth/AuthTokenRefresher.kt | 58 +++++++++++++ .../core/network/auth/AuthTokenStore.kt | 14 +++ .../network/auth/DataStoreAuthTokenStore.kt | 86 +++++++++++++++++++ .../network/auth/TokenRefreshAuthenticator.kt | 54 ++++++++++++ .../datasource/AuthRemoteDataSourceImpl.kt | 1 - .../prezel/core/network/di/AuthTokenModule.kt | 17 ++++ .../prezel/core/network/di/NetworkModule.kt | 60 ++++++++++--- .../login/impl/landing/LoginViewModel.kt | 36 ++++---- Prezel/gradle/libs.versions.toml | 2 + 13 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenModule.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 855012d3..6012790e 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -4,19 +4,24 @@ import com.team.prezel.core.data.toResult import com.team.prezel.core.domain.AuthRepository import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.model.auth.LoginResponse import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, + private val authTokenStore: AuthTokenStore, ) : AuthRepository { override suspend fun reissueToken(refreshToken: String): Result = - authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult { it.toAuthToken() } + authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) - override suspend fun logout(accessToken: String): Result = authRemoteDataSource.logout(accessToken = accessToken).toResult { Unit } + override suspend fun logout(accessToken: String): Result = + authRemoteDataSource.logout(accessToken = accessToken).toResult { + authTokenStore.clear() + } - override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult { it.toAuthToken() } + override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) override suspend fun withdraw( accessToken: String, @@ -27,7 +32,19 @@ internal class AuthRepositoryImpl @Inject constructor( accessToken = accessToken, reasonCategory = reason.category, reasonText = reason.reasonText, - ).toResult { Unit } + ).toResult { + authTokenStore.clear() + } + + private fun saveTokens(response: LoginResponse): AuthToken = + response + .toAuthToken() + .also { token -> + authTokenStore.saveTokens( + accessToken = token.accessToken, + refreshToken = token.refreshToken, + ) + } private fun LoginResponse.toAuthToken(): AuthToken = AuthToken( diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt index cc7e08dc..3b401f8e 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt @@ -3,7 +3,6 @@ package com.team.prezel.core.domain import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason - interface AuthRepository { suspend fun reissueToken(refreshToken: String): Result diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt index 33202163..aeb2430c 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt @@ -25,4 +25,3 @@ class WithdrawUseCase @Inject constructor( reason = reason, ) } - diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index efb6ce21..5c28a8b6 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -17,6 +17,7 @@ android { } dependencies { + implementation(libs.androidx.datastore.preferences) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt new file mode 100644 index 00000000..db43a815 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -0,0 +1,58 @@ +package com.team.prezel.core.network.auth + +import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.model.auth.LoginResponse +import com.team.prezel.core.network.model.auth.ReissueTokenRequest +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ResponseException +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class AuthTokenRefresher @Inject constructor( + @param:Named("refresh") + private val refreshHttpClient: HttpClient, + private val authTokenStore: AuthTokenStore, +) { + private val mutex = Mutex() + + suspend fun refreshAccessToken(): String? = + mutex.withLock { + val refreshToken = authTokenStore.getRefreshToken() ?: return@withLock null + + runCatching { + refreshHttpClient + .post("${BuildConfig.BASE_URL}auth/reissue") { + contentType(ContentType.Application.Json) + setBody(ReissueTokenRequest(refreshToken = refreshToken)) + }.body() + }.onSuccess { response -> + authTokenStore.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + if (BuildConfig.DEBUG) { + Timber.tag("AuthToken").d("Refreshed accessToken=%s", response.accessToken) + Timber.tag("AuthToken").d("Refreshed refreshToken=%s", response.refreshToken) + } + }.onFailure { throwable -> + if (throwable.isInvalidRefreshToken()) { + authTokenStore.clear() + } + Timber.e(throwable, "토큰 재발급에 실패했습니다.") + }.getOrNull() + ?.accessToken + } + + private fun Throwable.isInvalidRefreshToken(): Boolean = this is ResponseException && response.status == HttpStatusCode.Unauthorized +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt new file mode 100644 index 00000000..218f3094 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.network.auth + +interface AuthTokenStore { + fun getAccessToken(): String? + + fun getRefreshToken(): String? + + fun saveTokens( + accessToken: String, + refreshToken: String, + ) + + fun clear() +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt new file mode 100644 index 00000000..16f983e4 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt @@ -0,0 +1,86 @@ +package com.team.prezel.core.network.auth + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import com.team.prezel.core.network.di.ApplicationScope +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DataStoreAuthTokenStore @Inject constructor( + @ApplicationContext context: Context, + @param:ApplicationScope private val applicationScope: CoroutineScope, +) : AuthTokenStore { + private val dataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = applicationScope, + produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, + ) + + @Volatile + private var accessToken: String? = null + + @Volatile + private var refreshToken: String? = null + + init { + val preferences = runBlocking { + dataStore.data + .catch { exception -> + if (exception is IOException) emit(emptyPreferences()) else throw exception + }.first() + } + accessToken = preferences[KEY_ACCESS_TOKEN] + refreshToken = preferences[KEY_REFRESH_TOKEN] + } + + override fun getAccessToken(): String? = accessToken + + override fun getRefreshToken(): String? = refreshToken + + override fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + this.accessToken = accessToken + this.refreshToken = refreshToken + + applicationScope.launch { + dataStore.edit { preferences -> + preferences[KEY_ACCESS_TOKEN] = accessToken + preferences[KEY_REFRESH_TOKEN] = refreshToken + } + } + } + + override fun clear() { + accessToken = null + refreshToken = null + + applicationScope.launch { + dataStore.edit { preferences -> + preferences.remove(KEY_ACCESS_TOKEN) + preferences.remove(KEY_REFRESH_TOKEN) + } + } + } + + private companion object { + const val PREFERENCES_NAME = "auth_token_preferences" + val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") + val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt new file mode 100644 index 00000000..7dd2797e --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt @@ -0,0 +1,54 @@ +package com.team.prezel.core.network.auth + +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenRefreshAuthenticator @Inject constructor( + private val authTokenRefresher: AuthTokenRefresher, +) : Authenticator { + override fun authenticate( + route: Route?, + response: Response, + ): Request? { + if (!response.request.url.encodedPath + .requiresAuthorization() + ) { + return null + } + if (responseCount(response) >= MAX_AUTH_RETRY_COUNT) return null + + val refreshedAccessToken = + runBlocking { authTokenRefresher.refreshAccessToken() } ?: return null + + return response.request + .newBuilder() + .removeHeader(HttpHeaders.Authorization) + .addHeader(HttpHeaders.Authorization, "Bearer $refreshedAccessToken") + .build() + } + + private fun String.requiresAuthorization(): Boolean = this != LOGIN_PATH && this != REISSUE_PATH + + private fun responseCount(response: Response): Int { + var count = 1 + var current = response.priorResponse + while (current != null) { + count++ + current = current.priorResponse + } + return count + } + + private companion object { + const val LOGIN_PATH = "/auth/login" + const val REISSUE_PATH = "/auth/reissue" + const val MAX_AUTH_RETRY_COUNT = 2 + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index 99e23c84..6b400f35 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -61,5 +61,4 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( } } } - } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenModule.kt new file mode 100644 index 00000000..edc3043a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.network.di + +import com.team.prezel.core.network.auth.AuthTokenStore +import com.team.prezel.core.network.auth.DataStoreAuthTokenStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AuthTokenModule { + @Binds + @Singleton + abstract fun bindAuthTokenStore(impl: DataStoreAuthTokenStore): AuthTokenStore +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index a77a1357..5d4d06c6 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -2,12 +2,15 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.auth.AuthTokenStore +import com.team.prezel.core.network.auth.TokenRefreshAuthenticator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -15,10 +18,13 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.contentType +import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import timber.log.Timber +import javax.inject.Named import javax.inject.Singleton @Module @@ -36,25 +42,40 @@ object NetworkModule { @Provides @Singleton - fun provideHttpClient(json: Json): HttpClient = + @Named("refresh") + fun provideRefreshHttpClient(json: Json): HttpClient = HttpClient(OkHttp) { - expectSuccess = true + configureBaseClient(json) - install(ContentNegotiation) { - json(json) + defaultRequest { + contentType(ContentType.Application.Json) } + } - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - Timber.tag("KtorClient").d(message) - } + @Provides + @Singleton + fun provideHttpClient( + json: Json, + authTokenStore: AuthTokenStore, + tokenRefreshAuthenticator: TokenRefreshAuthenticator, + ): HttpClient = + HttpClient(OkHttp) { + engine { + config { + authenticator(tokenRefreshAuthenticator) } - level = if (BuildConfig.DEBUG) LogLevel.BODY else LogLevel.NONE } + configureBaseClient(json) + defaultRequest { contentType(ContentType.Application.Json) + + if (headers[HttpHeaders.Authorization] == null && url.encodedPath.requiresAuthorization()) { + authTokenStore.getAccessToken()?.let { accessToken -> + headers.append(HttpHeaders.Authorization, "Bearer $accessToken") + } + } } } @@ -67,4 +88,23 @@ object NetworkModule { .httpClient(httpClient) .converterFactories(ApiResponseConverterFactory()) .build() + + private fun HttpClientConfig<*>.configureBaseClient(json: Json) { + expectSuccess = true + + install(ContentNegotiation) { + json(json) + } + + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Timber.tag("KtorClient").d(message) + } + } + level = if (BuildConfig.DEBUG) LogLevel.BODY else LogLevel.NONE + } + } + + private fun String.requiresAuthorization(): Boolean = this != "/auth/login" && this != "/auth/reissue" } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 63beffa0..315c5de8 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -20,10 +20,7 @@ internal class LoginViewModel @Inject constructor( override fun onIntent(intent: LoginUiIntent) { when (intent) { is LoginUiIntent.OnClickLogin -> handleClickLogin(provider = intent.provider) - is LoginUiIntent.OnLoginResult -> handleLoginResult( - provider = intent.provider, - result = intent.result, - ) + is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) } } @@ -41,10 +38,7 @@ internal class LoginViewModel @Inject constructor( } } - private fun handleLoginResult( - provider: AuthProvider, - result: AuthResult, - ) { + private fun handleLoginResult(result: AuthResult) { viewModelScope.launch { when (result) { is AuthResult.Success -> handleServerLogin(idToken = result.idToken) @@ -52,6 +46,7 @@ internal class LoginViewModel @Inject constructor( updateState { copy(isLoading = false, pendingProvider = null) } sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) } + is AuthResult.Failure -> { updateState { copy(isLoading = false, pendingProvider = null) } sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) @@ -61,18 +56,19 @@ internal class LoginViewModel @Inject constructor( } private suspend fun handleServerLogin(idToken: String) { - authRepository.login( - idToken = idToken, - ).fold( - onSuccess = { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.NavigateToTerms) - }, - onFailure = { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginFailedUnknown)) - }, - ) + authRepository + .login( + idToken = idToken, + ).fold( + onSuccess = { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.NavigateToTerms) + }, + onFailure = { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginFailedUnknown)) + }, + ) } private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 7ee08173..b2083a2d 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -8,6 +8,7 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" activityCompose = "1.12.4" +androidxDatastore = "1.2.1" androidxLifecycle = "2.10.0" composeBom = "2026.01.00" ktlint = "14.0.1" @@ -30,6 +31,7 @@ lottie = "6.6.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } From 5a02bb48f704aad244296b3a38c93cdb1bddcab1 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 17 Apr 2026 20:38:21 +0900 Subject: [PATCH 04/63] =?UTF-8?q?refactor:=20`AuthTokenStore`=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=20=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `AuthTokenStore` 및 `DataStoreAuthTokenStore` 비동기화 및 안정성 개선** * `saveTokens`와 `clear` 메서드를 `suspend`로 변경하여 비동기 데이터 저장을 보장하도록 개선했습니다. * `DataStoreAuthTokenStore` 내부 로직에 `Mutex`를 도입하여 토큰 읽기/쓰기 시 발생할 수 있는 race condition을 방지했습니다. * `init` 블록에서 `runBlocking`으로 초기화하던 로직을 별도의 `initializeCache` 메서드로 분리하고 `applicationScope`를 사용하도록 수정했습니다. * **refactor: `AuthTokenRefresher` 예외 처리 및 토큰 갱신 로직 개선** * `runCatching` 대신 `try-catch` 블록을 사용하여 토큰 재발급 로직의 명시성을 높였습니다. * 토큰 재발급 성공 시 로직을 정돈하고 `Result` 반환 흐름을 개선했습니다. * **refactor: `ApiResponse` 확장 함수 및 저장 로직 비동기 대응** * `ApiResponse.toResult` 확장 함수에 `suspend` 및 `crossinline` 키워드를 추가하여 비동기 `transform` 함수를 지원하도록 변경했습니다. * `AuthRepositoryImpl`의 `saveTokens`를 `suspend`로 변경하여 변경된 `AuthTokenStore` 인터페이스를 반영했습니다. * **security: 디버그 로그 내 민감 정보 출력 제거** * `AuthRemoteDataSourceImpl`, `KakaoAuthClient`, `AuthTokenRefresher`에서 디버그 시 출력하던 실제 토큰 값(`accessToken`, `refreshToken`, `idToken`)을 로그에서 삭제하고, 성공 여부만 출력하도록 변경하여 보안을 강화했습니다. --- .../team/prezel/core/auth/KakaoAuthClient.kt | 159 +++++++++--------- .../team/prezel/core/data/ApiResponseExt.kt | 2 +- .../data/repository/AuthRepositoryImpl.kt | 2 +- .../core/network/auth/AuthTokenRefresher.kt | 25 +-- .../core/network/auth/AuthTokenStore.kt | 6 +- .../network/auth/DataStoreAuthTokenStore.kt | 52 +++--- .../datasource/AuthRemoteDataSourceImpl.kt | 5 +- 7 files changed, 129 insertions(+), 122 deletions(-) diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt index 1d2d0d31..2f15ebbd 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt @@ -7,7 +7,6 @@ import com.kakao.sdk.common.model.AuthErrorCause import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause import com.kakao.sdk.user.UserApiClient -import com.team.prezel.core.auth.BuildConfig import com.team.prezel.core.auth.model.AuthResult import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine @@ -16,105 +15,99 @@ import javax.inject.Inject import kotlin.coroutines.resume class KakaoAuthClient - @Inject - constructor() : AuthClient { - override suspend fun login(context: Context): AuthResult = - suspendCancellableCoroutine { continuation -> - if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk(context = context, continuation = continuation) - return@suspendCancellableCoroutine - } - - loginWithKakaoAccount(context = context, continuation = continuation) +@Inject +constructor() : AuthClient { + override suspend fun login(context: Context): AuthResult = + suspendCancellableCoroutine { continuation -> + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk(context = context, continuation = continuation) + return@suspendCancellableCoroutine } - override suspend fun logout(): Result = - suspendCancellableCoroutine { continuation -> - Timber.d("카카오 로그아웃 시도") + loginWithKakaoAccount(context = context, continuation = continuation) + } - UserApiClient.instance.logout { error -> - if (error != null) { - Timber.e(error, "카카오 로그아웃에 실패했습니다.") - continuation.resume(Result.failure(error)) - return@logout - } + override suspend fun logout(): Result = + suspendCancellableCoroutine { continuation -> + Timber.d("카카오 로그아웃 시도") - Timber.d("카카오 로그아웃에 성공했습니다.") - continuation.resume(Result.success(Unit)) + UserApiClient.instance.logout { error -> + if (error != null) { + Timber.e(error, "카카오 로그아웃에 실패했습니다.") + continuation.resume(Result.failure(error)) + return@logout } - } - private fun loginWithKakaoTalk( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오톡으로 로그인 시도") - UserApiClient.instance.loginWithKakaoTalk( - context = context, - callback = continuation.loginCallback(loginType = "카카오톡"), - ) + Timber.d("카카오 로그아웃에 성공했습니다.") + continuation.resume(Result.success(Unit)) + } } - private fun loginWithKakaoAccount( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오 계정으로 로그인 시도") - UserApiClient.instance.loginWithKakaoAccount( - context = context, - callback = continuation.loginCallback(loginType = "카카오 계정"), - ) - } + private fun loginWithKakaoTalk( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오톡으로 로그인 시도") + UserApiClient.instance.loginWithKakaoTalk( + context = context, + callback = continuation.loginCallback(loginType = "카카오톡"), + ) + } - private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = - { token, error -> - when { - error != null -> { - val authResult = error.toAuthResult() - Timber.e(error, "$loginType 로그인에 실패했습니다. ($authResult)") - resume(authResult) - } + private fun loginWithKakaoAccount( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오 계정으로 로그인 시도") + UserApiClient.instance.loginWithKakaoAccount( + context = context, + callback = continuation.loginCallback(loginType = "카카오 계정"), + ) + } + + private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = + { token, error -> + when { + error != null -> { + val authResult = error.toAuthResult() + Timber.e(error, "$loginType 로그인에 실패했습니다. ($authResult)") + resume(authResult) + } - token != null -> { + token != null -> { + val idToken = token.idToken + if (idToken.isNullOrBlank()) { + Timber.e("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") + resume(AuthResult.Failure.Unknown) + } else { if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d( - "Kakao SDK token access=%s refresh=%s id=%s", - token.accessToken, - token.refreshToken, - token.idToken, - ) - } - val idToken = token.idToken - if (idToken.isNullOrBlank()) { - Timber.e("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") - resume(AuthResult.Failure.Unknown) - } else { - Timber.d("$loginType 로그인에 성공했습니다.") - resume(AuthResult.Success(idToken = idToken)) + Timber.tag("AuthToken").d("$loginType 로그인에 성공했습니다.") } + resume(AuthResult.Success(idToken = idToken)) } + } - else -> { - Timber.e("$loginType 로그인 결과가 비어있습니다.") - resume(AuthResult.Failure.Unknown) - } + else -> { + Timber.e("$loginType 로그인 결과가 비어있습니다.") + resume(AuthResult.Failure.Unknown) } } - - private fun Throwable.toAuthResult(): AuthResult { - if (this is AuthError) return toAuthErrorResult() - if (this is ClientError) return toClientErrorResult() - return AuthResult.Failure.Unknown } - private fun AuthError.toAuthErrorResult(): AuthResult { - if (statusCode == 429) return AuthResult.Failure.RateLimited - if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled - return AuthResult.Failure.Unknown - } + private fun Throwable.toAuthResult(): AuthResult { + if (this is AuthError) return toAuthErrorResult() + if (this is ClientError) return toClientErrorResult() + return AuthResult.Failure.Unknown + } - private fun ClientError.toClientErrorResult(): AuthResult { - if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled - return AuthResult.Failure.Unknown - } + private fun AuthError.toAuthErrorResult(): AuthResult { + if (statusCode == 429) return AuthResult.Failure.RateLimited + if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled + return AuthResult.Failure.Unknown + } + + private fun ClientError.toClientErrorResult(): AuthResult { + if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled + return AuthResult.Failure.Unknown } +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt index 895484f9..94f74038 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt @@ -2,7 +2,7 @@ package com.team.prezel.core.data import com.team.prezel.core.network.model.ApiResponse -internal inline fun ApiResponse.toResult(transform: (T) -> R): Result = +internal suspend inline fun ApiResponse.toResult(crossinline transform: suspend (T) -> R): Result = when (this) { is ApiResponse.Success -> Result.success(transform(data)) is ApiResponse.Failure.HttpError -> Result.failure(throwable) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 6012790e..fbf046cb 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -36,7 +36,7 @@ internal class AuthRepositoryImpl @Inject constructor( authTokenStore.clear() } - private fun saveTokens(response: LoginResponse): AuthToken = + private suspend fun saveTokens(response: LoginResponse): AuthToken = response .toAuthToken() .also { token -> diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index db43a815..ac6b49c8 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -30,28 +30,29 @@ class AuthTokenRefresher @Inject constructor( mutex.withLock { val refreshToken = authTokenStore.getRefreshToken() ?: return@withLock null - runCatching { - refreshHttpClient - .post("${BuildConfig.BASE_URL}auth/reissue") { - contentType(ContentType.Application.Json) - setBody(ReissueTokenRequest(refreshToken = refreshToken)) - }.body() - }.onSuccess { response -> + try { + val response = + refreshHttpClient + .post("${BuildConfig.BASE_URL}auth/reissue") { + contentType(ContentType.Application.Json) + setBody(ReissueTokenRequest(refreshToken = refreshToken)) + }.body() + authTokenStore.saveTokens( accessToken = response.accessToken, refreshToken = response.refreshToken, ) if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d("Refreshed accessToken=%s", response.accessToken) - Timber.tag("AuthToken").d("Refreshed refreshToken=%s", response.refreshToken) + Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") } - }.onFailure { throwable -> + response.accessToken + } catch (throwable: Throwable) { if (throwable.isInvalidRefreshToken()) { authTokenStore.clear() } Timber.e(throwable, "토큰 재발급에 실패했습니다.") - }.getOrNull() - ?.accessToken + null + } } private fun Throwable.isInvalidRefreshToken(): Boolean = this is ResponseException && response.status == HttpStatusCode.Unauthorized diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt index 218f3094..2a3d2c4e 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt @@ -5,10 +5,12 @@ interface AuthTokenStore { fun getRefreshToken(): String? - fun saveTokens( + fun initializeCache() + + suspend fun saveTokens( accessToken: String, refreshToken: String, ) - fun clear() + suspend fun clear() } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt index 16f983e4..67894db0 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt @@ -14,8 +14,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @@ -36,48 +38,60 @@ internal class DataStoreAuthTokenStore @Inject constructor( @Volatile private var refreshToken: String? = null + private val mutex = Mutex() + private val isCacheInitializationStarted = AtomicBoolean(false) + init { - val preferences = runBlocking { - dataStore.data - .catch { exception -> - if (exception is IOException) emit(emptyPreferences()) else throw exception - }.first() - } - accessToken = preferences[KEY_ACCESS_TOKEN] - refreshToken = preferences[KEY_REFRESH_TOKEN] + initializeCache() } override fun getAccessToken(): String? = accessToken override fun getRefreshToken(): String? = refreshToken - override fun saveTokens( + override fun initializeCache() { + if (!isCacheInitializationStarted.compareAndSet(false, true)) return + + applicationScope.launch { + mutex.withLock { + val preferences = readPreferences() + accessToken = preferences[KEY_ACCESS_TOKEN] + refreshToken = preferences[KEY_REFRESH_TOKEN] + } + } + } + + override suspend fun saveTokens( accessToken: String, refreshToken: String, ) { - this.accessToken = accessToken - this.refreshToken = refreshToken - - applicationScope.launch { + mutex.withLock { dataStore.edit { preferences -> preferences[KEY_ACCESS_TOKEN] = accessToken preferences[KEY_REFRESH_TOKEN] = refreshToken } + this.accessToken = accessToken + this.refreshToken = refreshToken } } - override fun clear() { - accessToken = null - refreshToken = null - - applicationScope.launch { + override suspend fun clear() { + mutex.withLock { dataStore.edit { preferences -> preferences.remove(KEY_ACCESS_TOKEN) preferences.remove(KEY_REFRESH_TOKEN) } + accessToken = null + refreshToken = null } } + private suspend fun readPreferences(): Preferences = + dataStore.data + .catch { exception -> + if (exception is IOException) emit(emptyPreferences()) else throw exception + }.first() + private companion object { const val PREFERENCES_NAME = "auth_token_preferences" val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index 6b400f35..52bb0168 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -47,10 +47,7 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( if (!BuildConfig.DEBUG) return when (response) { - is ApiResponse.Success -> { - Timber.tag("AuthToken").d("Server accessToken=%s", response.data.accessToken) - Timber.tag("AuthToken").d("Server refreshToken=%s", response.data.refreshToken) - } + is ApiResponse.Success -> Timber.tag("AuthToken").d("서버 인증 응답에 성공했습니다.") is ApiResponse.Failure.HttpError -> { Timber.tag("AuthToken").e(response.throwable, "Server login failed: http error") From f5e02cc6a9548d9d35286614395cd08ce1a9cc36 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 17 Apr 2026 21:16:30 +0900 Subject: [PATCH 05/63] =?UTF-8?q?refactor:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=A1=9C=EA=B9=85=20=EC=A0=95=EC=B1=85=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20AuthTokenStore=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: Ktor HTTP 클라이언트 로깅 설정 변경** * 보안 강화를 위해 `Authorization` 헤더 값이 로그에 노출되지 않도록 `sanitizeHeader` 설정을 추가했습니다. * 디버그 모드에서의 로그 레벨을 `LogLevel.BODY`에서 `LogLevel.HEADERS`로 변경하여 출력 정보의 범위를 조정했습니다. * **refactor: DataStoreAuthTokenStore 초기화 방식 개선** * `AuthTokenStore` 인터페이스에서 불필요한 `initializeCache()` 메서드를 제거했습니다. * `DataStoreAuthTokenStore` 초기화 시 `CoroutineScope.launch`를 통한 비동기 방식 대신 `runBlocking`을 사용하여 인스턴스 생성 시점에 토큰 캐싱이 완료되도록 변경했습니다. * `AtomicBoolean`을 이용한 중복 초기화 방지 로직을 삭제하여 코드를 단순화했습니다. --- .../team/prezel/core/auth/KakaoAuthClient.kt | 154 +++++++++--------- .../core/network/auth/AuthTokenStore.kt | 2 - .../network/auth/DataStoreAuthTokenStore.kt | 20 +-- .../prezel/core/network/di/NetworkModule.kt | 3 +- 4 files changed, 83 insertions(+), 96 deletions(-) diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt index 2f15ebbd..d6da4eb7 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt @@ -15,99 +15,99 @@ import javax.inject.Inject import kotlin.coroutines.resume class KakaoAuthClient -@Inject -constructor() : AuthClient { - override suspend fun login(context: Context): AuthResult = - suspendCancellableCoroutine { continuation -> - if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk(context = context, continuation = continuation) - return@suspendCancellableCoroutine + @Inject + constructor() : AuthClient { + override suspend fun login(context: Context): AuthResult = + suspendCancellableCoroutine { continuation -> + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk(context = context, continuation = continuation) + return@suspendCancellableCoroutine + } + + loginWithKakaoAccount(context = context, continuation = continuation) } - loginWithKakaoAccount(context = context, continuation = continuation) - } + override suspend fun logout(): Result = + suspendCancellableCoroutine { continuation -> + Timber.d("카카오 로그아웃 시도") - override suspend fun logout(): Result = - suspendCancellableCoroutine { continuation -> - Timber.d("카카오 로그아웃 시도") + UserApiClient.instance.logout { error -> + if (error != null) { + Timber.e(error, "카카오 로그아웃에 실패했습니다.") + continuation.resume(Result.failure(error)) + return@logout + } - UserApiClient.instance.logout { error -> - if (error != null) { - Timber.e(error, "카카오 로그아웃에 실패했습니다.") - continuation.resume(Result.failure(error)) - return@logout + Timber.d("카카오 로그아웃에 성공했습니다.") + continuation.resume(Result.success(Unit)) } - - Timber.d("카카오 로그아웃에 성공했습니다.") - continuation.resume(Result.success(Unit)) } - } - private fun loginWithKakaoTalk( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오톡으로 로그인 시도") - UserApiClient.instance.loginWithKakaoTalk( - context = context, - callback = continuation.loginCallback(loginType = "카카오톡"), - ) - } + private fun loginWithKakaoTalk( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오톡으로 로그인 시도") + UserApiClient.instance.loginWithKakaoTalk( + context = context, + callback = continuation.loginCallback(loginType = "카카오톡"), + ) + } - private fun loginWithKakaoAccount( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오 계정으로 로그인 시도") - UserApiClient.instance.loginWithKakaoAccount( - context = context, - callback = continuation.loginCallback(loginType = "카카오 계정"), - ) - } + private fun loginWithKakaoAccount( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오 계정으로 로그인 시도") + UserApiClient.instance.loginWithKakaoAccount( + context = context, + callback = continuation.loginCallback(loginType = "카카오 계정"), + ) + } - private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = - { token, error -> - when { - error != null -> { - val authResult = error.toAuthResult() - Timber.e(error, "$loginType 로그인에 실패했습니다. ($authResult)") - resume(authResult) - } + private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = + { token, error -> + when { + error != null -> { + val authResult = error.toAuthResult() + Timber.e(error, "$loginType 로그인에 실패했습니다. ($authResult)") + resume(authResult) + } - token != null -> { - val idToken = token.idToken - if (idToken.isNullOrBlank()) { - Timber.e("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") - resume(AuthResult.Failure.Unknown) - } else { - if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d("$loginType 로그인에 성공했습니다.") + token != null -> { + val idToken = token.idToken + if (idToken.isNullOrBlank()) { + Timber.e("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") + resume(AuthResult.Failure.Unknown) + } else { + if (BuildConfig.DEBUG) { + Timber.tag("AuthToken").d("$loginType 로그인에 성공했습니다.") + } + resume(AuthResult.Success(idToken = idToken)) } - resume(AuthResult.Success(idToken = idToken)) } - } - else -> { - Timber.e("$loginType 로그인 결과가 비어있습니다.") - resume(AuthResult.Failure.Unknown) + else -> { + Timber.e("$loginType 로그인 결과가 비어있습니다.") + resume(AuthResult.Failure.Unknown) + } } } - } - private fun Throwable.toAuthResult(): AuthResult { - if (this is AuthError) return toAuthErrorResult() - if (this is ClientError) return toClientErrorResult() - return AuthResult.Failure.Unknown - } + private fun Throwable.toAuthResult(): AuthResult { + if (this is AuthError) return toAuthErrorResult() + if (this is ClientError) return toClientErrorResult() + return AuthResult.Failure.Unknown + } - private fun AuthError.toAuthErrorResult(): AuthResult { - if (statusCode == 429) return AuthResult.Failure.RateLimited - if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled - return AuthResult.Failure.Unknown - } + private fun AuthError.toAuthErrorResult(): AuthResult { + if (statusCode == 429) return AuthResult.Failure.RateLimited + if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled + return AuthResult.Failure.Unknown + } - private fun ClientError.toClientErrorResult(): AuthResult { - if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled - return AuthResult.Failure.Unknown + private fun ClientError.toClientErrorResult(): AuthResult { + if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled + return AuthResult.Failure.Unknown + } } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt index 2a3d2c4e..f4746437 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt @@ -5,8 +5,6 @@ interface AuthTokenStore { fun getRefreshToken(): String? - fun initializeCache() - suspend fun saveTokens( accessToken: String, refreshToken: String, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt index 67894db0..4563dbc6 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt @@ -13,11 +13,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.IOException -import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton @@ -39,28 +38,17 @@ internal class DataStoreAuthTokenStore @Inject constructor( private var refreshToken: String? = null private val mutex = Mutex() - private val isCacheInitializationStarted = AtomicBoolean(false) init { - initializeCache() + val preferences = runBlocking { readPreferences() } + accessToken = preferences[KEY_ACCESS_TOKEN] + refreshToken = preferences[KEY_REFRESH_TOKEN] } override fun getAccessToken(): String? = accessToken override fun getRefreshToken(): String? = refreshToken - override fun initializeCache() { - if (!isCacheInitializationStarted.compareAndSet(false, true)) return - - applicationScope.launch { - mutex.withLock { - val preferences = readPreferences() - accessToken = preferences[KEY_ACCESS_TOKEN] - refreshToken = preferences[KEY_REFRESH_TOKEN] - } - } - } - override suspend fun saveTokens( accessToken: String, refreshToken: String, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 5d4d06c6..a686ab18 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -102,7 +102,8 @@ object NetworkModule { Timber.tag("KtorClient").d(message) } } - level = if (BuildConfig.DEBUG) LogLevel.BODY else LogLevel.NONE + sanitizeHeader { header -> header == HttpHeaders.Authorization } + level = if (BuildConfig.DEBUG) LogLevel.HEADERS else LogLevel.NONE } } From d44392956ff94becfea5d8e93689d6d8e17e1638 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 17 Apr 2026 23:25:05 +0900 Subject: [PATCH 06/63] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=ED=99=94=EB=A9=B4=20=ED=9D=90=EB=A6=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: CheckLoginStatusUseCase 추가** * 로컬에 저장된 토큰 상태를 확인하여 로그인 여부를 판단하는 UseCase를 추가했습니다. * Access Token이 없더라도 Refresh Token이 존재하면 토큰 재발급을 시도하여 로그인 상태를 유지하도록 로직을 구현했습니다. * **feat: SplashViewModel 내 로그인 상태에 따른 화면 전환 로직 적용** * `CheckLoginStatusUseCase`를 사용하여 초기 진입 시 사용자 상태를 확인합니다. * 성공적으로 로그인 상태가 확인되면 홈 화면(`NavigateToHome`)으로, 그렇지 않으면 로그인 화면(`NavigateToLogin`)으로 이동하도록 분기 로직을 추가했습니다. * **refactor: AuthRepository 토큰 조회 메서드 추가** * `AuthRepository` 인터페이스 및 `AuthRepositoryImpl`에 `getAccessToken()`, `getRefreshToken()` 메서드를 추가하여 저장된 토큰에 접근할 수 있도록 개선했습니다. * **build: feature:splash:impl 모듈 의존성 추가** * `CheckLoginStatusUseCase` 사용을 위해 `core:domain` 모듈에 대한 의존성을 추가했습니다. --- .../core/data/repository/AuthRepositoryImpl.kt | 4 ++++ .../team/prezel/core/domain/AuthRepository.kt | 4 ++++ .../domain/usecase/CheckLoginStatusUseCase.kt | 18 ++++++++++++++++++ Prezel/feature/splash/impl/build.gradle.kts | 1 + .../feature/splash/impl/SplashViewModel.kt | 11 +++++++++-- 5 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index fbf046cb..142f2d29 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -13,6 +13,10 @@ internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, private val authTokenStore: AuthTokenStore, ) : AuthRepository { + override fun getAccessToken(): String? = authTokenStore.getAccessToken() + + override fun getRefreshToken(): String? = authTokenStore.getRefreshToken() + override suspend fun reissueToken(refreshToken: String): Result = authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt index 3b401f8e..d80fc243 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt @@ -4,6 +4,10 @@ import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason interface AuthRepository { + fun getAccessToken(): String? + + fun getRefreshToken(): String? + suspend fun reissueToken(refreshToken: String): Result suspend fun logout(accessToken: String): Result diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt new file mode 100644 index 00000000..ca797b92 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt @@ -0,0 +1,18 @@ +package com.team.prezel.core.domain.usecase + +import com.team.prezel.core.domain.AuthRepository +import javax.inject.Inject + +class CheckLoginStatusUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(): Boolean { + val accessToken = authRepository.getAccessToken() + if (!accessToken.isNullOrBlank()) return true + + val refreshToken = authRepository.getRefreshToken() + if (refreshToken.isNullOrBlank()) return false + + return authRepository.reissueToken(refreshToken).isSuccess + } +} diff --git a/Prezel/feature/splash/impl/build.gradle.kts b/Prezel/feature/splash/impl/build.gradle.kts index 32a9314c..988400bf 100644 --- a/Prezel/feature/splash/impl/build.gradle.kts +++ b/Prezel/feature/splash/impl/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.coreDomain) implementation(projects.featureSplashApi) implementation(projects.featureLoginApi) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index 87587b4d..262d87f2 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -1,6 +1,7 @@ package com.team.prezel.feature.splash.impl import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.CheckLoginStatusUseCase import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent @@ -10,7 +11,9 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -internal class SplashViewModel @Inject constructor() : +internal class SplashViewModel @Inject constructor( + private val checkLoginStatusUseCase: CheckLoginStatusUseCase, +) : BaseViewModel( SplashUiState(), ) { @@ -25,7 +28,11 @@ internal class SplashViewModel @Inject constructor() : viewModelScope .launch { - sendEffect(SplashUiEffect.NavigateToLogin) + if (checkLoginStatusUseCase()) { + sendEffect(SplashUiEffect.NavigateToHome) + } else { + sendEffect(SplashUiEffect.NavigateToLogin) + } }.invokeOnCompletion { updateState { copy(isLoading = false) } } } } From 5dcda5bed14157864913da4607b4d85e44646992 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Fri, 17 Apr 2026 23:54:47 +0900 Subject: [PATCH 07/63] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=82=B4=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: ProfileViewModel 내 인증 관련 비즈니스 로직 추가** * `LogoutUseCase` 및 `WithdrawUseCase`를 호출하여 로그아웃과 회원 탈퇴 기능을 구현했습니다. * `ProfileUiState`를 로딩 상태를 관리하는 `data class` 구조로 개편하고, 화면 이동을 위한 `ProfileUiEffect`를 추가했습니다. * 작업 성공 시 `NavigateToLogin` 이펙트를 발생시켜 로그인 화면으로 이동하도록 처리했습니다. * **feat: ProfileScreen UI 구현 및 ViewModel 연동** * 로그아웃 및 회원 탈퇴 버튼을 추가하고 클릭 이벤트를 ViewModel에 연결했습니다. * `LaunchedEffect`를 통해 `ProfileUiEffect`를 수집하고, `LocalNavigator`를 사용하여 루트 화면을 로그인으로 전환하는 로직을 추가했습니다. * **refactor: 네트워크 설정 및 상태 모델 정리** * `NetworkModule`: JSON 직렬화 설정에서 불필요한 `coerceInputValues` 옵션을 제거했습니다. * `ProfileUiState`: 기존 `sealed interface` 방식에서 `isLoading` 필드를 가진 `data class` 방식으로 단순화했습니다. * **build: 모듈 의존성 추가** * `feature:profile:impl` 모듈에 `core:domain`, `core:model`, `feature:login:api` 의존성을 추가했습니다. --- .../prezel/core/network/di/NetworkModule.kt | 1 - Prezel/feature/profile/impl/build.gradle.kts | 3 + .../feature/profile/impl/ProfileScreen.kt | 56 +++++++++++++++-- .../feature/profile/impl/ProfileUiEffect.kt | 5 ++ .../feature/profile/impl/ProfileUiState.kt | 8 +-- .../feature/profile/impl/ProfileViewModel.kt | 62 ++++++++++++++++++- 6 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index a686ab18..47388c3f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -35,7 +35,6 @@ object NetworkModule { fun provideJson(): Json = Json { ignoreUnknownKeys = true - coerceInputValues = true encodeDefaults = true prettyPrint = false } diff --git a/Prezel/feature/profile/impl/build.gradle.kts b/Prezel/feature/profile/impl/build.gradle.kts index cff63da9..1b47ecaf 100644 --- a/Prezel/feature/profile/impl/build.gradle.kts +++ b/Prezel/feature/profile/impl/build.gradle.kts @@ -7,5 +7,8 @@ android { } dependencies { + implementation(projects.coreDomain) + implementation(projects.coreModel) + implementation(projects.featureLoginApi) implementation(projects.featureProfileApi) } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 9bb8e26b..a87e53a3 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -1,18 +1,62 @@ package com.team.prezel.feature.profile.impl -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.feature.login.api.LoginNavKey @Composable -fun ProfileScreen(modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, +fun ProfileScreen( + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val navigator = LocalNavigator.current + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + ProfileUiEffect.NavigateToLogin -> navigator.replaceRoot(LoginNavKey) + } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Center, ) { Text("Profile") + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = viewModel::logout, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("로그아웃") + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = viewModel::withdraw, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("회원탈퇴") + } } } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt new file mode 100644 index 00000000..eb115426 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.profile.impl + +sealed interface ProfileUiEffect { + data object NavigateToLogin : ProfileUiEffect +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt index f84d216e..39c545ed 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt @@ -1,7 +1,5 @@ package com.team.prezel.feature.profile.impl -sealed interface ProfileUiState { - data object Loading : ProfileUiState - - data object LoadFailed : ProfileUiState -} +data class ProfileUiState( + val isLoading: Boolean = false, +) diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index ac728c71..db103c18 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -1,10 +1,70 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.usecase.LogoutUseCase +import com.team.prezel.core.domain.usecase.WithdrawUseCase +import com.team.prezel.core.model.auth.WithdrawReason import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class ProfileViewModel @Inject - constructor() : ViewModel() + constructor( + private val authRepository: AuthRepository, + private val logoutUseCase: LogoutUseCase, + private val withdrawUseCase: WithdrawUseCase, + ) : ViewModel() { + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState = _uiState.asStateFlow() + + private val _uiEffect = MutableSharedFlow() + val uiEffect = _uiEffect.asSharedFlow() + + fun logout() { + val accessToken = authRepository.getAccessToken() ?: return + if (_uiState.value.isLoading) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val result = logoutUseCase(accessToken) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + Timber.tag("ProfileTest").d("로그아웃에 성공했습니다.") + _uiEffect.emit(ProfileUiEffect.NavigateToLogin) + } else { + Timber.tag("ProfileTest").e(result.exceptionOrNull(), "로그아웃에 실패했습니다.") + } + } + } + + fun withdraw() { + val accessToken = authRepository.getAccessToken() ?: return + if (_uiState.value.isLoading) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val result = + withdrawUseCase( + accessToken = accessToken, + reason = WithdrawReason.Other("임시 테스트 탈퇴"), + ) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + Timber.tag("ProfileTest").d("회원탈퇴에 성공했습니다.") + _uiEffect.emit(ProfileUiEffect.NavigateToLogin) + } else { + Timber.tag("ProfileTest").e(result.exceptionOrNull(), "회원탈퇴에 실패했습니다.") + } + } + } + } From e6220662a13e894448df213f3d76239c3cbcaede Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 00:12:21 +0900 Subject: [PATCH 08/63] =?UTF-8?q?refactor:=20UseCase=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=ED=9A=8D=EB=93=9D=20=EB=A1=9C=EC=A7=81=20=EC=BA=A1=EC=8A=90?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20UI=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: Logout 및 Withdraw UseCase 내 토큰 처리 로직 개선** * `ProfileViewModel`에서 직접 수행하던 액세스 토큰 조회 로직을 UseCase 내부로 이동하여 캡슐화를 강화했습니다. * `LogoutUseCase`: `invoke()` 시 파라미터 대신 내부적으로 `AuthRepository`를 통해 토큰을 가져오도록 변경했습니다. * `WithdrawUseCase`: `invoke()` 시 `WithdrawReason`만 받도록 변경하고, 내부에서 토큰 유효성 검사 로직을 추가했습니다. * **refactor: ProfileViewModel 리팩터링** * UseCase 변경에 따라 더 이상 필요하지 않은 `AuthRepository` 의존성을 제거했습니다. * 로그아웃 및 회원탈퇴 실패 시 `ProfileUiEffect.ShowSnackbar`를 통해 에러 메시지를 전달하도록 예외 처리를 추가했습니다. * **feat: 프로필 화면 UI 피드백 및 Scaffold 적용** * `ProfileScreen`에 `Scaffold`와 `SnackbarHost`를 도입하여 에러 발생 시 사용자에게 알림을 표시할 수 있도록 개선했습니다. * `ProfileUiEffect`에 `ShowSnackbar` 타입을 추가하여 ViewModel에서 UI로 메시지 전달이 가능하도록 정의했습니다. * 불필요한 "Profile" 텍스트를 제거하고 레이아웃을 정돈했습니다. * **docs: UseCase KDoc 업데이트** * 변경된 토큰 획득 방식(내부 조회)에 맞춰 `LogoutUseCase`와 `WithdrawUseCase`의 주석 내용을 수정했습니다. --- .../core/domain/usecase/LogoutUseCase.kt | 11 +++- .../core/domain/usecase/WithdrawUseCase.kt | 15 +++--- .../feature/profile/impl/ProfileScreen.kt | 50 +++++++++++-------- .../feature/profile/impl/ProfileUiEffect.kt | 4 ++ .../feature/profile/impl/ProfileViewModel.kt | 9 ++-- .../feature/splash/impl/SplashViewModel.kt | 33 ++++++------ 6 files changed, 71 insertions(+), 51 deletions(-) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt index f125ba4e..8ef9e099 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt @@ -7,7 +7,7 @@ import javax.inject.Inject * 현재 로그인 세션의 로그아웃을 요청하는 UseCase. * * ### 동작 흐름 - * 1. 호출부로부터 전달받은 액세스 토큰을 입력값으로 받습니다. + * 1. 저장된 현재 액세스 토큰을 조회합니다. * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출하여 서버에 로그아웃을 요청합니다. * 3. 요청 결과에 따라 성공 여부를 [Result]로 반환합니다. * @@ -15,5 +15,12 @@ import javax.inject.Inject class LogoutUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(accessToken: String): Result = authRepository.logout(accessToken = accessToken) + suspend operator fun invoke(): Result { + val accessToken = + authRepository.getAccessToken() ?: return Result.failure( + IllegalStateException("저장된 access token이 없습니다."), + ) + + return authRepository.logout(accessToken = accessToken) + } } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt index aeb2430c..657c42f2 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt @@ -8,7 +8,7 @@ import javax.inject.Inject * 회원 탈퇴 사유와 함께 탈퇴 요청을 수행하는 UseCase. * * ### 동작 흐름 - * 1. 호출부로부터 전달받은 액세스 토큰과 [WithdrawReason]을 입력값으로 받습니다. + * 1. 저장된 현재 액세스 토큰과 호출부로부터 전달받은 [WithdrawReason]을 입력값으로 사용합니다. * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출하여 서버에 회원 탈퇴를 요청합니다. * 3. 요청 결과에 따라 성공 여부를 [Result]로 반환합니다. * @@ -16,12 +16,15 @@ import javax.inject.Inject class WithdrawUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke( - accessToken: String, - reason: WithdrawReason, - ): Result = - authRepository.withdraw( + suspend operator fun invoke(reason: WithdrawReason): Result { + val accessToken = + authRepository.getAccessToken() ?: return Result.failure( + IllegalStateException("저장된 access token이 없습니다."), + ) + + return authRepository.withdraw( accessToken = accessToken, reason = reason, ) + } } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index a87e53a3..42423c12 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -8,10 +8,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -26,37 +30,43 @@ fun ProfileScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val navigator = LocalNavigator.current + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(viewModel) { viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToLogin -> navigator.replaceRoot(LoginNavKey) + is ProfileUiEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message) } } } - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.Center, + Scaffold( + modifier = modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { - Text("Profile") - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = viewModel::logout, - enabled = !uiState.isLoading, - modifier = Modifier.fillMaxWidth(), + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Center, ) { - Text("로그아웃") - } - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = viewModel::withdraw, - enabled = !uiState.isLoading, - modifier = Modifier.fillMaxWidth(), - ) { - Text("회원탈퇴") + Button( + onClick = viewModel::logout, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("로그아웃") + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = viewModel::withdraw, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("회원탈퇴") + } } } } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt index eb115426..6922e16d 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt @@ -2,4 +2,8 @@ package com.team.prezel.feature.profile.impl sealed interface ProfileUiEffect { data object NavigateToLogin : ProfileUiEffect + + data class ShowSnackbar( + val message: String, + ) : ProfileUiEffect } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index db103c18..723f6738 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -2,7 +2,6 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.team.prezel.core.domain.AuthRepository import com.team.prezel.core.domain.usecase.LogoutUseCase import com.team.prezel.core.domain.usecase.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason @@ -20,7 +19,6 @@ import javax.inject.Inject class ProfileViewModel @Inject constructor( - private val authRepository: AuthRepository, private val logoutUseCase: LogoutUseCase, private val withdrawUseCase: WithdrawUseCase, ) : ViewModel() { @@ -31,31 +29,29 @@ class ProfileViewModel val uiEffect = _uiEffect.asSharedFlow() fun logout() { - val accessToken = authRepository.getAccessToken() ?: return if (_uiState.value.isLoading) return viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val result = logoutUseCase(accessToken) + val result = logoutUseCase() _uiState.update { it.copy(isLoading = false) } if (result.isSuccess) { Timber.tag("ProfileTest").d("로그아웃에 성공했습니다.") _uiEffect.emit(ProfileUiEffect.NavigateToLogin) } else { Timber.tag("ProfileTest").e(result.exceptionOrNull(), "로그아웃에 실패했습니다.") + _uiEffect.emit(ProfileUiEffect.ShowSnackbar("로그아웃에 실패했습니다.")) } } } fun withdraw() { - val accessToken = authRepository.getAccessToken() ?: return if (_uiState.value.isLoading) return viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } val result = withdrawUseCase( - accessToken = accessToken, reason = WithdrawReason.Other("임시 테스트 탈퇴"), ) _uiState.update { it.copy(isLoading = false) } @@ -64,6 +60,7 @@ class ProfileViewModel _uiEffect.emit(ProfileUiEffect.NavigateToLogin) } else { Timber.tag("ProfileTest").e(result.exceptionOrNull(), "회원탈퇴에 실패했습니다.") + _uiEffect.emit(ProfileUiEffect.ShowSnackbar("회원탈퇴에 실패했습니다.")) } } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index 262d87f2..f8b43ca8 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -13,26 +13,25 @@ import javax.inject.Inject @HiltViewModel internal class SplashViewModel @Inject constructor( private val checkLoginStatusUseCase: CheckLoginStatusUseCase, -) : - BaseViewModel( +) : BaseViewModel( SplashUiState(), ) { - override fun onIntent(intent: SplashUiIntent) { - when (intent) { - SplashUiIntent.CheckLoginStatus -> checkLoginStatus() - } + override fun onIntent(intent: SplashUiIntent) { + when (intent) { + SplashUiIntent.CheckLoginStatus -> checkLoginStatus() } + } - private fun checkLoginStatus() { - updateState { copy(isLoading = true) } + private fun checkLoginStatus() { + updateState { copy(isLoading = true) } - viewModelScope - .launch { - if (checkLoginStatusUseCase()) { - sendEffect(SplashUiEffect.NavigateToHome) - } else { - sendEffect(SplashUiEffect.NavigateToLogin) - } - }.invokeOnCompletion { updateState { copy(isLoading = false) } } - } + viewModelScope + .launch { + if (checkLoginStatusUseCase()) { + sendEffect(SplashUiEffect.NavigateToHome) + } else { + sendEffect(SplashUiEffect.NavigateToLogin) + } + }.invokeOnCompletion { updateState { copy(isLoading = false) } } } +} From baf9fcb9d486a752a6c0023792ea793c47f75058 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 01:20:04 +0900 Subject: [PATCH 09/63] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4?= =?UTF-8?q?=20=EC=8B=9C=20=EB=A1=9C=EC=BB=AC=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20AuthManag?= =?UTF-8?q?er=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthManager` 로그아웃 로직 강화** * `currentProvider`가 명시되지 않은 상태더라도 `authClients`에 등록된 공급자가 하나라면 해당 공급자를 통해 로그아웃을 진행할 수 있도록 예외 처리 로직을 개선했습니다. * **refactor: `ProfileViewModel` 내 로그아웃 및 탈퇴 처리 로직 수정** * 서버 측 로그아웃(`logoutUseCase`) 및 탈퇴(`withdrawUseCase`) 요청이 성공한 후, 클라이언트의 로컬 인증 세션을 정리하기 위해 `AuthManager.logout()`을 호출하도록 변경했습니다. * UseCase의 결과를 `fold`로 처리하여 성공 시에만 로컬 인증 해제 로직이 수행되도록 구현했습니다. * **build: `feature:profile:impl` 모듈 의존성 추가** * `AuthManager`를 참조하기 위해 `core:auth` 모듈에 대한 의존성을 추가했습니다. --- .../java/com/team/prezel/core/auth/AuthManager.kt | 7 ++++--- Prezel/feature/profile/impl/build.gradle.kts | 1 + .../prezel/feature/profile/impl/ProfileViewModel.kt | 11 ++++++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt index 3e564130..68dbd090 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt @@ -30,9 +30,10 @@ class AuthManager } suspend fun logout(): Result { - val provider = currentProvider ?: return Result.failure( - IllegalStateException("로그인된 AuthProvider가 없습니다."), - ) + val provider = + currentProvider ?: authClients.keys.singleOrNull() ?: return Result.failure( + IllegalStateException("로그인된 AuthProvider가 없습니다."), + ) val authClient = authClients[provider] ?: return Result.failure( IllegalStateException("해당 AuthProvider에 대한 AuthClient를 찾을 수 없습니다. provider=$provider"), diff --git a/Prezel/feature/profile/impl/build.gradle.kts b/Prezel/feature/profile/impl/build.gradle.kts index 1b47ecaf..4c3cd37e 100644 --- a/Prezel/feature/profile/impl/build.gradle.kts +++ b/Prezel/feature/profile/impl/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.coreAuth) implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.featureLoginApi) diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index 723f6738..f00c4cde 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -2,6 +2,7 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.domain.usecase.LogoutUseCase import com.team.prezel.core.domain.usecase.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason @@ -19,6 +20,7 @@ import javax.inject.Inject class ProfileViewModel @Inject constructor( + private val authManager: AuthManager, private val logoutUseCase: LogoutUseCase, private val withdrawUseCase: WithdrawUseCase, ) : ViewModel() { @@ -33,7 +35,11 @@ class ProfileViewModel viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val result = logoutUseCase() + val result = + logoutUseCase().fold( + onSuccess = { authManager.logout() }, + onFailure = { Result.failure(it) }, + ) _uiState.update { it.copy(isLoading = false) } if (result.isSuccess) { Timber.tag("ProfileTest").d("로그아웃에 성공했습니다.") @@ -53,6 +59,9 @@ class ProfileViewModel val result = withdrawUseCase( reason = WithdrawReason.Other("임시 테스트 탈퇴"), + ).fold( + onSuccess = { authManager.logout() }, + onFailure = { Result.failure(it) }, ) _uiState.update { it.copy(isLoading = false) } if (result.isSuccess) { From 7996d3c9c41bcc41b4fca8bf5d733c702634e0ae Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 11:25:56 +0900 Subject: [PATCH 10/63] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=ED=9B=84=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 로그인 성공 시 홈 화면 내비게이션 추가** * `LoginUiEffect`에 `NavigateToHome` 상태를 추가했습니다. * `LoginScreen` 및 `LoginEntryBuilder`에서 로그인 성공 시 `HomeNavKey`로 루트를 교체하는 로직을 구현했습니다. * **refactor: AuthRepositoryImpl 로그인 및 토큰 저장 로직 개선** * `login` 메서드에서 `ApiResponse` 타입에 따른 명시적인 결과 처리를 수행하도록 수정했습니다. * `ApiResponse.Success`를 직접 처리하여 토큰을 저장하고 도메인 모델(`AuthToken`)로 변환하는 오버로딩 함수를 추가했습니다. * **refactor: ApiResponseConverterFactory 및 네트워크 관련 수정** * `ApiResponseConverterFactory`에서 `ApiResponse.Success` 생성 시 파라미터 이름을 명시적으로 지정하도록 변경했습니다. * `AuthRepositoryImpl`에서 불필요하게 사용되던 기존 `toResult` 확장 함수 의존성을 제거하고 직접 응답 상태를 분기 처리합니다. --- .../data/repository/AuthRepositoryImpl.kt | 24 ++++++++++++++++++- .../network/ApiResponseConverterFactory.kt | 4 +++- .../feature/login/impl/landing/LoginScreen.kt | 3 +++ .../impl/landing/contract/LoginUiEffect.kt | 2 ++ .../impl/navigation/LoginEntryBuilder.kt | 3 +++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 142f2d29..71de7e51 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse import javax.inject.Inject @@ -25,7 +26,12 @@ internal class AuthRepositoryImpl @Inject constructor( authTokenStore.clear() } - override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) + override suspend fun login(idToken: String): Result = + when (val response = authRemoteDataSource.login(idToken = idToken)) { + is ApiResponse.Success -> Result.success(saveTokens(response)) + is ApiResponse.Failure.HttpError -> Result.failure(response.throwable) + is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + } override suspend fun withdraw( accessToken: String, @@ -40,6 +46,16 @@ internal class AuthRepositoryImpl @Inject constructor( authTokenStore.clear() } + private suspend fun saveTokens(response: ApiResponse.Success): AuthToken = + response + .toAuthToken() + .also { token -> + authTokenStore.saveTokens( + accessToken = token.accessToken, + refreshToken = token.refreshToken, + ) + } + private suspend fun saveTokens(response: LoginResponse): AuthToken = response .toAuthToken() @@ -50,6 +66,12 @@ internal class AuthRepositoryImpl @Inject constructor( ) } + private fun ApiResponse.Success.toAuthToken(): AuthToken = + AuthToken( + accessToken = data.accessToken, + refreshToken = data.refreshToken, + ) + private fun LoginResponse.toAuthToken(): AuthToken = AuthToken( accessToken = accessToken, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt index 5569c137..4d4e78f0 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt @@ -36,7 +36,9 @@ class ApiResponseConverterFactory : Converter.Factory { ): ApiResponse = try { val body = response.body(bodyTypeInfo) - ApiResponse.Success(body) + ApiResponse.Success( + data = body, + ) } catch (t: Throwable) { t.rethrowIfCancellation() Timber.e(t, "Response parsing failed") diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index 01a73437..6e60e0b0 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -55,6 +55,7 @@ private const val AUTH_SHARED_ELEMENT_TRANSITION_DELAY = 400 @Composable internal fun SharedTransitionScope.LoginScreen( authManager: AuthManager, + navigateToHome: () -> Unit, navigateToTerms: () -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel(), @@ -78,6 +79,8 @@ internal fun SharedTransitionScope.LoginScreen( } } + LoginUiEffect.NavigateToHome -> navigateToHome() + LoginUiEffect.NavigateToTerms -> navigateToTerms() is LoginUiEffect.ShowMessage -> { diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index cfc5af93..db8c403c 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -9,6 +9,8 @@ internal sealed interface LoginUiEffect : UiEffect { val provider: AuthProvider, ) : LoginUiEffect + data object NavigateToHome : LoginUiEffect + data object NavigateToTerms : LoginUiEffect data class ShowMessage( diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt index 170e2228..27953133 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt @@ -22,6 +22,9 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au with(LocalSharedTransitionScope.current) { LoginScreen( authManager = authManager, + navigateToHome = { + navigator.replaceRoot(HomeNavKey) + }, navigateToTerms = { navigator.navigate(LoginTermsNavKey) }, From 51954aafc9a2b994a14b31db840a48398ffab9e3 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 11:54:39 +0900 Subject: [PATCH 11/63] =?UTF-8?q?refactor:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EA=B4=80=EB=A0=A8=20DI=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20UseCase?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 네트워크 DI 모듈 통합 및 이름 변경** * `AuthNetworkModule`을 삭제하고, `AuthService` 제공 로직을 공통 `NetworkModule`로 통합하여 관리 효율성을 높였습니다. * `AuthTokenModule`을 `TokenStoreModule`로 이름을 변경하여 토큰 저장소 관련 의존성 주입 역할을 명확히 했습니다. * **docs: CheckLoginStatusUseCase KDoc 주석 추가** * `core:domain` 모듈의 `CheckLoginStatusUseCase` 클래스에 로그인 상태 확인 로직의 동작 흐름(액세스 토큰 확인, 리프레시 토큰을 통한 재발급 시도 등)을 설명하는 한글 주석을 추가했습니다. --- .../domain/usecase/CheckLoginStatusUseCase.kt | 10 ++++++++++ .../core/network/di/AuthNetworkModule.kt | 18 ------------------ .../prezel/core/network/di/NetworkModule.kt | 6 ++++++ ...{AuthTokenModule.kt => TokenStoreModule.kt} | 2 +- 4 files changed, 17 insertions(+), 19 deletions(-) delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt rename Prezel/core/network/src/main/java/com/team/prezel/core/network/di/{AuthTokenModule.kt => TokenStoreModule.kt} (91%) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt index ca797b92..e25b5f9a 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt @@ -3,6 +3,16 @@ package com.team.prezel.core.domain.usecase import com.team.prezel.core.domain.AuthRepository import javax.inject.Inject +/** + * 저장된 인증 토큰을 기반으로 현재 로그인 상태를 확인하는 UseCase. + * + * ### 동작 흐름 + * 1. 저장된 액세스 토큰이 존재하면 로그인 상태로 판단하여 `true`를 반환합니다. + * 2. 액세스 토큰이 없으면 저장된 리프레시 토큰 존재 여부를 확인합니다. + * 3. 리프레시 토큰도 없으면 `false`를 반환합니다. + * 4. 리프레시 토큰이 있으면 [AuthRepository.reissueToken]을 호출해 재발급을 시도하고, 성공 여부를 반환합니다. + * + */ class CheckLoginStatusUseCase @Inject constructor( private val authRepository: AuthRepository, ) { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt deleted file mode 100644 index 9f8fb2b5..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthNetworkModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.team.prezel.core.network.di - -import com.team.prezel.core.network.service.AuthService -import com.team.prezel.core.network.service.createAuthService -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import de.jensklingenberg.ktorfit.Ktorfit -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object AuthNetworkModule { - @Provides - @Singleton - fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 47388c3f..d2c730bf 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -4,6 +4,8 @@ import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.auth.TokenRefreshAuthenticator +import com.team.prezel.core.network.service.AuthService +import com.team.prezel.core.network.service.createAuthService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -88,6 +90,10 @@ object NetworkModule { .converterFactories(ApiResponseConverterFactory()) .build() + @Provides + @Singleton + internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() + private fun HttpClientConfig<*>.configureBaseClient(json: Json) { expectSuccess = true diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/TokenStoreModule.kt similarity index 91% rename from Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenModule.kt rename to Prezel/core/network/src/main/java/com/team/prezel/core/network/di/TokenStoreModule.kt index edc3043a..f509514a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/TokenStoreModule.kt @@ -10,7 +10,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal abstract class AuthTokenModule { +internal abstract class TokenStoreModule { @Binds @Singleton abstract fun bindAuthTokenStore(impl: DataStoreAuthTokenStore): AuthTokenStore From f51f1629b4222bfd4fd1806b297cf983230d49c9 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 12:10:50 +0900 Subject: [PATCH 12/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=A0=95=EC=B1=85=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20AuthRepository=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: `AuthPathPolicy` 싱글톤 객체 도입 인증이 필요한 API 경로 판별 로직을 별도의 객체로 분리하여 코드 중복을 제거하고 관리를 중앙화했습니다. * `AuthPathPolicy`: `/auth/login`, `/auth/reissue` 경로를 제외한 요청에 대해 인증 필요 여부(`requiresAuthorization`)를 판단하는 로직 추가 * `NetworkModule` 및 `TokenRefreshAuthenticator`에서 개별적으로 구현되어 있던 경로 확인 로직을 `AuthPathPolicy`로 교체 * refactor: `AuthRepositoryImpl` 코드 정리 및 데이터 변환 로직 개선 데이터 레이어의 가독성을 높이고 불필요한 코드를 정리했습니다. * `login`: `toResult` 확장 함수를 사용하여 응답 처리 로직을 간결하게 변경 * `saveTokens`: `ApiResponse.Success` 래퍼 대신 내부 데이터 모델(`LoginResponse`)을 직접 받도록 수정 * `WithdrawReason` 매핑: 기존 커스텀 Getter 형식을 `toCategory()`, `toReasonText()` 메서드로 변경하여 도메인 모델 변환 로직 명확화 * 불필요한 `ApiResponse` 및 관련 확장 함수(`toAuthToken`) 정리 --- .../data/repository/AuthRepositoryImpl.kt | 35 ++++--------------- Prezel/core/domain/.gitignore | 2 +- .../core/network/auth/AuthPathPolicy.kt | 9 +++++ .../network/auth/TokenRefreshAuthenticator.kt | 9 +---- .../prezel/core/network/di/NetworkModule.kt | 5 ++- 5 files changed, 20 insertions(+), 40 deletions(-) create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 71de7e51..34505732 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -6,7 +6,6 @@ import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.datasource.AuthRemoteDataSource -import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse import javax.inject.Inject @@ -27,11 +26,7 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun login(idToken: String): Result = - when (val response = authRemoteDataSource.login(idToken = idToken)) { - is ApiResponse.Success -> Result.success(saveTokens(response)) - is ApiResponse.Failure.HttpError -> Result.failure(response.throwable) - is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) - } + authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) override suspend fun withdraw( accessToken: String, @@ -40,22 +35,12 @@ internal class AuthRepositoryImpl @Inject constructor( authRemoteDataSource .withdraw( accessToken = accessToken, - reasonCategory = reason.category, - reasonText = reason.reasonText, + reasonCategory = reason.toCategory(), + reasonText = reason.toReasonText(), ).toResult { authTokenStore.clear() } - private suspend fun saveTokens(response: ApiResponse.Success): AuthToken = - response - .toAuthToken() - .also { token -> - authTokenStore.saveTokens( - accessToken = token.accessToken, - refreshToken = token.refreshToken, - ) - } - private suspend fun saveTokens(response: LoginResponse): AuthToken = response .toAuthToken() @@ -66,20 +51,14 @@ internal class AuthRepositoryImpl @Inject constructor( ) } - private fun ApiResponse.Success.toAuthToken(): AuthToken = - AuthToken( - accessToken = data.accessToken, - refreshToken = data.refreshToken, - ) - private fun LoginResponse.toAuthToken(): AuthToken = AuthToken( accessToken = accessToken, refreshToken = refreshToken, ) - private val WithdrawReason.category: String - get() = when (this) { + private fun WithdrawReason.toCategory(): String = + when (this) { WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" WithdrawReason.TooDifficultOrComplex -> "TOO_DIFFICULT_OR_COMPLEX" @@ -88,8 +67,8 @@ internal class AuthRepositoryImpl @Inject constructor( is WithdrawReason.Other -> "OTHER" } - private val WithdrawReason.reasonText: String - get() = when (this) { + private fun WithdrawReason.toReasonText(): String = + when (this) { is WithdrawReason.Other -> text else -> "" } diff --git a/Prezel/core/domain/.gitignore b/Prezel/core/domain/.gitignore index 42afabfd..796b96d1 100644 --- a/Prezel/core/domain/.gitignore +++ b/Prezel/core/domain/.gitignore @@ -1 +1 @@ -/build \ No newline at end of file +/build diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt new file mode 100644 index 00000000..ed4ce987 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.network.auth + +internal object AuthPathPolicy { + private const val LOGIN_PATH = "/auth/login" + private const val REISSUE_PATH = "/auth/reissue" + + fun requiresAuthorization(encodedPath: String): Boolean = + !encodedPath.endsWith(LOGIN_PATH) && !encodedPath.endsWith(REISSUE_PATH) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt index 7dd2797e..833d9f21 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt @@ -17,9 +17,7 @@ class TokenRefreshAuthenticator @Inject constructor( route: Route?, response: Response, ): Request? { - if (!response.request.url.encodedPath - .requiresAuthorization() - ) { + if (!AuthPathPolicy.requiresAuthorization(response.request.url.encodedPath)) { return null } if (responseCount(response) >= MAX_AUTH_RETRY_COUNT) return null @@ -34,8 +32,6 @@ class TokenRefreshAuthenticator @Inject constructor( .build() } - private fun String.requiresAuthorization(): Boolean = this != LOGIN_PATH && this != REISSUE_PATH - private fun responseCount(response: Response): Int { var count = 1 var current = response.priorResponse @@ -45,10 +41,7 @@ class TokenRefreshAuthenticator @Inject constructor( } return count } - private companion object { - const val LOGIN_PATH = "/auth/login" - const val REISSUE_PATH = "/auth/reissue" const val MAX_AUTH_RETRY_COUNT = 2 } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index d2c730bf..c54dbe88 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -2,6 +2,7 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.auth.AuthPathPolicy import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.auth.TokenRefreshAuthenticator import com.team.prezel.core.network.service.AuthService @@ -72,7 +73,7 @@ object NetworkModule { defaultRequest { contentType(ContentType.Application.Json) - if (headers[HttpHeaders.Authorization] == null && url.encodedPath.requiresAuthorization()) { + if (headers[HttpHeaders.Authorization] == null && AuthPathPolicy.requiresAuthorization(url.encodedPath)) { authTokenStore.getAccessToken()?.let { accessToken -> headers.append(HttpHeaders.Authorization, "Bearer $accessToken") } @@ -111,6 +112,4 @@ object NetworkModule { level = if (BuildConfig.DEBUG) LogLevel.HEADERS else LogLevel.NONE } } - - private fun String.requiresAuthorization(): Boolean = this != "/auth/login" && this != "/auth/reissue" } From 0e239317a822cdd6f9391d8fcacfcab16e074dff Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 12:20:15 +0900 Subject: [PATCH 13/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=9E=AC=EC=A0=95=EC=9D=98=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: Auth 관련 인터페이스 및 UseCase 패키지 경로 변경** * 도메인 모델의 응집도를 높이기 위해 인증 관련 파일들을 세부 패키지로 이동했습니다. * `AuthRepository`: `core.domain` -> `core.domain.repository.auth` * `LogoutUseCase`, `ReissueTokenUseCase`, `WithdrawUseCase`, `CheckLoginStatusUseCase`, `LoginUseCase`: `core.domain.usecase` -> `core.domain.usecase.auth` * 패키지 이동에 따른 `RepositoryModule` 및 각 기능별(Splash, Login, Profile) `ViewModel` 내 참조 경로를 수정했습니다. * **refactor: AuthTokenRefresher 예외 처리 및 안정성 강화** * 토큰 재발급 중 코루틴이 취소될 경우 `CancellationException`을 명시적으로 다시 던지도록 개선했습니다. * 일반적인 `Exception` 발생 시에만 `isInvalidRefreshToken()` 여부를 확인하여 토큰 저장소를 정리하고 로그를 기록하도록 변경했습니다. * **docs: AuthTokenStore KDoc 주석 추가** * `AuthTokenStore` 인터페이스에 메모리 캐시 동기 조회 및 영속성 저장소 갱신에 관한 동작 계약을 설명하는 주석을 추가했습니다. * **style: 코드 포맷팅 정리** * `AuthRepositoryImpl`, `AuthPathPolicy`, `TokenRefreshAuthenticator` 등 일부 클래스의 줄바꿈 및 불필요한 공백을 수정했습니다. --- .../team/prezel/core/data/di/RepositoryModule.kt | 2 +- .../core/data/repository/AuthRepositoryImpl.kt | 5 ++--- .../domain/{ => repository/auth}/AuthRepository.kt | 2 +- .../usecase/{ => auth}/CheckLoginStatusUseCase.kt | 6 +++--- .../core/domain/usecase/{ => auth}/LoginUseCase.kt | 4 ++-- .../core/domain/usecase/{ => auth}/LogoutUseCase.kt | 4 ++-- .../usecase/{ => auth}/ReissueTokenUseCase.kt | 4 ++-- .../domain/usecase/{ => auth}/WithdrawUseCase.kt | 4 ++-- .../team/prezel/core/network/auth/AuthPathPolicy.kt | 3 +-- .../prezel/core/network/auth/AuthTokenRefresher.kt | 9 ++++++--- .../team/prezel/core/network/auth/AuthTokenStore.kt | 13 +++++++++++++ .../core/network/auth/TokenRefreshAuthenticator.kt | 1 + .../feature/login/impl/landing/LoginViewModel.kt | 2 +- .../prezel/feature/profile/impl/ProfileViewModel.kt | 4 ++-- .../prezel/feature/splash/impl/SplashViewModel.kt | 2 +- 15 files changed, 40 insertions(+), 25 deletions(-) rename Prezel/core/domain/src/main/java/com/team/prezel/core/domain/{ => repository/auth}/AuthRepository.kt (90%) rename Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/{ => auth}/CheckLoginStatusUseCase.kt (74%) rename Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/{ => auth}/LoginUseCase.kt (86%) rename Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/{ => auth}/LogoutUseCase.kt (87%) rename Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/{ => auth}/ReissueTokenUseCase.kt (87%) rename Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/{ => auth}/WithdrawUseCase.kt (89%) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index 189528eb..590940a4 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,7 +1,7 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.repository.AuthRepositoryImpl -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 34505732..8d621e3a 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -1,7 +1,7 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.toResult -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.auth.AuthTokenStore @@ -25,8 +25,7 @@ internal class AuthRepositoryImpl @Inject constructor( authTokenStore.clear() } - override suspend fun login(idToken: String): Result = - authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) + override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) override suspend fun withdraw( accessToken: String, diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt similarity index 90% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt rename to Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index d80fc243..ce5fb527 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -1,4 +1,4 @@ -package com.team.prezel.core.domain +package com.team.prezel.core.domain.repository.auth import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt similarity index 74% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt rename to Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt index e25b5f9a..f1a02b05 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -1,6 +1,6 @@ -package com.team.prezel.core.domain.usecase +package com.team.prezel.core.domain.usecase.auth -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import javax.inject.Inject /** @@ -10,7 +10,7 @@ import javax.inject.Inject * 1. 저장된 액세스 토큰이 존재하면 로그인 상태로 판단하여 `true`를 반환합니다. * 2. 액세스 토큰이 없으면 저장된 리프레시 토큰 존재 여부를 확인합니다. * 3. 리프레시 토큰도 없으면 `false`를 반환합니다. - * 4. 리프레시 토큰이 있으면 [AuthRepository.reissueToken]을 호출해 재발급을 시도하고, 성공 여부를 반환합니다. + * 4. 리프레시 토큰이 있으면 [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출해 재발급을 시도하고, 성공 여부를 반환합니다. * */ class CheckLoginStatusUseCase @Inject constructor( diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LoginUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt similarity index 86% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LoginUseCase.kt rename to Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt index 40aff91d..9d6e1a52 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LoginUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt @@ -1,6 +1,6 @@ -package com.team.prezel.core.domain.usecase +package com.team.prezel.core.domain.usecase.auth -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.model.auth.AuthToken import javax.inject.Inject diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt similarity index 87% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt rename to Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt index 8ef9e099..c8c39c38 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/LogoutUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt @@ -1,6 +1,6 @@ -package com.team.prezel.core.domain.usecase +package com.team.prezel.core.domain.usecase.auth -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import javax.inject.Inject /** diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/ReissueTokenUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/ReissueTokenUseCase.kt similarity index 87% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/ReissueTokenUseCase.kt rename to Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/ReissueTokenUseCase.kt index 253a5171..eebe1ce1 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/ReissueTokenUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/ReissueTokenUseCase.kt @@ -1,6 +1,6 @@ -package com.team.prezel.core.domain.usecase +package com.team.prezel.core.domain.usecase.auth -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.model.auth.AuthToken import javax.inject.Inject diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt similarity index 89% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt rename to Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt index 657c42f2..f496e5c3 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/WithdrawUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt @@ -1,6 +1,6 @@ -package com.team.prezel.core.domain.usecase +package com.team.prezel.core.domain.usecase.auth -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.model.auth.WithdrawReason import javax.inject.Inject diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt index ed4ce987..fc7901a1 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt @@ -4,6 +4,5 @@ internal object AuthPathPolicy { private const val LOGIN_PATH = "/auth/login" private const val REISSUE_PATH = "/auth/reissue" - fun requiresAuthorization(encodedPath: String): Boolean = - !encodedPath.endsWith(LOGIN_PATH) && !encodedPath.endsWith(REISSUE_PATH) + fun requiresAuthorization(encodedPath: String): Boolean = !encodedPath.endsWith(LOGIN_PATH) && !encodedPath.endsWith(REISSUE_PATH) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index ac6b49c8..fc26eb4f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -11,6 +11,7 @@ import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -46,11 +47,13 @@ class AuthTokenRefresher @Inject constructor( Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") } response.accessToken - } catch (throwable: Throwable) { - if (throwable.isInvalidRefreshToken()) { + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + if (exception.isInvalidRefreshToken()) { authTokenStore.clear() } - Timber.e(throwable, "토큰 재발급에 실패했습니다.") + Timber.e(exception, "토큰 재발급에 실패했습니다.") null } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt index f4746437..bf53c2a9 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt @@ -1,5 +1,18 @@ package com.team.prezel.core.network.auth +/** + * 인증 토큰의 현재 값을 동기 조회하고, 영속 저장소와의 동기화를 비동기로 처리하는 저장소 계약입니다. + * + * ### 동기 조회 계약 + * - [getAccessToken], [getRefreshToken]은 메모리 캐시의 현재 값을 즉시 반환해야 합니다. + * - 호출 시점에 디스크 I/O나 네트워크 I/O를 유발하지 않아야 합니다. + * - 따라서 OkHttp `Authenticator`와 같이 네트워크 스레드에서 호출되는 환경에서도 블로킹 없이 동작해야 합니다. + * + * ### 갱신 계약 + * - [saveTokens], [clear]는 영속 저장소 반영과 메모리 캐시 갱신을 함께 수행합니다. + * - 두 함수가 정상적으로 반환된 직후에는 동기 getter가 항상 최신 값을 반환해야 합니다. + * + */ interface AuthTokenStore { fun getAccessToken(): String? diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt index 833d9f21..79274570 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt @@ -41,6 +41,7 @@ class TokenRefreshAuthenticator @Inject constructor( } return count } + private companion object { const val MAX_AUTH_RETRY_COUNT = 2 } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 315c5de8..b0119f41 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -3,7 +3,7 @@ package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult -import com.team.prezel.core.domain.AuthRepository +import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index f00c4cde..389b944b 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -3,8 +3,8 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.AuthManager -import com.team.prezel.core.domain.usecase.LogoutUseCase -import com.team.prezel.core.domain.usecase.WithdrawUseCase +import com.team.prezel.core.domain.usecase.auth.LogoutUseCase +import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index f8b43ca8..116c0403 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -1,7 +1,7 @@ package com.team.prezel.feature.splash.impl import androidx.lifecycle.viewModelScope -import com.team.prezel.core.domain.usecase.CheckLoginStatusUseCase +import com.team.prezel.core.domain.usecase.auth.CheckLoginStatusUseCase import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent From 5830a2e7b0ff8ca8523355907e6791d7d44b2457 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 14:09:03 +0900 Subject: [PATCH 14/63] =?UTF-8?q?feat:=20API=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=82=B4=20=EC=9D=B8=EC=A6=9D=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 공통 API 에러 응답 모델 및 예외 클래스 추가** * 서버 응답 규격에 맞춘 `ApiErrorResponse` 모델을 `core:network`에 추가했습니다. * HTTP 에러 발생 시 상태 코드와 서비스 에러 코드를 포함하는 `ApiHttpException` 도메인 예외 클래스를 정의했습니다. * `ApiResponseConverterFactory`에서 HTTP 에러 응답 바디를 파싱하여 `ApiResponse.Failure.HttpError`에 포함하도록 개선했습니다. * `ApiResponse.toResult` 확장 함수에서 `ResponseException`을 `ApiHttpException`으로 변환하여 반환하도록 수정했습니다. * **refactor: AuthTokenRefresher 로직 개선** * `AuthTokenRefresher`가 직접 `HttpClient`를 사용하지 않고 `AuthRemoteDataSource`를 의존하도록 변경했습니다. * 토큰 재발급 실패 시, 에러 코드(`T001`, `U003`)를 확인하여 복구 불가능한 세션인 경우에만 로컬 토큰을 삭제하도록 로직을 정교화했습니다. * **feat: 프로필(Profile) 기능 내 인증 만료 및 에러 처리 구현** * 로그아웃 및 회원 탈퇴 실패 시, 인증 만료 코드(`U001`) 여부에 따라 로그인 화면으로 강제 이동시키고 안내 메시지를 표시하는 로직을 추가했습니다. * `ProfileUiState`, `ProfileUiEffect`를 `contract` 패키지로 이동하고 `UiState`, `UiEffect` 인터페이스를 상속받도록 리팩터링했습니다. * `ProfileUiMessage` 열거형과 관련 문자열 리소스(`logout_failed`, `withdraw_failed`, `authentication_expired`)를 추가했습니다. * **refactor: 로그인(Login) UI 메시지 네이밍 컨벤션 적용** * `LoginUiMessage`의 상수 이름을 대문자 스네이크 케이스(`LOGIN_CANCELLED` 등)로 변경하여 통일성을 높였습니다. * **misc: 기타 설정 변경** * `AndroidManifest.xml`에 `usesCleartextTraffic="true"` 설정을 추가했습니다. * `AuthManager`에 현재 인증 프로바이더 정보를 초기화하는 `clearCurrentProvider()` 메서드를 추가했습니다. * 루트 `build.gradle.kts`에서 불필요한 `kotlin.jvm` 플러그인 설정을 제거했습니다. --- Prezel/app/src/main/AndroidManifest.xml | 1 + Prezel/build.gradle.kts | 1 - .../com/team/prezel/core/auth/AuthManager.kt | 4 ++ .../team/prezel/core/data/ApiResponseExt.kt | 11 ++- .../core/domain/error/ApiHttpException.kt | 8 +++ .../network/ApiResponseConverterFactory.kt | 22 +++++- .../core/network/auth/AuthTokenRefresher.kt | 69 +++++++++---------- .../core/network/model/ApiErrorResponse.kt | 12 ++++ .../prezel/core/network/model/ApiResponse.kt | 1 + .../feature/login/impl/landing/LoginScreen.kt | 6 +- .../login/impl/landing/LoginViewModel.kt | 8 +-- .../impl/landing/model/LoginUiMessage.kt | 6 +- .../feature/profile/impl/ProfileScreen.kt | 15 +++- .../feature/profile/impl/ProfileUiEffect.kt | 9 --- .../feature/profile/impl/ProfileUiState.kt | 5 -- .../feature/profile/impl/ProfileViewModel.kt | 68 +++++++++++------- .../profile/impl/contract/ProfileUiEffect.kt | 12 ++++ .../profile/impl/contract/ProfileUiState.kt | 7 ++ .../profile/impl/model/ProfileUiMessage.kt | 7 ++ .../impl/src/main/res/values/strings.xml | 6 ++ 20 files changed, 189 insertions(+), 89 deletions(-) create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt delete mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt delete mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt create mode 100644 Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt create mode 100644 Prezel/feature/profile/impl/src/main/res/values/strings.xml diff --git a/Prezel/app/src/main/AndroidManifest.xml b/Prezel/app/src/main/AndroidManifest.xml index 880e3bc2..3fa06fa9 100644 --- a/Prezel/app/src/main/AndroidManifest.xml +++ b/Prezel/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:usesCleartextTraffic="true" android:theme="@style/Theme.PrezelSplashScreen"> ApiResponse.toResult(crossinline transform: suspend (T) -> R): Result = when (this) { is ApiResponse.Success -> Result.success(transform(data)) - is ApiResponse.Failure.HttpError -> Result.failure(throwable) + is ApiResponse.Failure.HttpError -> + Result.failure( + ApiHttpException( + status = error?.status, + code = error?.code, + message = error?.message, + cause = throwable, + ), + ) is ApiResponse.Failure.NetworkError -> Result.failure(throwable) } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt new file mode 100644 index 00000000..9d3bfbc8 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt @@ -0,0 +1,8 @@ +package com.team.prezel.core.domain.error + +class ApiHttpException( + val status: Int?, + val code: String?, + override val message: String?, + cause: Throwable, +) : RuntimeException(message ?: cause.message, cause) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt index 4d4e78f0..41c3bdfd 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt @@ -1,5 +1,6 @@ package com.team.prezel.core.network +import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.ApiResponse import de.jensklingenberg.ktorfit.Ktorfit import de.jensklingenberg.ktorfit.converter.Converter @@ -8,12 +9,21 @@ import de.jensklingenberg.ktorfit.converter.TypeData import io.ktor.client.call.body import io.ktor.client.plugins.ResponseException import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText import io.ktor.util.reflect.TypeInfo +import kotlinx.serialization.json.Json import timber.log.Timber import java.io.IOException import kotlin.coroutines.cancellation.CancellationException class ApiResponseConverterFactory : Converter.Factory { + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = false + } + override fun suspendResponseConverter( typeData: TypeData, ktorfit: Ktorfit, @@ -45,7 +55,7 @@ class ApiResponseConverterFactory : Converter.Factory { ApiResponse.Failure.NetworkError(t) } - private fun mapFailure(t: Throwable): ApiResponse { + private suspend fun mapFailure(t: Throwable): ApiResponse { t.rethrowIfCancellation() return when (t) { @@ -56,7 +66,10 @@ class ApiResponseConverterFactory : Converter.Factory { is ResponseException -> { Timber.e(t, "HTTP error ${t.response.status.value}") - ApiResponse.Failure.HttpError(t) + ApiResponse.Failure.HttpError( + error = parseErrorResponse(t), + throwable = t, + ) } else -> { @@ -66,6 +79,11 @@ class ApiResponseConverterFactory : Converter.Factory { } } + private suspend fun parseErrorResponse(exception: ResponseException): ApiErrorResponse? = + runCatching { + json.decodeFromString(exception.response.bodyAsText()) + }.getOrNull() + private fun Throwable.rethrowIfCancellation() { if (this is CancellationException) throw this } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index fc26eb4f..3dc96f1c 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -1,28 +1,18 @@ package com.team.prezel.core.network.auth import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.network.model.auth.LoginResponse -import com.team.prezel.core.network.model.auth.ReissueTokenRequest -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.plugins.ResponseException -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import kotlinx.coroutines.CancellationException +import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import com.team.prezel.core.network.model.ApiErrorResponse +import com.team.prezel.core.network.model.ApiResponse import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject -import javax.inject.Named import javax.inject.Singleton @Singleton class AuthTokenRefresher @Inject constructor( - @param:Named("refresh") - private val refreshHttpClient: HttpClient, + private val authRemoteDataSource: AuthRemoteDataSource, private val authTokenStore: AuthTokenStore, ) { private val mutex = Mutex() @@ -31,32 +21,37 @@ class AuthTokenRefresher @Inject constructor( mutex.withLock { val refreshToken = authTokenStore.getRefreshToken() ?: return@withLock null - try { - val response = - refreshHttpClient - .post("${BuildConfig.BASE_URL}auth/reissue") { - contentType(ContentType.Application.Json) - setBody(ReissueTokenRequest(refreshToken = refreshToken)) - }.body() - - authTokenStore.saveTokens( - accessToken = response.accessToken, - refreshToken = response.refreshToken, - ) - if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") + when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { + is ApiResponse.Success -> { + authTokenStore.saveTokens( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken, + ) + if (BuildConfig.DEBUG) { + Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") + } + response.data.accessToken } - response.accessToken - } catch (exception: CancellationException) { - throw exception - } catch (exception: Exception) { - if (exception.isInvalidRefreshToken()) { - authTokenStore.clear() + + is ApiResponse.Failure.HttpError -> { + if (response.error.isSessionRecoveryUnrecoverable()) { + authTokenStore.clear() + } + Timber.e(response.throwable, "토큰 재발급에 실패했습니다.") + null + } + + is ApiResponse.Failure.NetworkError -> { + Timber.e(response.throwable, "토큰 재발급에 실패했습니다.") + null } - Timber.e(exception, "토큰 재발급에 실패했습니다.") - null } } - private fun Throwable.isInvalidRefreshToken(): Boolean = this is ResponseException && response.status == HttpStatusCode.Unauthorized + private fun ApiErrorResponse?.isSessionRecoveryUnrecoverable(): Boolean = this?.code == TOKEN_INVALID_CODE || this?.code == USER_NOT_FOUND_CODE + + private companion object { + const val TOKEN_INVALID_CODE = "T001" + const val USER_NOT_FOUND_CODE = "U003" + } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt new file mode 100644 index 00000000..54ddaa59 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class ApiErrorResponse( + val status: Int, + val code: String, + val data: JsonElement? = null, + val message: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt index 83d44a66..eee2a1a5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt @@ -7,6 +7,7 @@ sealed interface ApiResponse { sealed interface Failure : ApiResponse { data class HttpError( + val error: ApiErrorResponse?, val throwable: Throwable, ) : Failure diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index 6e60e0b0..6f62641e 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -85,9 +85,9 @@ internal fun SharedTransitionScope.LoginScreen( is LoginUiEffect.ShowMessage -> { val resId = when (effect.message) { - LoginUiMessage.LoginCancelled -> R.string.feature_login_impl_kakao_cancelled - LoginUiMessage.LoginFailedRateLimited -> R.string.feature_login_impl_kakao_rate_limited - LoginUiMessage.LoginFailedUnknown -> R.string.feature_login_impl_kakao_failure + LoginUiMessage.LOGIN_CANCELLED -> R.string.feature_login_impl_kakao_cancelled + LoginUiMessage.LOGIN_FAILED_RATE_LIMITED -> R.string.feature_login_impl_kakao_rate_limited + LoginUiMessage.LOGIN_FAILED_UNKNOWN -> R.string.feature_login_impl_kakao_failure } snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index b0119f41..312ece4b 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -44,7 +44,7 @@ internal class LoginViewModel @Inject constructor( is AuthResult.Success -> handleServerLogin(idToken = result.idToken) AuthResult.Cancelled -> { updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) + sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_CANCELLED)) } is AuthResult.Failure -> { @@ -66,14 +66,14 @@ internal class LoginViewModel @Inject constructor( }, onFailure = { updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginFailedUnknown)) + sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) }, ) } private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = when (this) { - AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited - AuthResult.Failure.Unknown -> LoginUiMessage.LoginFailedUnknown + AuthResult.Failure.RateLimited -> LoginUiMessage.LOGIN_FAILED_RATE_LIMITED + AuthResult.Failure.Unknown -> LoginUiMessage.LOGIN_FAILED_UNKNOWN } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt index f9b4149c..8e95aaf3 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt @@ -1,7 +1,7 @@ package com.team.prezel.feature.login.impl.landing.model internal enum class LoginUiMessage { - LoginCancelled, - LoginFailedRateLimited, - LoginFailedUnknown, + LOGIN_CANCELLED, + LOGIN_FAILED_RATE_LIMITED, + LOGIN_FAILED_UNKNOWN, } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 42423c12..0bb7109d 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -17,11 +17,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.login.api.LoginNavKey +import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect +import com.team.prezel.feature.profile.impl.model.ProfileUiMessage @Composable fun ProfileScreen( @@ -30,13 +34,22 @@ fun ProfileScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val navigator = LocalNavigator.current + val resources = LocalResources.current val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(viewModel) { viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToLogin -> navigator.replaceRoot(LoginNavKey) - is ProfileUiEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message) + is ProfileUiEffect.ShowMessage -> { + val resId = when (effect.message) { + ProfileUiMessage.AUTHENTICATION_EXPIRED -> R.string.feature_profile_impl_authentication_expired + ProfileUiMessage.LOGOUT_FAILED -> R.string.feature_profile_impl_logout_failed + ProfileUiMessage.WITHDRAW_FAILED -> R.string.feature_profile_impl_withdraw_failed + } + + snackbarHostState.showPrezelSnackbar(resources.getString(resId)) + } } } } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt deleted file mode 100644 index 6922e16d..00000000 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiEffect.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.team.prezel.feature.profile.impl - -sealed interface ProfileUiEffect { - data object NavigateToLogin : ProfileUiEffect - - data class ShowSnackbar( - val message: String, - ) : ProfileUiEffect -} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt deleted file mode 100644 index 39c545ed..00000000 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileUiState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.feature.profile.impl - -data class ProfileUiState( - val isLoading: Boolean = false, -) diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index 389b944b..42f8f83b 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -3,9 +3,13 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.AuthManager +import com.team.prezel.core.domain.error.ApiHttpException import com.team.prezel.core.domain.usecase.auth.LogoutUseCase import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect +import com.team.prezel.feature.profile.impl.contract.ProfileUiState +import com.team.prezel.feature.profile.impl.model.ProfileUiMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -35,19 +39,13 @@ class ProfileViewModel viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val result = - logoutUseCase().fold( - onSuccess = { authManager.logout() }, - onFailure = { Result.failure(it) }, - ) + val result = logoutUseCase().fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) _uiState.update { it.copy(isLoading = false) } - if (result.isSuccess) { - Timber.tag("ProfileTest").d("로그아웃에 성공했습니다.") - _uiEffect.emit(ProfileUiEffect.NavigateToLogin) - } else { - Timber.tag("ProfileTest").e(result.exceptionOrNull(), "로그아웃에 실패했습니다.") - _uiEffect.emit(ProfileUiEffect.ShowSnackbar("로그아웃에 실패했습니다.")) - } + handleAuthActionResult( + result = result, + failureLog = "로그아웃에 실패했습니다.", + failureMessage = ProfileUiMessage.LOGOUT_FAILED, + ) } } @@ -59,18 +57,42 @@ class ProfileViewModel val result = withdrawUseCase( reason = WithdrawReason.Other("임시 테스트 탈퇴"), - ).fold( - onSuccess = { authManager.logout() }, - onFailure = { Result.failure(it) }, - ) + ).fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) _uiState.update { it.copy(isLoading = false) } - if (result.isSuccess) { - Timber.tag("ProfileTest").d("회원탈퇴에 성공했습니다.") - _uiEffect.emit(ProfileUiEffect.NavigateToLogin) - } else { - Timber.tag("ProfileTest").e(result.exceptionOrNull(), "회원탈퇴에 실패했습니다.") - _uiEffect.emit(ProfileUiEffect.ShowSnackbar("회원탈퇴에 실패했습니다.")) - } + handleAuthActionResult( + result = result, + failureLog = "회원탈퇴에 실패했습니다.", + failureMessage = ProfileUiMessage.WITHDRAW_FAILED, + ) + } + } + + private suspend fun handleAuthActionResult( + result: Result, + failureLog: String, + failureMessage: ProfileUiMessage, + ) { + if (result.isSuccess) { + _uiEffect.emit(ProfileUiEffect.NavigateToLogin) + return } + + val exception = result.exceptionOrNull() + Timber.tag("ProfileTest").e(exception, failureLog) + + if (exception.isAuthenticationRequired()) { + authManager.clearCurrentProvider() + _uiEffect.emit(ProfileUiEffect.NavigateToLogin) + _uiEffect.emit(ProfileUiEffect.ShowMessage(ProfileUiMessage.AUTHENTICATION_EXPIRED)) + return + } + + _uiEffect.emit(ProfileUiEffect.ShowMessage(failureMessage)) + } + + private fun Throwable?.isAuthenticationRequired(): Boolean = (this as? ApiHttpException)?.code == AUTHENTICATION_REQUIRED_CODE + + private companion object { + const val AUTHENTICATION_REQUIRED_CODE = "U001" } } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt new file mode 100644 index 00000000..eb8ca585 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt @@ -0,0 +1,12 @@ +package com.team.prezel.feature.profile.impl.contract + +import com.team.prezel.core.ui.UiEffect +import com.team.prezel.feature.profile.impl.model.ProfileUiMessage + +sealed interface ProfileUiEffect : UiEffect { + data object NavigateToLogin : ProfileUiEffect + + data class ShowMessage( + val message: ProfileUiMessage, + ) : ProfileUiEffect +} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt new file mode 100644 index 00000000..6e71b535 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.profile.impl.contract + +import com.team.prezel.core.ui.UiState + +data class ProfileUiState( + val isLoading: Boolean = false, +) : UiState diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt new file mode 100644 index 00000000..03d66583 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.profile.impl.model + +enum class ProfileUiMessage { + LOGOUT_FAILED, + WITHDRAW_FAILED, + AUTHENTICATION_EXPIRED, +} diff --git a/Prezel/feature/profile/impl/src/main/res/values/strings.xml b/Prezel/feature/profile/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..a83e9fa7 --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + 로그아웃에 실패했습니다. + 회원탈퇴에 실패했습니다. + 인증이 만료되었어요. 다시 로그인해 주세요. + From f8ca97de90e4c6fa3c5f5adccc19dae44d0e1f43 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 14:16:16 +0900 Subject: [PATCH 15/63] =?UTF-8?q?refactor:=20`AuthTokenStore`=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `core:datastore` 모듈 생성 및 `AuthTokenStore` 이전** * `core:network`에 위치하던 `AuthTokenStore` 인터페이스와 `DataStoreAuthTokenStore` 구현체를 신규 생성한 `core:datastore` 모듈로 이동하여 책임을 분리했습니다. * DataStore 관련 의존성을 `core:network`에서 `core:datastore`로 재배치했습니다. * `CoroutineScopesModule`을 이동하고 `Dispatchers.IO`를 기본값으로 사용하도록 수정했습니다. * **refactor: `AuthRepository` 및 `UseCase` 인증 로직 개선** * `AuthRepository` 인터페이스에서 `logout`, `withdraw` 호출 시 외부에서 `accessToken`을 전달받던 방식을 내부 `AuthTokenStore`에서 직접 참조하는 방식으로 변경했습니다. * 이에 따라 `LogoutUseCase`와 `WithdrawUseCase`에서 토큰 존재 여부를 확인하던 중복 로직을 제거했습니다. * `AuthRepositoryImpl`에서 인증 실패(U001) 코드 응답 시, 로컬 토큰을 삭제하고 `AuthenticationRequiredException`을 반환하도록 개선했습니다. * **feat: `AuthenticationRequiredException` 도메인 에러 추가** * 인증이 필요한 상황을 명시적으로 처리하기 위한 커스텀 예외 클래스를 `core:domain`에 추가했습니다. * `ProfileViewModel`에서 하드코딩된 에러 코드로 판단하던 로직을 해당 예외 타입을 확인하도록 리팩터링했습니다. * **refactor: `NetworkModule` 정리** * 사용하지 않는 `refresh` 전용 HttpClient 설정을 제거하여 의존성 구조를 단순화했습니다. --- Prezel/core/data/build.gradle.kts | 1 + .../data/repository/AuthRepositoryImpl.kt | 58 ++++++++++++++----- Prezel/core/datastore/build.gradle.kts | 14 +++++ .../core/datastore}/auth/AuthTokenStore.kt | 2 +- .../auth/DataStoreAuthTokenStore.kt | 10 ++-- .../datastore/di/CoroutineScopesModule.kt} | 14 ++--- .../core/datastore}/di/TokenStoreModule.kt | 6 +- .../error/AuthenticationRequiredException.kt | 5 ++ .../domain/repository/auth/AuthRepository.kt | 7 +-- .../core/domain/usecase/auth/LogoutUseCase.kt | 9 +-- .../domain/usecase/auth/WithdrawUseCase.kt | 12 +--- Prezel/core/network/build.gradle.kts | 2 +- .../core/network/auth/AuthTokenRefresher.kt | 1 + .../prezel/core/network/di/NetworkModule.kt | 15 +---- .../feature/profile/impl/ProfileViewModel.kt | 8 +-- Prezel/settings.gradle.kts | 1 + 16 files changed, 86 insertions(+), 79 deletions(-) create mode 100644 Prezel/core/datastore/build.gradle.kts rename Prezel/core/{network/src/main/java/com/team/prezel/core/network => datastore/src/main/java/com/team/prezel/core/datastore}/auth/AuthTokenStore.kt (95%) rename Prezel/core/{network/src/main/java/com/team/prezel/core/network => datastore/src/main/java/com/team/prezel/core/datastore}/auth/DataStoreAuthTokenStore.kt (96%) rename Prezel/core/{network/src/main/java/com/team/prezel/core/network/di/CoroutineScopeModule.kt => datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt} (57%) rename Prezel/core/{network/src/main/java/com/team/prezel/core/network => datastore/src/main/java/com/team/prezel/core/datastore}/di/TokenStoreModule.kt (67%) create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index 15e72cbe..7b97b97a 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -9,6 +9,7 @@ android { } dependencies { + implementation(projects.coreDatastore) implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.coreNetwork) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 8d621e3a..5ba9f014 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -1,11 +1,13 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.toResult +import com.team.prezel.core.datastore.auth.AuthTokenStore +import com.team.prezel.core.domain.error.AuthenticationRequiredException import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason -import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse import javax.inject.Inject @@ -20,25 +22,34 @@ internal class AuthRepositoryImpl @Inject constructor( override suspend fun reissueToken(refreshToken: String): Result = authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) - override suspend fun logout(accessToken: String): Result = - authRemoteDataSource.logout(accessToken = accessToken).toResult { - authTokenStore.clear() + override suspend fun logout(): Result { + val accessToken = authTokenStore.getAccessToken() ?: return authenticationRequired() + + return when (val response = authRemoteDataSource.logout(accessToken = accessToken)) { + is ApiResponse.Success -> Result.success(authTokenStore.clear()) + is ApiResponse.Failure.HttpError -> response.toLogoutResult() + is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) } + } override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) - override suspend fun withdraw( - accessToken: String, - reason: WithdrawReason, - ): Result = - authRemoteDataSource - .withdraw( - accessToken = accessToken, - reasonCategory = reason.toCategory(), - reasonText = reason.toReasonText(), - ).toResult { - authTokenStore.clear() - } + override suspend fun withdraw(reason: WithdrawReason): Result { + val accessToken = authTokenStore.getAccessToken() ?: return authenticationRequired() + + return when ( + val response = + authRemoteDataSource.withdraw( + accessToken = accessToken, + reasonCategory = reason.toCategory(), + reasonText = reason.toReasonText(), + ) + ) { + is ApiResponse.Success -> Result.success(authTokenStore.clear()) + is ApiResponse.Failure.HttpError -> response.toLogoutResult() + is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + } + } private suspend fun saveTokens(response: LoginResponse): AuthToken = response @@ -56,6 +67,17 @@ internal class AuthRepositoryImpl @Inject constructor( refreshToken = refreshToken, ) + private suspend fun ApiResponse.Failure.HttpError.toLogoutResult(): Result = + if (error?.code == AUTHENTICATION_REQUIRED_CODE) { + authTokenStore.clear() + authenticationRequired(message = error?.message) + } else { + Result.failure(throwable) + } + + private fun authenticationRequired(message: String? = null): Result = + Result.failure(AuthenticationRequiredException(message ?: "인증이 필요합니다.")) + private fun WithdrawReason.toCategory(): String = when (this) { WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" @@ -71,4 +93,8 @@ internal class AuthRepositoryImpl @Inject constructor( is WithdrawReason.Other -> text else -> "" } + + private companion object { + const val AUTHENTICATION_REQUIRED_CODE = "U001" + } } diff --git a/Prezel/core/datastore/build.gradle.kts b/Prezel/core/datastore/build.gradle.kts new file mode 100644 index 00000000..df42312e --- /dev/null +++ b/Prezel/core/datastore/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.prezel.android.library) + alias(libs.plugins.prezel.hilt) +} + +android { + namespace = "com.team.prezel.core.datastore" + testOptions.unitTests.isIncludeAndroidResources = true +} + +dependencies { + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt similarity index 95% rename from Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt rename to Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt index bf53c2a9..d6a38b51 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt @@ -1,4 +1,4 @@ -package com.team.prezel.core.network.auth +package com.team.prezel.core.datastore.auth /** * 인증 토큰의 현재 값을 동기 조회하고, 영속 저장소와의 동기화를 비동기로 처리하는 저장소 계약입니다. diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt similarity index 96% rename from Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt rename to Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt index 4563dbc6..b99cd0f4 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt @@ -1,4 +1,4 @@ -package com.team.prezel.core.network.auth +package com.team.prezel.core.datastore.auth import android.content.Context import androidx.datastore.core.DataStore @@ -8,17 +8,17 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile -import com.team.prezel.core.network.di.ApplicationScope +import com.team.prezel.core.datastore.di.ApplicationScope import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.IOException -import javax.inject.Inject -import javax.inject.Singleton @Singleton internal class DataStoreAuthTokenStore @Inject constructor( diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/CoroutineScopeModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt similarity index 57% rename from Prezel/core/network/src/main/java/com/team/prezel/core/network/di/CoroutineScopeModule.kt rename to Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt index 5319bfc9..d33f7d37 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/CoroutineScopeModule.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt @@ -1,16 +1,14 @@ -package com.team.prezel.core.network.di +package com.team.prezel.core.datastore.di -import com.team.prezel.core.network.Dispatcher -import com.team.prezel.core.network.PrezelDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Qualifier import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob @Retention(AnnotationRetention.RUNTIME) @Qualifier @@ -22,7 +20,5 @@ internal object CoroutineScopesModule { @Provides @Singleton @ApplicationScope - fun providesCoroutineScope( - @Dispatcher(PrezelDispatchers.Default) dispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + fun providesCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/TokenStoreModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/TokenStoreModule.kt similarity index 67% rename from Prezel/core/network/src/main/java/com/team/prezel/core/network/di/TokenStoreModule.kt rename to Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/TokenStoreModule.kt index f509514a..2e641e26 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/TokenStoreModule.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/TokenStoreModule.kt @@ -1,7 +1,7 @@ -package com.team.prezel.core.network.di +package com.team.prezel.core.datastore.di -import com.team.prezel.core.network.auth.AuthTokenStore -import com.team.prezel.core.network.auth.DataStoreAuthTokenStore +import com.team.prezel.core.datastore.auth.AuthTokenStore +import com.team.prezel.core.datastore.auth.DataStoreAuthTokenStore import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt new file mode 100644 index 00000000..f0672276 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.domain.error + +class AuthenticationRequiredException( + override val message: String = "인증이 필요합니다.", +) : IllegalStateException(message) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index ce5fb527..8864bae9 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -10,12 +10,9 @@ interface AuthRepository { suspend fun reissueToken(refreshToken: String): Result - suspend fun logout(accessToken: String): Result + suspend fun logout(): Result suspend fun login(idToken: String): Result - suspend fun withdraw( - accessToken: String, - reason: WithdrawReason, - ): Result + suspend fun withdraw(reason: WithdrawReason): Result } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt index c8c39c38..f87af6e9 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt @@ -15,12 +15,5 @@ import javax.inject.Inject class LogoutUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(): Result { - val accessToken = - authRepository.getAccessToken() ?: return Result.failure( - IllegalStateException("저장된 access token이 없습니다."), - ) - - return authRepository.logout(accessToken = accessToken) - } + suspend operator fun invoke(): Result = authRepository.logout() } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt index f496e5c3..af95ab5a 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt @@ -16,15 +16,5 @@ import javax.inject.Inject class WithdrawUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(reason: WithdrawReason): Result { - val accessToken = - authRepository.getAccessToken() ?: return Result.failure( - IllegalStateException("저장된 access token이 없습니다."), - ) - - return authRepository.withdraw( - accessToken = accessToken, - reason = reason, - ) - } + suspend operator fun invoke(reason: WithdrawReason): Result = authRepository.withdraw(reason = reason) } diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index 5c28a8b6..1398226b 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -17,7 +17,7 @@ android { } dependencies { - implementation(libs.androidx.datastore.preferences) + implementation(projects.coreDatastore) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 3dc96f1c..8692380f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -1,5 +1,6 @@ package com.team.prezel.core.network.auth +import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.model.ApiErrorResponse diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index c54dbe88..aaa8dfce 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -2,8 +2,8 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.auth.AuthPathPolicy -import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.auth.TokenRefreshAuthenticator import com.team.prezel.core.network.service.AuthService import com.team.prezel.core.network.service.createAuthService @@ -27,7 +27,6 @@ import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import timber.log.Timber -import javax.inject.Named import javax.inject.Singleton @Module @@ -42,18 +41,6 @@ object NetworkModule { prettyPrint = false } - @Provides - @Singleton - @Named("refresh") - fun provideRefreshHttpClient(json: Json): HttpClient = - HttpClient(OkHttp) { - configureBaseClient(json) - - defaultRequest { - contentType(ContentType.Application.Json) - } - } - @Provides @Singleton fun provideHttpClient( diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index 42f8f83b..96a60953 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -3,7 +3,7 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.AuthManager -import com.team.prezel.core.domain.error.ApiHttpException +import com.team.prezel.core.domain.error.AuthenticationRequiredException import com.team.prezel.core.domain.usecase.auth.LogoutUseCase import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason @@ -90,9 +90,5 @@ class ProfileViewModel _uiEffect.emit(ProfileUiEffect.ShowMessage(failureMessage)) } - private fun Throwable?.isAuthenticationRequired(): Boolean = (this as? ApiHttpException)?.code == AUTHENTICATION_REQUIRED_CODE - - private companion object { - const val AUTHENTICATION_REQUIRED_CODE = "U001" - } + private fun Throwable?.isAuthenticationRequired(): Boolean = this is AuthenticationRequiredException } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 7beec85e..098ef79d 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -41,6 +41,7 @@ includeAuto( ":app", "core:auth", ":core:data", + ":core:datastore", ":core:designsystem", ":core:domain", ":core:model", From 09fba1b664af6486af54cca66154018bd8394c7b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 14:38:33 +0900 Subject: [PATCH 16/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20ProfileViewModel=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `DataStoreAuthTokenStore` 초기화 방식 변경** * 초기 토큰 로드 시 사용하던 `runBlocking`을 제거하고 `applicationScope.launch`를 이용한 비동기 방식으로 변경하여 메인 스레드 차단 가능성을 방지했습니다. * **refactor: `AuthRepositoryImpl` 인증 예외 처리 강화** * 로그아웃 및 회원 탈퇴 시 액세스 토큰이 없는 경우, 단순히 에러를 반환하던 방식에서 로컬 토큰을 초기화(`clear`)한 후 인증 필요 예외를 반환하도록 `clearTokensAndAuthenticationRequired` 메서드를 추가했습니다. * **refactor: `ProfileViewModel` 로딩 상태 관리 개선** * 로그아웃(`logout`) 및 회원 탈퇴(`withdraw`) 로직에 `try-finally` 블록을 적용했습니다. 이를 통해 작업 성공 여부나 예외 발생과 관계없이 `isLoading` 상태가 `false`로 안전하게 변경되도록 보장했습니다. * **style: `AuthRepositoryImpl` 코드 포맷팅 수정** * `login` 메서드의 줄바꿈 등 가독성 향상을 위한 코드 스타일을 정리했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 12 +++-- .../datastore/auth/DataStoreAuthTokenStore.kt | 10 +++-- .../feature/profile/impl/ProfileViewModel.kt | 44 +++++++++++-------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 5ba9f014..758df5eb 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -23,7 +23,7 @@ internal class AuthRepositoryImpl @Inject constructor( authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) override suspend fun logout(): Result { - val accessToken = authTokenStore.getAccessToken() ?: return authenticationRequired() + val accessToken = authTokenStore.getAccessToken() ?: return clearTokensAndAuthenticationRequired() return when (val response = authRemoteDataSource.logout(accessToken = accessToken)) { is ApiResponse.Success -> Result.success(authTokenStore.clear()) @@ -32,10 +32,11 @@ internal class AuthRepositoryImpl @Inject constructor( } } - override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) + override suspend fun login(idToken: String): Result = + authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) override suspend fun withdraw(reason: WithdrawReason): Result { - val accessToken = authTokenStore.getAccessToken() ?: return authenticationRequired() + val accessToken = authTokenStore.getAccessToken() ?: return clearTokensAndAuthenticationRequired() return when ( val response = @@ -78,6 +79,11 @@ internal class AuthRepositoryImpl @Inject constructor( private fun authenticationRequired(message: String? = null): Result = Result.failure(AuthenticationRequiredException(message ?: "인증이 필요합니다.")) + private suspend fun clearTokensAndAuthenticationRequired(): Result { + authTokenStore.clear() + return authenticationRequired() + } + private fun WithdrawReason.toCategory(): String = when (this) { WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt index b99cd0f4..0edbfb6f 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt @@ -16,7 +16,7 @@ import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -40,9 +40,11 @@ internal class DataStoreAuthTokenStore @Inject constructor( private val mutex = Mutex() init { - val preferences = runBlocking { readPreferences() } - accessToken = preferences[KEY_ACCESS_TOKEN] - refreshToken = preferences[KEY_REFRESH_TOKEN] + applicationScope.launch { + val preferences = readPreferences() + accessToken = preferences[KEY_ACCESS_TOKEN] + refreshToken = preferences[KEY_REFRESH_TOKEN] + } } override fun getAccessToken(): String? = accessToken diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index 96a60953..ff56c0f6 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -38,14 +38,17 @@ class ProfileViewModel if (_uiState.value.isLoading) return viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - val result = logoutUseCase().fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) - _uiState.update { it.copy(isLoading = false) } - handleAuthActionResult( - result = result, - failureLog = "로그아웃에 실패했습니다.", - failureMessage = ProfileUiMessage.LOGOUT_FAILED, - ) + try { + _uiState.update { it.copy(isLoading = true) } + val result = logoutUseCase().fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) + handleAuthActionResult( + result = result, + failureLog = "로그아웃에 실패했습니다.", + failureMessage = ProfileUiMessage.LOGOUT_FAILED, + ) + } finally { + _uiState.update { it.copy(isLoading = false) } + } } } @@ -53,17 +56,20 @@ class ProfileViewModel if (_uiState.value.isLoading) return viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - val result = - withdrawUseCase( - reason = WithdrawReason.Other("임시 테스트 탈퇴"), - ).fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) - _uiState.update { it.copy(isLoading = false) } - handleAuthActionResult( - result = result, - failureLog = "회원탈퇴에 실패했습니다.", - failureMessage = ProfileUiMessage.WITHDRAW_FAILED, - ) + try { + _uiState.update { it.copy(isLoading = true) } + val result = + withdrawUseCase( + reason = WithdrawReason.Other("임시 테스트 탈퇴"), + ).fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) + handleAuthActionResult( + result = result, + failureLog = "회원탈퇴에 실패했습니다.", + failureMessage = ProfileUiMessage.WITHDRAW_FAILED, + ) + } finally { + _uiState.update { it.copy(isLoading = false) } + } } } From efa1332c3fad88f5073efeb7b217fd1c482f030a Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 14:46:54 +0900 Subject: [PATCH 17/63] =?UTF-8?q?refactor:=20`AuthActionResult`=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthActionResult` sealed interface 추가** * 인증 기반 작업(로그아웃, 회원 탈퇴)의 결과를 명확하게 정의하기 위해 `Success`, `AuthenticationRequired`, `Failure` 상태를 가지는 `AuthActionResult`를 `core:domain`에 추가했습니다. * 기존의 `AuthenticationRequiredException`을 제거하고 도메인 모델을 통한 상태 처리를 지향하도록 변경했습니다. * **refactor: `LogoutUseCase` 및 `WithdrawUseCase` 반환 타입 변경** * 기존 `Result` 대신 `AuthActionResult`를 반환하도록 수정했습니다. * UseCase 내부에 있던 토큰 조회 로직을 Repository로 위임하여 책임을 분리하고 설명을 보완하는 KDoc을 업데이트했습니다. * **refactor: `AuthRepository` 및 `AuthRepositoryImpl` 로직 개선** * `logout` 및 `withdraw` 메서드의 반환 타입을 `AuthActionResult`로 변경했습니다. * 토큰 부재 시 `clearTokensAndAuthenticationRequired`를 호출하여 상태를 일관되게 관리하도록 수정했습니다. * API 응답 유형(Success, HttpError, NetworkError)에 따라 `AuthActionResult`를 매핑하는 `toAuthActionResult` 확장 함수를 추가했습니다. * **refactor: `ProfileViewModel` 내 인증 결과 처리 로직 고도화** * `handleAuthActionResult`를 추가하여 `AuthActionResult` 상태에 따른 UI 에펙트(`NavigateToLogin`, `ShowMessage`) 및 `AuthManager` 상태 제어 로직을 통합했습니다. * 로그아웃 및 회원 탈퇴 호출 시 중복되던 후처리 로직을 간소화했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 40 ++++++++++--------- .../datastore/auth/DataStoreAuthTokenStore.kt | 6 +-- .../datastore/di/CoroutineScopesModule.kt | 4 +- .../error/AuthenticationRequiredException.kt | 5 --- .../domain/repository/auth/AuthRepository.kt | 5 ++- .../domain/usecase/auth/AuthActionResult.kt | 11 +++++ .../core/domain/usecase/auth/LogoutUseCase.kt | 9 ++--- .../domain/usecase/auth/WithdrawUseCase.kt | 9 ++--- .../prezel/core/network/di/NetworkModule.kt | 2 +- .../feature/profile/impl/ProfileViewModel.kt | 37 +++++++++-------- 10 files changed, 67 insertions(+), 61 deletions(-) delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/AuthActionResult.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 758df5eb..263cd186 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -2,8 +2,8 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.toResult import com.team.prezel.core.datastore.auth.AuthTokenStore -import com.team.prezel.core.domain.error.AuthenticationRequiredException import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.usecase.auth.AuthActionResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.datasource.AuthRemoteDataSource @@ -22,20 +22,22 @@ internal class AuthRepositoryImpl @Inject constructor( override suspend fun reissueToken(refreshToken: String): Result = authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) - override suspend fun logout(): Result { + override suspend fun logout(): AuthActionResult { val accessToken = authTokenStore.getAccessToken() ?: return clearTokensAndAuthenticationRequired() return when (val response = authRemoteDataSource.logout(accessToken = accessToken)) { - is ApiResponse.Success -> Result.success(authTokenStore.clear()) - is ApiResponse.Failure.HttpError -> response.toLogoutResult() - is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + is ApiResponse.Success -> { + authTokenStore.clear() + AuthActionResult.Success + } + is ApiResponse.Failure.HttpError -> response.toAuthActionResult() + is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) } } - override suspend fun login(idToken: String): Result = - authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) + override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) - override suspend fun withdraw(reason: WithdrawReason): Result { + override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { val accessToken = authTokenStore.getAccessToken() ?: return clearTokensAndAuthenticationRequired() return when ( @@ -46,9 +48,12 @@ internal class AuthRepositoryImpl @Inject constructor( reasonText = reason.toReasonText(), ) ) { - is ApiResponse.Success -> Result.success(authTokenStore.clear()) - is ApiResponse.Failure.HttpError -> response.toLogoutResult() - is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + is ApiResponse.Success -> { + authTokenStore.clear() + AuthActionResult.Success + } + is ApiResponse.Failure.HttpError -> response.toAuthActionResult() + is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) } } @@ -68,20 +73,17 @@ internal class AuthRepositoryImpl @Inject constructor( refreshToken = refreshToken, ) - private suspend fun ApiResponse.Failure.HttpError.toLogoutResult(): Result = + private suspend fun ApiResponse.Failure.HttpError.toAuthActionResult(): AuthActionResult = if (error?.code == AUTHENTICATION_REQUIRED_CODE) { authTokenStore.clear() - authenticationRequired(message = error?.message) + AuthActionResult.AuthenticationRequired } else { - Result.failure(throwable) + AuthActionResult.Failure(throwable) } - private fun authenticationRequired(message: String? = null): Result = - Result.failure(AuthenticationRequiredException(message ?: "인증이 필요합니다.")) - - private suspend fun clearTokensAndAuthenticationRequired(): Result { + private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult { authTokenStore.clear() - return authenticationRequired() + return AuthActionResult.AuthenticationRequired } private fun WithdrawReason.toCategory(): String = diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt index 0edbfb6f..48c7abe3 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt @@ -10,15 +10,15 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import com.team.prezel.core.datastore.di.ApplicationScope import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.IOException -import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton @Singleton internal class DataStoreAuthTokenStore @Inject constructor( diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt index d33f7d37..08cc9884 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt @@ -4,11 +4,11 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Qualifier -import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton @Retention(AnnotationRetention.RUNTIME) @Qualifier diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt deleted file mode 100644 index f0672276..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/AuthenticationRequiredException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.domain.error - -class AuthenticationRequiredException( - override val message: String = "인증이 필요합니다.", -) : IllegalStateException(message) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index 8864bae9..9b32331a 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -1,5 +1,6 @@ package com.team.prezel.core.domain.repository.auth +import com.team.prezel.core.domain.usecase.auth.AuthActionResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason @@ -10,9 +11,9 @@ interface AuthRepository { suspend fun reissueToken(refreshToken: String): Result - suspend fun logout(): Result + suspend fun logout(): AuthActionResult suspend fun login(idToken: String): Result - suspend fun withdraw(reason: WithdrawReason): Result + suspend fun withdraw(reason: WithdrawReason): AuthActionResult } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/AuthActionResult.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/AuthActionResult.kt new file mode 100644 index 00000000..d5cdd5a3 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/AuthActionResult.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.auth + +sealed interface AuthActionResult { + data object Success : AuthActionResult + + data object AuthenticationRequired : AuthActionResult + + data class Failure( + val throwable: Throwable, + ) : AuthActionResult +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt index f87af6e9..2f164bce 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt @@ -7,13 +7,12 @@ import javax.inject.Inject * 현재 로그인 세션의 로그아웃을 요청하는 UseCase. * * ### 동작 흐름 - * 1. 저장된 현재 액세스 토큰을 조회합니다. - * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출하여 서버에 로그아웃을 요청합니다. - * 3. 요청 결과에 따라 성공 여부를 [Result]로 반환합니다. - * + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출합니다. + * 2. repository가 저장된 토큰 조회와 서버 로그아웃 요청을 처리합니다. + * 3. 결과를 [AuthActionResult]로 반환합니다. */ class LogoutUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(): Result = authRepository.logout() + suspend operator fun invoke(): AuthActionResult = authRepository.logout() } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt index af95ab5a..d7447ff9 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt @@ -8,13 +8,12 @@ import javax.inject.Inject * 회원 탈퇴 사유와 함께 탈퇴 요청을 수행하는 UseCase. * * ### 동작 흐름 - * 1. 저장된 현재 액세스 토큰과 호출부로부터 전달받은 [WithdrawReason]을 입력값으로 사용합니다. - * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출하여 서버에 회원 탈퇴를 요청합니다. - * 3. 요청 결과에 따라 성공 여부를 [Result]로 반환합니다. - * + * 1. 호출부로부터 전달받은 [WithdrawReason]으로 [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출합니다. + * 2. repository가 저장된 토큰 조회와 서버 회원 탈퇴 요청을 처리합니다. + * 3. 결과를 [AuthActionResult]로 반환합니다. */ class WithdrawUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(reason: WithdrawReason): Result = authRepository.withdraw(reason = reason) + suspend operator fun invoke(reason: WithdrawReason): AuthActionResult = authRepository.withdraw(reason = reason) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index aaa8dfce..31491a20 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,8 +1,8 @@ package com.team.prezel.core.network.di +import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.auth.AuthPathPolicy import com.team.prezel.core.network.auth.TokenRefreshAuthenticator import com.team.prezel.core.network.service.AuthService diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index ff56c0f6..cb3d06d0 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -3,7 +3,7 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.AuthManager -import com.team.prezel.core.domain.error.AuthenticationRequiredException +import com.team.prezel.core.domain.usecase.auth.AuthActionResult import com.team.prezel.core.domain.usecase.auth.LogoutUseCase import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason @@ -40,7 +40,7 @@ class ProfileViewModel viewModelScope.launch { try { _uiState.update { it.copy(isLoading = true) } - val result = logoutUseCase().fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) + val result = logoutUseCase() handleAuthActionResult( result = result, failureLog = "로그아웃에 실패했습니다.", @@ -61,7 +61,7 @@ class ProfileViewModel val result = withdrawUseCase( reason = WithdrawReason.Other("임시 테스트 탈퇴"), - ).fold(onSuccess = { authManager.logout() }, onFailure = { Result.failure(it) }) + ) handleAuthActionResult( result = result, failureLog = "회원탈퇴에 실패했습니다.", @@ -74,27 +74,26 @@ class ProfileViewModel } private suspend fun handleAuthActionResult( - result: Result, + result: AuthActionResult, failureLog: String, failureMessage: ProfileUiMessage, ) { - if (result.isSuccess) { - _uiEffect.emit(ProfileUiEffect.NavigateToLogin) - return - } + when (result) { + AuthActionResult.Success -> { + authManager.logout() + _uiEffect.emit(ProfileUiEffect.NavigateToLogin) + } - val exception = result.exceptionOrNull() - Timber.tag("ProfileTest").e(exception, failureLog) + AuthActionResult.AuthenticationRequired -> { + authManager.clearCurrentProvider() + _uiEffect.emit(ProfileUiEffect.NavigateToLogin) + _uiEffect.emit(ProfileUiEffect.ShowMessage(ProfileUiMessage.AUTHENTICATION_EXPIRED)) + } - if (exception.isAuthenticationRequired()) { - authManager.clearCurrentProvider() - _uiEffect.emit(ProfileUiEffect.NavigateToLogin) - _uiEffect.emit(ProfileUiEffect.ShowMessage(ProfileUiMessage.AUTHENTICATION_EXPIRED)) - return + is AuthActionResult.Failure -> { + Timber.tag("ProfileTest").e(result.throwable, failureLog) + _uiEffect.emit(ProfileUiEffect.ShowMessage(failureMessage)) + } } - - _uiEffect.emit(ProfileUiEffect.ShowMessage(failureMessage)) } - - private fun Throwable?.isAuthenticationRequired(): Boolean = this is AuthenticationRequiredException } From cbbed10aec0168173162dee7d01ca76598ff547c Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 14:52:52 +0900 Subject: [PATCH 18/63] =?UTF-8?q?refactor:=20AuthActionResult=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: `AuthActionResult` 클래스 패키지 경로 변경 `AuthActionResult` 인터페이스를 UseCase 패키지에서 Result 관련 공통 패키지로 이동하여 도메인 레이어의 구조를 개선했습니다. * `core:domain` 내 `usecase.auth` -> `result.auth`로 파일 이동 및 패키지 명 수정 * refactor: 패키지 이동에 따른 의존성 참조 수정 `AuthActionResult`의 위치가 변경됨에 따라 이를 참조하는 Repository, UseCase, ViewModel 등의 import 문을 업데이트했습니다. * `AuthRepository`, `AuthRepositoryImpl` 내 import 수정 * `WithdrawUseCase`, `LogoutUseCase` 내 import 수정 * `feature:profile:impl` 모듈의 `ProfileViewModel` 내 import 수정 --- .../com/team/prezel/core/data/repository/AuthRepositoryImpl.kt | 2 +- .../team/prezel/core/domain/repository/auth/AuthRepository.kt | 2 +- .../core/domain/{usecase => result}/auth/AuthActionResult.kt | 2 +- .../com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt | 1 + .../com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt | 1 + .../com/team/prezel/feature/profile/impl/ProfileViewModel.kt | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) rename Prezel/core/domain/src/main/java/com/team/prezel/core/domain/{usecase => result}/auth/AuthActionResult.kt (82%) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 263cd186..848606d4 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -3,7 +3,7 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.toResult import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.domain.usecase.auth.AuthActionResult +import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.datasource.AuthRemoteDataSource diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index 9b32331a..87c5b54d 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -1,6 +1,6 @@ package com.team.prezel.core.domain.repository.auth -import com.team.prezel.core.domain.usecase.auth.AuthActionResult +import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/AuthActionResult.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/AuthActionResult.kt similarity index 82% rename from Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/AuthActionResult.kt rename to Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/AuthActionResult.kt index d5cdd5a3..bf2fdb25 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/AuthActionResult.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/AuthActionResult.kt @@ -1,4 +1,4 @@ -package com.team.prezel.core.domain.usecase.auth +package com.team.prezel.core.domain.result.auth sealed interface AuthActionResult { data object Success : AuthActionResult diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt index 2f164bce..c807283d 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.domain.usecase.auth import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.result.auth.AuthActionResult import javax.inject.Inject /** diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt index d7447ff9..5d0855f8 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.domain.usecase.auth import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.model.auth.WithdrawReason import javax.inject.Inject diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index cb3d06d0..7848747b 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -3,7 +3,7 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.AuthManager -import com.team.prezel.core.domain.usecase.auth.AuthActionResult +import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.usecase.auth.LogoutUseCase import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason From c9f57b223c91493157365096be9f263f6af9c43a Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 15:54:43 +0900 Subject: [PATCH 19/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=AA=A8=EB=93=88=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: AuthRemoteDataSource 및 AuthService 내 토큰 파라미터 제거** * `logout` 및 `withdraw` API 호출 시 명시적으로 전달하던 `accessToken` 파라미터를 제거했습니다. * 이는 공통 인터셉터나 `Authenticator`를 통해 인증 헤더가 자동으로 처리되도록 개선됨에 따른 변경입니다. * **feat: 토큰 재발급 전용 HttpClient 및 서비스 분리** * 토큰 재발급(`reissueToken`) 시 무한 루프를 방지하기 위해 `@Named("refresh")`를 사용하여 별도의 `HttpClient`와 `AuthService`를 생성하도록 `NetworkModule`을 수정했습니다. * `AuthTokenRefresher`가 `AuthRemoteDataSource` 대신 분리된 `refresh` 전용 `AuthService`를 직접 의존하도록 변경했습니다. * **refactor: WithdrawReason 카테고리 매핑 문자열 수정** * `AuthRepositoryImpl`에서 `WithdrawReason` 도메인 모델을 서버 스펙에 맞는 상수 문자열로 변환하는 로직을 업데이트했습니다. * 예: `TOO_DIFFICULT_OR_COMPLEX` -> `TOO_COMPLEX`, `OTHER` -> `ETC` 등. * **fix: DataStoreAuthTokenStore 초기화 시 스레드 안전성 확보** * `applicationScope`에서 초기 토큰 로드 시 `mutex`를 사용하여 동시성 이슈를 방지했습니다. * **refactor: ApiResponseConverterFactory 에러 처리 개선** * `parseErrorResponse`에서 `runCatching` 대신 `try-catch`를 사용하고, `CancellationException` 발생 시 코루틴 취소 흐름이 유지되도록 `rethrowIfCancellation()`을 추가했습니다. * **chore: ProfileViewModel 로그 및 예외 처리 추가** * 로그아웃 실패 시 Timber를 이용한 경고 로그를 추가하여 로컬 세션 정리 실패 상황을 모니터링할 수 있도록 개선했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 15 ++++----- .../datastore/auth/DataStoreAuthTokenStore.kt | 8 +++-- .../network/ApiResponseConverterFactory.kt | 7 ++-- .../core/network/auth/AuthTokenRefresher.kt | 13 ++++++-- .../datasource/AuthRemoteDataSource.kt | 3 +- .../datasource/AuthRemoteDataSourceImpl.kt | 7 +--- .../prezel/core/network/di/NetworkModule.kt | 33 +++++++++++++++++++ .../core/network/service/AuthService.kt | 6 +--- .../feature/profile/impl/ProfileViewModel.kt | 6 +++- 9 files changed, 68 insertions(+), 30 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 848606d4..e035d09a 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -23,9 +23,9 @@ internal class AuthRepositoryImpl @Inject constructor( authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) override suspend fun logout(): AuthActionResult { - val accessToken = authTokenStore.getAccessToken() ?: return clearTokensAndAuthenticationRequired() + if (authTokenStore.getAccessToken() == null) return clearTokensAndAuthenticationRequired() - return when (val response = authRemoteDataSource.logout(accessToken = accessToken)) { + return when (val response = authRemoteDataSource.logout()) { is ApiResponse.Success -> { authTokenStore.clear() AuthActionResult.Success @@ -38,12 +38,11 @@ internal class AuthRepositoryImpl @Inject constructor( override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { - val accessToken = authTokenStore.getAccessToken() ?: return clearTokensAndAuthenticationRequired() + if (authTokenStore.getAccessToken() == null) return clearTokensAndAuthenticationRequired() return when ( val response = authRemoteDataSource.withdraw( - accessToken = accessToken, reasonCategory = reason.toCategory(), reasonText = reason.toReasonText(), ) @@ -90,10 +89,10 @@ internal class AuthRepositoryImpl @Inject constructor( when (this) { WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" - WithdrawReason.TooDifficultOrComplex -> "TOO_DIFFICULT_OR_COMPLEX" - WithdrawReason.AnalysisResultInaccurate -> "ANALYSIS_RESULT_INACCURATE" - WithdrawReason.TooManyErrors -> "TOO_MANY_ERRORS" - is WithdrawReason.Other -> "OTHER" + WithdrawReason.TooDifficultOrComplex -> "TOO_COMPLEX" + WithdrawReason.AnalysisResultInaccurate -> "INACCURATE_ANALYSIS" + WithdrawReason.TooManyErrors -> "MANY_ERRORS" + is WithdrawReason.Other -> "ETC" } private fun WithdrawReason.toReasonText(): String = diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt index 48c7abe3..5584aa48 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt @@ -41,9 +41,11 @@ internal class DataStoreAuthTokenStore @Inject constructor( init { applicationScope.launch { - val preferences = readPreferences() - accessToken = preferences[KEY_ACCESS_TOKEN] - refreshToken = preferences[KEY_REFRESH_TOKEN] + mutex.withLock { + val preferences = readPreferences() + accessToken = preferences[KEY_ACCESS_TOKEN] + refreshToken = preferences[KEY_REFRESH_TOKEN] + } } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt index 41c3bdfd..1fab62f5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt @@ -80,9 +80,12 @@ class ApiResponseConverterFactory : Converter.Factory { } private suspend fun parseErrorResponse(exception: ResponseException): ApiErrorResponse? = - runCatching { + try { json.decodeFromString(exception.response.bodyAsText()) - }.getOrNull() + } catch (t: Throwable) { + t.rethrowIfCancellation() + null + } private fun Throwable.rethrowIfCancellation() { if (this is CancellationException) throw this diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 8692380f..227ca99d 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -2,18 +2,20 @@ package com.team.prezel.core.network.auth import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.auth.ReissueTokenRequest +import com.team.prezel.core.network.service.AuthService import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton class AuthTokenRefresher @Inject constructor( - private val authRemoteDataSource: AuthRemoteDataSource, + @Named("refresh") private val authService: AuthService, private val authTokenStore: AuthTokenStore, ) { private val mutex = Mutex() @@ -22,7 +24,12 @@ class AuthTokenRefresher @Inject constructor( mutex.withLock { val refreshToken = authTokenStore.getRefreshToken() ?: return@withLock null - when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { + when ( + val response = + authService.reissueToken( + request = ReissueTokenRequest(refreshToken = refreshToken), + ) + ) { is ApiResponse.Success -> { authTokenStore.saveTokens( accessToken = response.data.accessToken, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt index f4dc126b..52b2ebc4 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt @@ -6,12 +6,11 @@ import com.team.prezel.core.network.model.auth.LoginResponse interface AuthRemoteDataSource { suspend fun reissueToken(refreshToken: String): ApiResponse - suspend fun logout(accessToken: String): ApiResponse + suspend fun logout(): ApiResponse suspend fun login(idToken: String): ApiResponse suspend fun withdraw( - accessToken: String, reasonCategory: String, reasonText: String, ): ApiResponse diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index 52bb0168..545e6e33 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -19,10 +19,7 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( request = ReissueTokenRequest(refreshToken = refreshToken), ).also(::logTokenResponse) - override suspend fun logout(accessToken: String): ApiResponse = - authService.logout( - authorization = "Bearer $accessToken", - ) + override suspend fun logout(): ApiResponse = authService.logout() override suspend fun login(idToken: String): ApiResponse = LoginRequest(idToken = idToken) @@ -31,12 +28,10 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( }.also(::logTokenResponse) override suspend fun withdraw( - accessToken: String, reasonCategory: String, reasonText: String, ): ApiResponse = authService.withdraw( - authorization = "Bearer $accessToken", request = WithdrawRequest( reasonCategory = reasonCategory, reasonText = reasonText, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 31491a20..5859d772 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -27,6 +27,7 @@ import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import timber.log.Timber +import javax.inject.Named import javax.inject.Singleton @Module @@ -68,6 +69,18 @@ object NetworkModule { } } + @Provides + @Singleton + @Named("refresh") + fun provideRefreshHttpClient(json: Json): HttpClient = + HttpClient(OkHttp) { + configureBaseClient(json) + + defaultRequest { + contentType(ContentType.Application.Json) + } + } + @Provides @Singleton fun provideKtorfit(httpClient: HttpClient): Ktorfit = @@ -78,10 +91,30 @@ object NetworkModule { .converterFactories(ApiResponseConverterFactory()) .build() + @Provides + @Singleton + @Named("refresh") + fun provideRefreshKtorfit( + @Named("refresh") httpClient: HttpClient, + ): Ktorfit = + Ktorfit + .Builder() + .baseUrl(BuildConfig.BASE_URL) + .httpClient(httpClient) + .converterFactories(ApiResponseConverterFactory()) + .build() + @Provides @Singleton internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() + @Provides + @Singleton + @Named("refresh") + internal fun provideRefreshAuthService( + @Named("refresh") ktorfit: Ktorfit, + ): AuthService = ktorfit.createAuthService() + private fun HttpClientConfig<*>.configureBaseClient(json: Json) { expectSuccess = true diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt index b24e5451..3fc040e5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -7,7 +7,6 @@ import com.team.prezel.core.network.model.auth.ReissueTokenRequest import com.team.prezel.core.network.model.auth.WithdrawRequest import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE -import de.jensklingenberg.ktorfit.http.Header import de.jensklingenberg.ktorfit.http.POST internal interface AuthService { @@ -17,9 +16,7 @@ internal interface AuthService { ): ApiResponse @POST("auth/logout") - suspend fun logout( - @Header("Authorization") authorization: String, - ): ApiResponse + suspend fun logout(): ApiResponse @POST("auth/login") suspend fun login( @@ -28,7 +25,6 @@ internal interface AuthService { @DELETE("auth/withdraw") suspend fun withdraw( - @Header("Authorization") authorization: String, @Body request: WithdrawRequest, ): ApiResponse } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index 7848747b..fa397f38 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -80,7 +80,11 @@ class ProfileViewModel ) { when (result) { AuthActionResult.Success -> { - authManager.logout() + authManager + .logout() + .onFailure { throwable -> + Timber.tag("ProfileTest").w(throwable, "로컬 인증 세션 정리에 실패했습니다.") + } _uiEffect.emit(ProfileUiEffect.NavigateToLogin) } From ccdf683a1c8199acbcf14ce3c32793cd4f909c6e Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 16:00:51 +0900 Subject: [PATCH 20/63] =?UTF-8?q?refactor:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=AA=A8=EB=93=88=20=EC=A3=BC=EC=9A=94=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=B0=8F=20=ED=95=A8=EC=88=98=EC=9D=98=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4=EC=9E=90=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 네트워크 관련 클래스 및 주입 함수를 `internal`로 변경** * 외부 모듈에 노출될 필요가 없는 인증 및 네트워크 설정 관련 코드의 캡슐화를 강화하기 위해 `internal` 접근 제어자를 추가했습니다. * `NetworkModule.kt`: `provideHttpClient` 함수를 `internal`로 변경 * `AuthTokenRefresher.kt`: `AuthTokenRefresher` 클래스를 `internal`로 변경 및 `@Named` 어노테이션 위치 수정 (`@param:Named`) * `TokenRefreshAuthenticator.kt`: `TokenRefreshAuthenticator` 클래스를 `internal`로 변경 --- .../com/team/prezel/core/network/auth/AuthTokenRefresher.kt | 4 ++-- .../prezel/core/network/auth/TokenRefreshAuthenticator.kt | 2 +- .../java/com/team/prezel/core/network/di/NetworkModule.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 227ca99d..323c238e 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -14,8 +14,8 @@ import javax.inject.Named import javax.inject.Singleton @Singleton -class AuthTokenRefresher @Inject constructor( - @Named("refresh") private val authService: AuthService, +internal class AuthTokenRefresher @Inject constructor( + @param:Named("refresh") private val authService: AuthService, private val authTokenStore: AuthTokenStore, ) { private val mutex = Mutex() diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt index 79274570..79800532 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt @@ -10,7 +10,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class TokenRefreshAuthenticator @Inject constructor( +internal class TokenRefreshAuthenticator @Inject constructor( private val authTokenRefresher: AuthTokenRefresher, ) : Authenticator { override fun authenticate( diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 5859d772..2e5fb055 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -44,7 +44,7 @@ object NetworkModule { @Provides @Singleton - fun provideHttpClient( + internal fun provideHttpClient( json: Json, authTokenStore: AuthTokenStore, tokenRefreshAuthenticator: TokenRefreshAuthenticator, From c09ea5b484bcc87b5bf9dfea31a1f786282bdd4f Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sat, 18 Apr 2026 16:03:29 +0900 Subject: [PATCH 21/63] =?UTF-8?q?chore:=20.gitignore=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `.kotlin/` 디렉토리 및 `.DS_Store` 파일 제외 설정 제거 --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index e577c5cc..e5cbb641 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,6 @@ output-metadata.json # IntelliJ *.iml .idea/ -.kotlin/ misc.xml deploymentTargetDropDown.xml render.experimental.xml @@ -33,6 +32,3 @@ google-services.json # Android Profiling *.hprof - -# macOS Finder -.DS_Store From 28fc53a8a437ca5def29c82d0796b494b2aae722 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 19 Apr 2026 12:12:55 +0900 Subject: [PATCH 22/63] =?UTF-8?q?refactor:=20`AuthTokenStore`=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthTokenStore` 초기화 대기 로직 구현** * DataStore의 데이터를 메모리(`accessToken`, `refreshToken`)로 로드하는 비동기 작업을 추적하기 위해 `initializationJob`을 도입했습니다. * `awaitInitialized()` 메서드를 추가하여 토큰 저장(`saveTokens`)이나 삭제(`clear`) 요청 시 초기화가 완료될 때까지 대기하도록 보장했습니다. * **refactor: `AuthRepository` 및 `CheckLoginStatusUseCase` 안정성 강화** * `AuthRepository` 인터페이스와 `AuthRepositoryImpl`에 `awaitTokenStoreInitialized()`를 추가하여 데이터 레이어의 초기화 상태를 도메인 레이어에서 제어할 수 있도록 개선했습니다. * `CheckLoginStatusUseCase` 호출 시, 토큰 존재 여부를 확인하기 전에 저장소 초기화를 먼저 대기하도록 수정하여 정확한 로그인 상태를 판별하게 했습니다. --- .../prezel/core/data/repository/AuthRepositoryImpl.kt | 4 ++++ .../team/prezel/core/datastore/auth/AuthTokenStore.kt | 2 ++ .../core/datastore/auth/DataStoreAuthTokenStore.kt | 11 ++++++++--- .../core/domain/repository/auth/AuthRepository.kt | 2 ++ .../domain/usecase/auth/CheckLoginStatusUseCase.kt | 2 ++ 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index e035d09a..2db0ade0 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -19,6 +19,10 @@ internal class AuthRepositoryImpl @Inject constructor( override fun getRefreshToken(): String? = authTokenStore.getRefreshToken() + override suspend fun awaitTokenStoreInitialized() { + authTokenStore.awaitInitialized() + } + override suspend fun reissueToken(refreshToken: String): Result = authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt index d6a38b51..f75c10de 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt @@ -18,6 +18,8 @@ interface AuthTokenStore { fun getRefreshToken(): String? + suspend fun awaitInitialized() + suspend fun saveTokens( accessToken: String, refreshToken: String, diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt index 5584aa48..caf415f6 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.Job import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -38,8 +39,7 @@ internal class DataStoreAuthTokenStore @Inject constructor( private var refreshToken: String? = null private val mutex = Mutex() - - init { + private val initializationJob: Job = applicationScope.launch { mutex.withLock { val preferences = readPreferences() @@ -47,16 +47,20 @@ internal class DataStoreAuthTokenStore @Inject constructor( refreshToken = preferences[KEY_REFRESH_TOKEN] } } - } override fun getAccessToken(): String? = accessToken override fun getRefreshToken(): String? = refreshToken + override suspend fun awaitInitialized() { + initializationJob.join() + } + override suspend fun saveTokens( accessToken: String, refreshToken: String, ) { + awaitInitialized() mutex.withLock { dataStore.edit { preferences -> preferences[KEY_ACCESS_TOKEN] = accessToken @@ -68,6 +72,7 @@ internal class DataStoreAuthTokenStore @Inject constructor( } override suspend fun clear() { + awaitInitialized() mutex.withLock { dataStore.edit { preferences -> preferences.remove(KEY_ACCESS_TOKEN) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index 87c5b54d..2538e9dc 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -9,6 +9,8 @@ interface AuthRepository { fun getRefreshToken(): String? + suspend fun awaitTokenStoreInitialized() + suspend fun reissueToken(refreshToken: String): Result suspend fun logout(): AuthActionResult diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt index f1a02b05..77a1e100 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -17,6 +17,8 @@ class CheckLoginStatusUseCase @Inject constructor( private val authRepository: AuthRepository, ) { suspend operator fun invoke(): Boolean { + authRepository.awaitTokenStoreInitialized() + val accessToken = authRepository.getAccessToken() if (!accessToken.isNullOrBlank()) return true From 032403ec0451c796abfe67c18b5c45d33b17ce41 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 19 Apr 2026 12:15:48 +0900 Subject: [PATCH 23/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B2=BD=ED=97=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthTokenStore` 초기화 계약 및 대기 로직 추가** * `AuthTokenStore` 인터페이스에 영속 저장소 데이터가 메모리 캐시에 적재될 때까지 대기하는 `awaitInitialized()` 메서드를 추가하고 관련 KDoc을 상세화했습니다. * `CheckLoginStatusUseCase`에서 토큰 확인 전 `awaitTokenStoreInitialized()`를 호출하여 데이터 정합성을 보장하도록 개선했습니다. * **refactor: 토큰 재발급 실패 처리 로직 강화** * `AuthRepositoryImpl`에서 토큰 재발급(`reissueToken`) 실패 시, 복구 불가능한 에러 코드(`T001`: 토큰 무효, `U003`: 사용자 없음 등)인 경우 로컬 토큰을 즉시 삭제하도록 수정했습니다. * **refactor: 프로필 화면 UI/UX 개선** * `ProfileViewModel`: 인증 만료 시 메시지 출력 후 로그인 화면으로 이동하도록 이벤트 순서를 조정했습니다. * `ProfileScreen`: `Scaffold`와 개별 `SnackbarHost` 대신 공통 `LocalSnackbarHostState`를 사용하도록 변경하고 레이아웃 구조를 단순화했습니다. * **style: 코드 정리 및 가독성 개선** * `DataStoreAuthTokenStore` 내 불필요한 import 정리 및 `AuthRepositoryImpl` 내 response 처리 로직의 가독성을 높였습니다. --- .../data/repository/AuthRepositoryImpl.kt | 20 ++++++++++++++++++- .../core/datastore/auth/AuthTokenStore.kt | 11 ++++++++-- .../datastore/auth/DataStoreAuthTokenStore.kt | 2 +- .../usecase/auth/CheckLoginStatusUseCase.kt | 11 ++++++---- .../feature/profile/impl/ProfileScreen.kt | 11 +++------- .../feature/profile/impl/ProfileViewModel.kt | 2 +- 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 2db0ade0..e9cb534d 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse import javax.inject.Inject @@ -24,7 +25,17 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun reissueToken(refreshToken: String): Result = - authRemoteDataSource.reissueToken(refreshToken = refreshToken).toResult(::saveTokens) + when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { + is ApiResponse.Success -> Result.success(saveTokens(response.data)) + is ApiResponse.Failure.HttpError -> { + if (response.error.isRefreshUnrecoverable()) { + authTokenStore.clear() + } + response.toResult { error("Unreachable") } + } + + is ApiResponse.Failure.NetworkError -> response.toResult { error("Unreachable") } + } override suspend fun logout(): AuthActionResult { if (authTokenStore.getAccessToken() == null) return clearTokensAndAuthenticationRequired() @@ -34,6 +45,7 @@ internal class AuthRepositoryImpl @Inject constructor( authTokenStore.clear() AuthActionResult.Success } + is ApiResponse.Failure.HttpError -> response.toAuthActionResult() is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) } @@ -55,6 +67,7 @@ internal class AuthRepositoryImpl @Inject constructor( authTokenStore.clear() AuthActionResult.Success } + is ApiResponse.Failure.HttpError -> response.toAuthActionResult() is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) } @@ -84,6 +97,9 @@ internal class AuthRepositoryImpl @Inject constructor( AuthActionResult.Failure(throwable) } + private fun ApiErrorResponse?.isRefreshUnrecoverable(): Boolean = + this?.code == AUTHENTICATION_REQUIRED_CODE || this?.code == TOKEN_INVALID_CODE || this?.code == USER_NOT_FOUND_CODE + private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult { authTokenStore.clear() return AuthActionResult.AuthenticationRequired @@ -107,5 +123,7 @@ internal class AuthRepositoryImpl @Inject constructor( private companion object { const val AUTHENTICATION_REQUIRED_CODE = "U001" + const val TOKEN_INVALID_CODE = "T001" + const val USER_NOT_FOUND_CODE = "U003" } } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt index f75c10de..e4837424 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt @@ -1,7 +1,12 @@ package com.team.prezel.core.datastore.auth /** - * 인증 토큰의 현재 값을 동기 조회하고, 영속 저장소와의 동기화를 비동기로 처리하는 저장소 계약입니다. + * 인증 토큰의 메모리 캐시와 영속 저장소를 함께 관리하는 저장소 계약입니다. + * + * ### 초기화 계약 + * - 구현체는 생성 직후 영속 저장소의 값을 메모리 캐시에 적재할 수 있습니다. + * - 호출부는 동기 getter의 초기 상태가 필요할 때 먼저 [awaitInitialized]를 호출해야 합니다. + * - [awaitInitialized]가 정상 반환된 이후에는 동기 getter가 영속 저장소와 동기화된 최신 캐시 값을 반환해야 합니다. * * ### 동기 조회 계약 * - [getAccessToken], [getRefreshToken]은 메모리 캐시의 현재 값을 즉시 반환해야 합니다. @@ -11,13 +16,15 @@ package com.team.prezel.core.datastore.auth * ### 갱신 계약 * - [saveTokens], [clear]는 영속 저장소 반영과 메모리 캐시 갱신을 함께 수행합니다. * - 두 함수가 정상적으로 반환된 직후에는 동기 getter가 항상 최신 값을 반환해야 합니다. - * */ interface AuthTokenStore { fun getAccessToken(): String? fun getRefreshToken(): String? + /** + * 영속 저장소의 초기값을 메모리 캐시에 반영할 때까지 대기합니다. + */ suspend fun awaitInitialized() suspend fun saveTokens( diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt index caf415f6..b458c903 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt @@ -11,12 +11,12 @@ import androidx.datastore.preferences.preferencesDataStoreFile import com.team.prezel.core.datastore.di.ApplicationScope import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.Job import java.io.IOException import javax.inject.Inject import javax.inject.Singleton diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt index 77a1e100..6d6f3fa7 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -7,10 +7,13 @@ import javax.inject.Inject * 저장된 인증 토큰을 기반으로 현재 로그인 상태를 확인하는 UseCase. * * ### 동작 흐름 - * 1. 저장된 액세스 토큰이 존재하면 로그인 상태로 판단하여 `true`를 반환합니다. - * 2. 액세스 토큰이 없으면 저장된 리프레시 토큰 존재 여부를 확인합니다. - * 3. 리프레시 토큰도 없으면 `false`를 반환합니다. - * 4. 리프레시 토큰이 있으면 [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출해 재발급을 시도하고, 성공 여부를 반환합니다. + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.awaitTokenStoreInitialized]를 호출해 + * 토큰 저장소의 초기화 완료를 보장합니다. + * 2. 저장된 액세스 토큰이 존재하면 로그인 상태로 판단하여 `true`를 반환합니다. + * 3. 액세스 토큰이 없으면 저장된 리프레시 토큰 존재 여부를 확인합니다. + * 4. 리프레시 토큰도 없으면 `false`를 반환합니다. + * 5. 리프레시 토큰이 있으면 [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출해 + * 재발급을 시도하고, 성공 여부를 반환합니다. * */ class CheckLoginStatusUseCase @Inject constructor( diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 0bb7109d..512889cc 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -8,14 +8,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp @@ -23,6 +19,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.core.ui.LocalSnackbarHostState import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect import com.team.prezel.feature.profile.impl.model.ProfileUiMessage @@ -35,7 +32,7 @@ fun ProfileScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val navigator = LocalNavigator.current val resources = LocalResources.current - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostState = LocalSnackbarHostState.current LaunchedEffect(viewModel) { viewModel.uiEffect.collect { effect -> @@ -54,14 +51,12 @@ fun ProfileScreen( } } - Scaffold( + Column( modifier = modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { Column( modifier = Modifier .fillMaxSize() - .padding(it) .padding(horizontal = 20.dp), verticalArrangement = Arrangement.Center, ) { diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index fa397f38..3c313b07 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -90,8 +90,8 @@ class ProfileViewModel AuthActionResult.AuthenticationRequired -> { authManager.clearCurrentProvider() - _uiEffect.emit(ProfileUiEffect.NavigateToLogin) _uiEffect.emit(ProfileUiEffect.ShowMessage(ProfileUiMessage.AUTHENTICATION_EXPIRED)) + _uiEffect.emit(ProfileUiEffect.NavigateToLogin) } is AuthActionResult.Failure -> { From 3dc031986eecc6b59259c417647d1c5a4762da4d Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 19 Apr 2026 12:24:10 +0900 Subject: [PATCH 24/63] =?UTF-8?q?refactor:=20AuthRepositoryImpl=20?= =?UTF-8?q?=EB=82=B4=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 토큰 재발급 실패 시 예외 처리 로직 고도화** * `reissueToken` 호출 시 HTTP 에러가 발생하면 사유와 상관없이 `authTokenStore.clear()`를 호출하여 토큰을 초기화하도록 단순화했습니다. * 에러 반환 시 `ApiResponse` 확장 함수 대신 `ApiHttpException`을 명시적으로 생성하여 실패 결과(`Result.failure`)를 전달하도록 변경했습니다. * 네트워크 에러 발생 시에도 `ApiResponse.Failure.NetworkError`에서 추출한 throwable을 통해 실패 결과를 반환하도록 수정했습니다. * **refactor: 불필요한 에러 코드 및 유틸리티 로직 제거** * 특정 에러 코드(`TOKEN_INVALID_CODE`, `USER_NOT_FOUND_CODE`)에 의존하여 복구 가능 여부를 판단하던 `isRefreshUnrecoverable()` 확장 함수를 삭제했습니다. * 사용하지 않는 `AUTHENTICATION_REQUIRED_CODE`를 제외한 나머지 companion object 상수들을 정리했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index e9cb534d..8a546593 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -2,12 +2,12 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.toResult import com.team.prezel.core.datastore.auth.AuthTokenStore +import com.team.prezel.core.domain.error.ApiHttpException import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.datasource.AuthRemoteDataSource -import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse import javax.inject.Inject @@ -28,13 +28,18 @@ internal class AuthRepositoryImpl @Inject constructor( when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { is ApiResponse.Success -> Result.success(saveTokens(response.data)) is ApiResponse.Failure.HttpError -> { - if (response.error.isRefreshUnrecoverable()) { - authTokenStore.clear() - } - response.toResult { error("Unreachable") } + authTokenStore.clear() + Result.failure( + ApiHttpException( + status = response.error?.status, + code = response.error?.code, + message = response.error?.message, + cause = response.throwable, + ), + ) } - is ApiResponse.Failure.NetworkError -> response.toResult { error("Unreachable") } + is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) } override suspend fun logout(): AuthActionResult { @@ -97,9 +102,6 @@ internal class AuthRepositoryImpl @Inject constructor( AuthActionResult.Failure(throwable) } - private fun ApiErrorResponse?.isRefreshUnrecoverable(): Boolean = - this?.code == AUTHENTICATION_REQUIRED_CODE || this?.code == TOKEN_INVALID_CODE || this?.code == USER_NOT_FOUND_CODE - private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult { authTokenStore.clear() return AuthActionResult.AuthenticationRequired @@ -123,7 +125,5 @@ internal class AuthRepositoryImpl @Inject constructor( private companion object { const val AUTHENTICATION_REQUIRED_CODE = "U001" - const val TOKEN_INVALID_CODE = "T001" - const val USER_NOT_FOUND_CODE = "U003" } } From 9ae183adca202203312a29155a12ae488983e22e Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 19 Apr 2026 12:32:26 +0900 Subject: [PATCH 25/63] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `LoginStatusResult` 도메인 모델 추가** * 로그인 상태를 명확하게 구분하기 위해 `Authenticated`, `Unauthenticated`, `RetryableFailure` 상태를 포함하는 sealed interface를 추가했습니다. * **refactor: `CheckLoginStatusUseCase` 반환 타입 및 예외 처리 변경** * 반환 타입을 `Boolean`에서 `LoginStatusResult`로 변경하여 세부적인 상태를 전달하도록 수정했습니다. * 토큰 재발급 실패 시, `ApiHttpException`인 경우 인증되지 않음(`Unauthenticated`)으로 처리하고, 그 외의 네트워크 오류 등은 재시도 가능 실패(`RetryableFailure`)로 분류하도록 로직을 강화했습니다. * **feat: Splash 화면 내 네트워크 오류 대응 로직 구현** * `SplashUiEffect`에 `ShowRetryableFailureMessage`를 추가했습니다. * `SplashViewModel`에서 `CheckLoginStatusUseCase` 결과에 따라 적절한 UI Effect를 발생시키도록 수정했습니다. * `SplashScreen`에서 재시도 가능한 실패 발생 시 스낵바 메시지를 출력하도록 구현하고, 관련 문자열 리소스를 추가했습니다. --- .../domain/result/auth/LoginStatusResult.kt | 11 ++++++++ .../usecase/auth/CheckLoginStatusUseCase.kt | 28 ++++++++++++++----- .../feature/splash/impl/SplashScreen.kt | 16 +++++++++++ .../feature/splash/impl/SplashViewModel.kt | 9 +++--- .../splash/impl/contract/SplashUiEffect.kt | 2 ++ .../impl/src/main/res/values/strings.xml | 3 ++ 6 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt create mode 100644 Prezel/feature/splash/impl/src/main/res/values/strings.xml diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt new file mode 100644 index 00000000..a06d2e01 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.result.auth + +sealed interface LoginStatusResult { + data object Authenticated : LoginStatusResult + + data object Unauthenticated : LoginStatusResult + + data class RetryableFailure( + val throwable: Throwable, + ) : LoginStatusResult +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt index 6d6f3fa7..b7dcfbb0 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -1,6 +1,8 @@ package com.team.prezel.core.domain.usecase.auth +import com.team.prezel.core.domain.error.ApiHttpException import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.result.auth.LoginStatusResult import javax.inject.Inject /** @@ -9,25 +11,37 @@ import javax.inject.Inject * ### 동작 흐름 * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.awaitTokenStoreInitialized]를 호출해 * 토큰 저장소의 초기화 완료를 보장합니다. - * 2. 저장된 액세스 토큰이 존재하면 로그인 상태로 판단하여 `true`를 반환합니다. + * 2. 저장된 액세스 토큰이 존재하면 [LoginStatusResult.Authenticated]를 반환합니다. * 3. 액세스 토큰이 없으면 저장된 리프레시 토큰 존재 여부를 확인합니다. - * 4. 리프레시 토큰도 없으면 `false`를 반환합니다. + * 4. 리프레시 토큰도 없으면 [LoginStatusResult.Unauthenticated]를 반환합니다. * 5. 리프레시 토큰이 있으면 [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출해 - * 재발급을 시도하고, 성공 여부를 반환합니다. + * 재발급을 시도합니다. + * 6. 재발급에 성공하면 [LoginStatusResult.Authenticated]를 반환합니다. + * 7. 재발급이 HTTP 오류로 실패하면 인증 복구 불가로 간주하고 [LoginStatusResult.Unauthenticated]를 반환합니다. + * 8. 재발급이 네트워크 오류 등 일시적인 실패로 끝나면 [LoginStatusResult.RetryableFailure]를 반환합니다. * */ class CheckLoginStatusUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(): Boolean { + suspend operator fun invoke(): LoginStatusResult { authRepository.awaitTokenStoreInitialized() val accessToken = authRepository.getAccessToken() - if (!accessToken.isNullOrBlank()) return true + if (!accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated val refreshToken = authRepository.getRefreshToken() - if (refreshToken.isNullOrBlank()) return false + if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated - return authRepository.reissueToken(refreshToken).isSuccess + return authRepository.reissueToken(refreshToken).fold( + onSuccess = { LoginStatusResult.Authenticated }, + onFailure = { throwable -> + if (throwable is ApiHttpException) { + LoginStatusResult.Unauthenticated + } else { + LoginStatusResult.RetryableFailure(throwable) + } + }, + ) } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt index c949e706..b46a0c2e 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt @@ -11,15 +11,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.LocalSnackbarHostState import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent +import kotlinx.coroutines.launch import com.team.prezel.core.designsystem.R as DSR @Composable @@ -30,6 +35,10 @@ internal fun SharedTransitionScope.SplashScreen( modifier: Modifier = Modifier, viewModel: SplashViewModel = hiltViewModel(), ) { + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { viewModel.onIntent(SplashUiIntent.CheckLoginStatus) @@ -37,6 +46,13 @@ internal fun SharedTransitionScope.SplashScreen( when (effect) { SplashUiEffect.NavigateToHome -> navigateToHome() SplashUiEffect.NavigateToLogin -> navigateToLogin() + SplashUiEffect.ShowRetryableFailureMessage -> { + coroutineScope.launch { + snackbarHostState.showPrezelSnackbar( + resources.getString(R.string.feature_splash_impl_retryable_failure), + ) + } + } } } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index 116c0403..dd540003 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -1,6 +1,7 @@ package com.team.prezel.feature.splash.impl import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.domain.usecase.auth.CheckLoginStatusUseCase import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.feature.splash.impl.contract.SplashUiEffect @@ -27,10 +28,10 @@ internal class SplashViewModel @Inject constructor( viewModelScope .launch { - if (checkLoginStatusUseCase()) { - sendEffect(SplashUiEffect.NavigateToHome) - } else { - sendEffect(SplashUiEffect.NavigateToLogin) + when (checkLoginStatusUseCase()) { + LoginStatusResult.Authenticated -> sendEffect(SplashUiEffect.NavigateToHome) + LoginStatusResult.Unauthenticated -> sendEffect(SplashUiEffect.NavigateToLogin) + is LoginStatusResult.RetryableFailure -> sendEffect(SplashUiEffect.ShowRetryableFailureMessage) } }.invokeOnCompletion { updateState { copy(isLoading = false) } } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt index 82273d89..ed4ce960 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt @@ -6,4 +6,6 @@ sealed interface SplashUiEffect : UiEffect { data object NavigateToHome : SplashUiEffect data object NavigateToLogin : SplashUiEffect + + data object ShowRetryableFailureMessage : SplashUiEffect } diff --git a/Prezel/feature/splash/impl/src/main/res/values/strings.xml b/Prezel/feature/splash/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..4b333fec --- /dev/null +++ b/Prezel/feature/splash/impl/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 네트워크 상태를 확인한 뒤 다시 시도해 주세요. + From 388517796a82b365a9338da6400b1275003e6ebe Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 19 Apr 2026 12:54:30 +0900 Subject: [PATCH 26/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=98=88=EC=99=B8=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `AuthRepository` 및 토큰 재발급 결과 처리 로직 개선** * `reissueToken`의 반환 타입을 `Result`에서 `LoginStatusResult`로 변경하여 인증 상태를 명확하게 관리하도록 수정했습니다. * 토큰 재발급 성공 시 토큰을 저장하고 `Authenticated` 상태를 반환하며, HTTP 에러 발생 시 토큰 저장소를 비우고 `Unauthenticated` 상태를 반환하도록 로직을 강화했습니다. * `CheckLoginStatusUseCase`에서 수동으로 예외를 검사하던 로직을 `AuthRepository`에서 반환된 `LoginStatusResult`를 바로 사용하도록 단순화했습니다. * **refactor: 불필요한 에러 모델 및 확장 함수 정리** * 사용되지 않는 커스텀 예외 클래스인 `ApiHttpException`을 삭제했습니다. * `ApiResponse.toResult` 확장 함수에서 `ApiHttpException`으로 변환하던 로직을 제거하고 원본 `throwable`을 그대로 반환하도록 수정했습니다. * `ReissueTokenUseCase`가 `AuthRepository`의 변경된 구조에 흡수됨에 따라 해당 클래스를 삭제했습니다. * **fix: UI 관련 버그 수정 및 안정성 향상** * `SplashScreen`: 로그인 상태 체크와 UI 효과 수집(collect) 로직이 서로 간섭하지 않도록 별도의 `LaunchedEffect` 블록으로 분리했습니다. * `ProfileScreen`: 스낵바 표시 시 `rememberCoroutineScope`를 사용하여 코루틴 내에서 안전하게 호출되도록 수정했습니다. --- .../team/prezel/core/data/ApiResponseExt.kt | 11 +--------- .../data/repository/AuthRepositoryImpl.kt | 20 ++++++++----------- .../core/domain/error/ApiHttpException.kt | 8 -------- .../domain/repository/auth/AuthRepository.kt | 3 ++- .../usecase/auth/CheckLoginStatusUseCase.kt | 14 ++----------- .../usecase/auth/ReissueTokenUseCase.kt | 20 ------------------- .../feature/profile/impl/ProfileScreen.kt | 7 ++++++- .../feature/splash/impl/SplashScreen.kt | 2 ++ 8 files changed, 21 insertions(+), 64 deletions(-) delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/ReissueTokenUseCase.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt index f5511ec3..94f74038 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt @@ -1,19 +1,10 @@ package com.team.prezel.core.data -import com.team.prezel.core.domain.error.ApiHttpException import com.team.prezel.core.network.model.ApiResponse internal suspend inline fun ApiResponse.toResult(crossinline transform: suspend (T) -> R): Result = when (this) { is ApiResponse.Success -> Result.success(transform(data)) - is ApiResponse.Failure.HttpError -> - Result.failure( - ApiHttpException( - status = error?.status, - code = error?.code, - message = error?.message, - cause = throwable, - ), - ) + is ApiResponse.Failure.HttpError -> Result.failure(throwable) is ApiResponse.Failure.NetworkError -> Result.failure(throwable) } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 8a546593..bf737d73 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -2,9 +2,9 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.toResult import com.team.prezel.core.datastore.auth.AuthTokenStore -import com.team.prezel.core.domain.error.ApiHttpException import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.result.auth.AuthActionResult +import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.datasource.AuthRemoteDataSource @@ -24,22 +24,18 @@ internal class AuthRepositoryImpl @Inject constructor( authTokenStore.awaitInitialized() } - override suspend fun reissueToken(refreshToken: String): Result = + override suspend fun reissueToken(refreshToken: String): LoginStatusResult = when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { - is ApiResponse.Success -> Result.success(saveTokens(response.data)) + is ApiResponse.Success -> { + saveTokens(response.data) + LoginStatusResult.Authenticated + } is ApiResponse.Failure.HttpError -> { authTokenStore.clear() - Result.failure( - ApiHttpException( - status = response.error?.status, - code = response.error?.code, - message = response.error?.message, - cause = response.throwable, - ), - ) + LoginStatusResult.Unauthenticated } - is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + is ApiResponse.Failure.NetworkError -> LoginStatusResult.RetryableFailure(response.throwable) } override suspend fun logout(): AuthActionResult { diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt deleted file mode 100644 index 9d3bfbc8..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/error/ApiHttpException.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.team.prezel.core.domain.error - -class ApiHttpException( - val status: Int?, - val code: String?, - override val message: String?, - cause: Throwable, -) : RuntimeException(message ?: cause.message, cause) diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index 2538e9dc..c1d2c604 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.domain.repository.auth import com.team.prezel.core.domain.result.auth.AuthActionResult +import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason @@ -11,7 +12,7 @@ interface AuthRepository { suspend fun awaitTokenStoreInitialized() - suspend fun reissueToken(refreshToken: String): Result + suspend fun reissueToken(refreshToken: String): LoginStatusResult suspend fun logout(): AuthActionResult diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt index b7dcfbb0..c02d145e 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -1,6 +1,5 @@ package com.team.prezel.core.domain.usecase.auth -import com.team.prezel.core.domain.error.ApiHttpException import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.result.auth.LoginStatusResult import javax.inject.Inject @@ -17,7 +16,7 @@ import javax.inject.Inject * 5. 리프레시 토큰이 있으면 [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출해 * 재발급을 시도합니다. * 6. 재발급에 성공하면 [LoginStatusResult.Authenticated]를 반환합니다. - * 7. 재발급이 HTTP 오류로 실패하면 인증 복구 불가로 간주하고 [LoginStatusResult.Unauthenticated]를 반환합니다. + * 7. 재발급이 인증 복구 불가로 실패하면 [LoginStatusResult.Unauthenticated]를 반환합니다. * 8. 재발급이 네트워크 오류 등 일시적인 실패로 끝나면 [LoginStatusResult.RetryableFailure]를 반환합니다. * */ @@ -33,15 +32,6 @@ class CheckLoginStatusUseCase @Inject constructor( val refreshToken = authRepository.getRefreshToken() if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated - return authRepository.reissueToken(refreshToken).fold( - onSuccess = { LoginStatusResult.Authenticated }, - onFailure = { throwable -> - if (throwable is ApiHttpException) { - LoginStatusResult.Unauthenticated - } else { - LoginStatusResult.RetryableFailure(throwable) - } - }, - ) + return authRepository.reissueToken(refreshToken) } } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/ReissueTokenUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/ReissueTokenUseCase.kt deleted file mode 100644 index eebe1ce1..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/ReissueTokenUseCase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.team.prezel.core.domain.usecase.auth - -import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.model.auth.AuthToken -import javax.inject.Inject - -/** - * 리프레시 토큰을 기반으로 인증 토큰 재발급을 요청하는 UseCase. - * - * ### 동작 흐름 - * 1. 호출부로부터 전달받은 리프레시 토큰을 입력값으로 받습니다. - * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출하여 서버에 토큰 재발급을 요청합니다. - * 3. 재발급 결과에 따라 새로운 [AuthToken] 또는 예외를 포함한 [Result]를 반환합니다. - * - */ -class ReissueTokenUseCase @Inject constructor( - private val authRepository: AuthRepository, -) { - suspend operator fun invoke(refreshToken: String): Result = authRepository.reissueToken(refreshToken = refreshToken) -} diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 512889cc..278d1cc2 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp @@ -23,6 +24,7 @@ import com.team.prezel.core.ui.LocalSnackbarHostState import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect import com.team.prezel.feature.profile.impl.model.ProfileUiMessage +import kotlinx.coroutines.launch @Composable fun ProfileScreen( @@ -33,6 +35,7 @@ fun ProfileScreen( val navigator = LocalNavigator.current val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() LaunchedEffect(viewModel) { viewModel.uiEffect.collect { effect -> @@ -45,7 +48,9 @@ fun ProfileScreen( ProfileUiMessage.WITHDRAW_FAILED -> R.string.feature_profile_impl_withdraw_failed } - snackbarHostState.showPrezelSnackbar(resources.getString(resId)) + coroutineScope.launch { + snackbarHostState.showPrezelSnackbar(resources.getString(resId)) + } } } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt index b46a0c2e..8c31bad7 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt @@ -41,7 +41,9 @@ internal fun SharedTransitionScope.SplashScreen( LaunchedEffect(Unit) { viewModel.onIntent(SplashUiIntent.CheckLoginStatus) + } + LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { SplashUiEffect.NavigateToHome -> navigateToHome() From da2c7be9755146d5f73f60f2f17a5df941d7aa1a Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 19 Apr 2026 13:07:35 +0900 Subject: [PATCH 27/63] =?UTF-8?q?refactor:=20Splash=20=EB=B0=8F=20Profile?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20UI=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: ProfileScreen 구조 개선 및 리팩터링** * `ProfileScreen`의 UI 렌더링 부분을 `ProfileScreenContent`로 분리하여 가독성을 높였습니다. * `ProfileUiMessage`를 리소스 ID로 변환하는 로직을 별도 확장 함수(`toTextRes`)로 추출했습니다. * 불필요한 `rememberCoroutineScope`를 제거하고 `LaunchedEffect` 내에서 직접 스낵바를 호출하도록 수정했습니다. * **refactor: AuthRepositoryImpl 내 로그인 상태 체크 로직 보완** * HTTP 에러 발생 시 특정 에러 코드(`AUTHENTICATION_REQUIRED_CODE`)인 경우에만 토큰을 삭제하고 미인증 상태로 처리하도록 변경했습니다. * 그 외의 HTTP 에러는 `RetryableFailure`로 분류하여 예외 처리 로직을 강화했습니다. * **refactor: Splash 및 Profile 뷰모델 로깅 및 로직 개선** * `SplashViewModel`: 로그인 상태 확인 실패 시 `Timber`를 이용한 경고 로그를 추가했습니다. * `ProfileViewModel`: 로그 출력 시 사용하던 불필요한 태그(`ProfileTest`)를 제거하고 일관된 로깅 방식을 적용했습니다. * `SplashScreen`: 불필요한 코루틴 스코프 생성을 제거하고 스낵바 호출 로직을 단순화했습니다. * **style: 가시성 제한자 수정** * `ProfileScreen`의 가시성을 `internal`로 변경하여 모듈 캡슐화를 강화했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 9 +++- .../feature/profile/impl/ProfileScreen.kt | 45 ++++++++++++------- .../feature/profile/impl/ProfileViewModel.kt | 4 +- .../feature/splash/impl/SplashScreen.kt | 11 ++--- .../feature/splash/impl/SplashViewModel.kt | 8 +++- 5 files changed, 46 insertions(+), 31 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index bf737d73..93b131a0 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -30,9 +30,14 @@ internal class AuthRepositoryImpl @Inject constructor( saveTokens(response.data) LoginStatusResult.Authenticated } + is ApiResponse.Failure.HttpError -> { - authTokenStore.clear() - LoginStatusResult.Unauthenticated + if (response.error?.code == AUTHENTICATION_REQUIRED_CODE) { + authTokenStore.clear() + LoginStatusResult.Unauthenticated + } else { + LoginStatusResult.RetryableFailure(response.throwable) + } } is ApiResponse.Failure.NetworkError -> LoginStatusResult.RetryableFailure(response.throwable) diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 278d1cc2..5a15b2c9 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp @@ -23,11 +22,11 @@ import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.ui.LocalSnackbarHostState import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect +import com.team.prezel.feature.profile.impl.contract.ProfileUiState import com.team.prezel.feature.profile.impl.model.ProfileUiMessage -import kotlinx.coroutines.launch @Composable -fun ProfileScreen( +internal fun ProfileScreen( modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel(), ) { @@ -35,27 +34,32 @@ fun ProfileScreen( val navigator = LocalNavigator.current val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - val coroutineScope = rememberCoroutineScope() LaunchedEffect(viewModel) { viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToLogin -> navigator.replaceRoot(LoginNavKey) - is ProfileUiEffect.ShowMessage -> { - val resId = when (effect.message) { - ProfileUiMessage.AUTHENTICATION_EXPIRED -> R.string.feature_profile_impl_authentication_expired - ProfileUiMessage.LOGOUT_FAILED -> R.string.feature_profile_impl_logout_failed - ProfileUiMessage.WITHDRAW_FAILED -> R.string.feature_profile_impl_withdraw_failed - } - - coroutineScope.launch { - snackbarHostState.showPrezelSnackbar(resources.getString(resId)) - } - } + is ProfileUiEffect.ShowMessage -> + snackbarHostState.showPrezelSnackbar(resources.getString(effect.message.toTextRes())) } } } + ProfileScreenContent( + uiState = uiState, + onLogout = viewModel::logout, + onWithdraw = viewModel::withdraw, + modifier = modifier, + ) +} + +@Composable +private fun ProfileScreenContent( + uiState: ProfileUiState, + onLogout: () -> Unit, + onWithdraw: () -> Unit, + modifier: Modifier = Modifier, +) { Column( modifier = modifier.fillMaxSize(), ) { @@ -66,7 +70,7 @@ fun ProfileScreen( verticalArrangement = Arrangement.Center, ) { Button( - onClick = viewModel::logout, + onClick = onLogout, enabled = !uiState.isLoading, modifier = Modifier.fillMaxWidth(), ) { @@ -74,7 +78,7 @@ fun ProfileScreen( } Spacer(modifier = Modifier.height(12.dp)) Button( - onClick = viewModel::withdraw, + onClick = onWithdraw, enabled = !uiState.isLoading, modifier = Modifier.fillMaxWidth(), ) { @@ -83,3 +87,10 @@ fun ProfileScreen( } } } + +private fun ProfileUiMessage.toTextRes(): Int = + when (this) { + ProfileUiMessage.AUTHENTICATION_EXPIRED -> R.string.feature_profile_impl_authentication_expired + ProfileUiMessage.LOGOUT_FAILED -> R.string.feature_profile_impl_logout_failed + ProfileUiMessage.WITHDRAW_FAILED -> R.string.feature_profile_impl_withdraw_failed + } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index 3c313b07..76316c89 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -83,7 +83,7 @@ class ProfileViewModel authManager .logout() .onFailure { throwable -> - Timber.tag("ProfileTest").w(throwable, "로컬 인증 세션 정리에 실패했습니다.") + Timber.w(throwable, "로컬 인증 세션 정리에 실패했습니다.") } _uiEffect.emit(ProfileUiEffect.NavigateToLogin) } @@ -95,7 +95,7 @@ class ProfileViewModel } is AuthActionResult.Failure -> { - Timber.tag("ProfileTest").e(result.throwable, failureLog) + Timber.e(result.throwable, failureLog) _uiEffect.emit(ProfileUiEffect.ShowMessage(failureMessage)) } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt index 8c31bad7..dd374451 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources @@ -24,7 +23,6 @@ import com.team.prezel.core.ui.LocalSnackbarHostState import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent -import kotlinx.coroutines.launch import com.team.prezel.core.designsystem.R as DSR @Composable @@ -37,7 +35,6 @@ internal fun SharedTransitionScope.SplashScreen( ) { val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - val coroutineScope = rememberCoroutineScope() LaunchedEffect(Unit) { viewModel.onIntent(SplashUiIntent.CheckLoginStatus) @@ -49,11 +46,9 @@ internal fun SharedTransitionScope.SplashScreen( SplashUiEffect.NavigateToHome -> navigateToHome() SplashUiEffect.NavigateToLogin -> navigateToLogin() SplashUiEffect.ShowRetryableFailureMessage -> { - coroutineScope.launch { - snackbarHostState.showPrezelSnackbar( - resources.getString(R.string.feature_splash_impl_retryable_failure), - ) - } + snackbarHostState.showPrezelSnackbar( + resources.getString(R.string.feature_splash_impl_retryable_failure), + ) } } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index dd540003..e3f887f7 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -9,6 +9,7 @@ import com.team.prezel.feature.splash.impl.contract.SplashUiIntent import com.team.prezel.feature.splash.impl.contract.SplashUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -28,10 +29,13 @@ internal class SplashViewModel @Inject constructor( viewModelScope .launch { - when (checkLoginStatusUseCase()) { + when (val result = checkLoginStatusUseCase()) { LoginStatusResult.Authenticated -> sendEffect(SplashUiEffect.NavigateToHome) LoginStatusResult.Unauthenticated -> sendEffect(SplashUiEffect.NavigateToLogin) - is LoginStatusResult.RetryableFailure -> sendEffect(SplashUiEffect.ShowRetryableFailureMessage) + is LoginStatusResult.RetryableFailure -> { + Timber.w(result.throwable, "로그인 상태 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.") + sendEffect(SplashUiEffect.ShowRetryableFailureMessage) + } } }.invokeOnCompletion { updateState { copy(isLoading = false) } } } From 70f7aa77d198949b6d1a54385da82b18279ef879 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 20 Apr 2026 17:20:28 +0900 Subject: [PATCH 28/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20Repository=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20UseCase=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: AuthRepository 및 CheckLoginStatusUseCase 개선** * `CheckLoginStatusUseCase`에 흩어져 있던 로그인 상태 확인 로직(토큰 존재 여부 확인 및 재발급 시도)을 `AuthRepository.checkLoginStatus()`로 캡슐화하여 일원화했습니다. * `AuthRepository`에서 외부로 노출되던 `getAccessToken`, `getRefreshToken`, `reissueToken` 등의 저수준 메서드를 인터페이스에서 제거했습니다. * `AuthRepositoryImpl` 내부에 `awaitTokenStoreInitialized` 로직을 통합하여 상태 확인 시 초기화를 보장하도록 수정했습니다. * **refactor: LoginUseCase 및 ViewModel 리팩터링** * `LoginUseCase`의 반환 타입을 `Result`에서 `Result`으로 변경하여 호출부에서 불필요한 토큰 데이터에 의존하지 않도록 개선했습니다. * `LoginViewModel`에서 `AuthRepository`를 직접 참조하던 방식을 `LoginUseCase`를 사용하도록 변경하여 도메인 레이어 의존성 원칙을 준수했습니다. * **docs: UseCase KDoc 업데이트** * 로직 변경에 맞춰 `LoginUseCase` 및 `CheckLoginStatusUseCase`의 동작 흐름 설명을 최신화했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 23 ++++++++++------ .../domain/repository/auth/AuthRepository.kt | 11 ++------ .../usecase/auth/CheckLoginStatusUseCase.kt | 25 +++-------------- .../core/domain/usecase/auth/LoginUseCase.kt | 5 ++-- .../login/impl/landing/LoginViewModel.kt | 27 +++++++++---------- 5 files changed, 35 insertions(+), 56 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 93b131a0..1da9d43a 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -16,16 +16,16 @@ internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, private val authTokenStore: AuthTokenStore, ) : AuthRepository { - override fun getAccessToken(): String? = authTokenStore.getAccessToken() + override suspend fun checkLoginStatus(): LoginStatusResult { + authTokenStore.awaitInitialized() - override fun getRefreshToken(): String? = authTokenStore.getRefreshToken() + val accessToken = authTokenStore.getAccessToken() + if (!accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated - override suspend fun awaitTokenStoreInitialized() { - authTokenStore.awaitInitialized() - } + val refreshToken = authTokenStore.getRefreshToken() + if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated - override suspend fun reissueToken(refreshToken: String): LoginStatusResult = - when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { + return when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { is ApiResponse.Success -> { saveTokens(response.data) LoginStatusResult.Authenticated @@ -42,6 +42,7 @@ internal class AuthRepositoryImpl @Inject constructor( is ApiResponse.Failure.NetworkError -> LoginStatusResult.RetryableFailure(response.throwable) } + } override suspend fun logout(): AuthActionResult { if (authTokenStore.getAccessToken() == null) return clearTokensAndAuthenticationRequired() @@ -57,7 +58,13 @@ internal class AuthRepositoryImpl @Inject constructor( } } - override suspend fun login(idToken: String): Result = authRemoteDataSource.login(idToken = idToken).toResult(::saveTokens) + override suspend fun login(idToken: String): Result = + authRemoteDataSource + .login(idToken = idToken) + .toResult { response -> + saveTokens(response) + Unit + } override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { if (authTokenStore.getAccessToken() == null) return clearTokensAndAuthenticationRequired() diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index c1d2c604..6350cecb 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -2,21 +2,14 @@ package com.team.prezel.core.domain.repository.auth import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.result.auth.LoginStatusResult -import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason interface AuthRepository { - fun getAccessToken(): String? - - fun getRefreshToken(): String? - - suspend fun awaitTokenStoreInitialized() - - suspend fun reissueToken(refreshToken: String): LoginStatusResult + suspend fun checkLoginStatus(): LoginStatusResult suspend fun logout(): AuthActionResult - suspend fun login(idToken: String): Result + suspend fun login(idToken: String): Result suspend fun withdraw(reason: WithdrawReason): AuthActionResult } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt index c02d145e..adb6a4fe 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -8,30 +8,13 @@ import javax.inject.Inject * 저장된 인증 토큰을 기반으로 현재 로그인 상태를 확인하는 UseCase. * * ### 동작 흐름 - * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.awaitTokenStoreInitialized]를 호출해 - * 토큰 저장소의 초기화 완료를 보장합니다. - * 2. 저장된 액세스 토큰이 존재하면 [LoginStatusResult.Authenticated]를 반환합니다. - * 3. 액세스 토큰이 없으면 저장된 리프레시 토큰 존재 여부를 확인합니다. - * 4. 리프레시 토큰도 없으면 [LoginStatusResult.Unauthenticated]를 반환합니다. - * 5. 리프레시 토큰이 있으면 [com.team.prezel.core.domain.repository.auth.AuthRepository.reissueToken]을 호출해 - * 재발급을 시도합니다. - * 6. 재발급에 성공하면 [LoginStatusResult.Authenticated]를 반환합니다. - * 7. 재발급이 인증 복구 불가로 실패하면 [LoginStatusResult.Unauthenticated]를 반환합니다. - * 8. 재발급이 네트워크 오류 등 일시적인 실패로 끝나면 [LoginStatusResult.RetryableFailure]를 반환합니다. + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.checkLoginStatus]를 호출합니다. + * 2. 저장된 토큰 및 재발급 여부를 포함한 로그인 상태 판별은 repository 내부에서 처리합니다. + * 3. 판별 결과로 [LoginStatusResult]를 반환합니다. * */ class CheckLoginStatusUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(): LoginStatusResult { - authRepository.awaitTokenStoreInitialized() - - val accessToken = authRepository.getAccessToken() - if (!accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated - - val refreshToken = authRepository.getRefreshToken() - if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated - - return authRepository.reissueToken(refreshToken) - } + suspend operator fun invoke(): LoginStatusResult = authRepository.checkLoginStatus() } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt index 9d6e1a52..77ba85e4 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt @@ -1,7 +1,6 @@ package com.team.prezel.core.domain.usecase.auth import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.model.auth.AuthToken import javax.inject.Inject /** @@ -10,11 +9,11 @@ import javax.inject.Inject * ### 동작 흐름 * 1. 호출부로부터 전달받은 ID 토큰을 입력값으로 받습니다. * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.login]을 호출하여 서버 로그인 요청을 수행합니다. - * 3. 로그인 결과에 따라 [AuthToken] 또는 예외를 포함한 [Result]를 반환합니다. + * 3. 로그인 결과에 따라 성공 여부 또는 예외를 포함한 [Result]를 반환합니다. * */ class LoginUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(idToken: String): Result = authRepository.login(idToken = idToken) + suspend operator fun invoke(idToken: String): Result = authRepository.login(idToken = idToken) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 312ece4b..601a756c 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -3,7 +3,7 @@ package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult -import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.usecase.auth.LoginUseCase import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent @@ -15,7 +15,7 @@ import javax.inject.Inject @HiltViewModel internal class LoginViewModel @Inject constructor( - private val authRepository: AuthRepository, + private val loginUseCase: LoginUseCase, ) : BaseViewModel(LoginUiState()) { override fun onIntent(intent: LoginUiIntent) { when (intent) { @@ -56,19 +56,16 @@ internal class LoginViewModel @Inject constructor( } private suspend fun handleServerLogin(idToken: String) { - authRepository - .login( - idToken = idToken, - ).fold( - onSuccess = { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.NavigateToTerms) - }, - onFailure = { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) - }, - ) + loginUseCase(idToken = idToken).fold( + onSuccess = { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.NavigateToTerms) + }, + onFailure = { + updateState { copy(isLoading = false, pendingProvider = null) } + sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) + }, + ) } private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = From a9ac0eb4a7cdc46b7265ada27189357fab1e356d Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 21 Apr 2026 11:31:04 +0900 Subject: [PATCH 29/63] =?UTF-8?q?refactor:=20Ktor=20Auth=20=ED=94=8C?= =?UTF-8?q?=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: Ktor `Auth` 플러그인을 이용한 토큰 관리 및 자동 갱신 구현** * 기존 OkHttp 기반의 `TokenRefreshAuthenticator`를 삭제하고 Ktor 전용 `Auth` 플러그인으로 대체했습니다. * `loadTokens`: `AuthTokenStore`에서 Access/Refresh 토큰을 불러와 인증 헤더를 구성합니다. * `refreshTokens`: 토큰 만료 시 `AuthTokenRefresher`를 통해 토큰을 자동 갱신하도록 개선했습니다. * `sendWithoutRequest`: `AuthPathPolicy`를 참조하여 인증이 필요한 경로에만 토큰을 전송하도록 설정했습니다. * **feat: 네트워크 타임아웃 및 User-Agent 설정 추가** * `HttpTimeout` 플러그인을 추가하여 요청(15s), 연결(10s), 소켓(15s) 타임아웃을 설정했습니다. * `UserAgent` 플러그인을 도입하여 앱 버전, 안드로이드 OS 버전, 기기 모델 정보를 포함하는 커스텀 User-Agent를 구성했습니다. * **refactor: `AuthRepositoryImpl` 토큰 체크 로직 개선** * `logout` 및 `withdraw` 시 토큰 존재 여부를 확인할 때 `null` 체크 외에 `isNullOrBlank()`를 사용하도록 방어 로직을 강화했습니다. * **build: Ktor Auth 의존성 추가** * `libs.versions.toml` 및 `core:network` 모듈에 `ktor-client-auth` 의존성을 추가했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 4 +- Prezel/core/network/build.gradle.kts | 1 + .../network/auth/TokenRefreshAuthenticator.kt | 48 --------------- .../prezel/core/network/di/NetworkModule.kt | 61 +++++++++++++++---- Prezel/gradle/libs.versions.toml | 1 + 5 files changed, 52 insertions(+), 63 deletions(-) delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 1da9d43a..d3cc3528 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -45,7 +45,7 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun logout(): AuthActionResult { - if (authTokenStore.getAccessToken() == null) return clearTokensAndAuthenticationRequired() + if (authTokenStore.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() return when (val response = authRemoteDataSource.logout()) { is ApiResponse.Success -> { @@ -67,7 +67,7 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { - if (authTokenStore.getAccessToken() == null) return clearTokensAndAuthenticationRequired() + if (authTokenStore.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() return when ( val response = diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index 1398226b..b7d13d79 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(projects.coreDatastore) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.auth) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.logging) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt deleted file mode 100644 index 79800532..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenRefreshAuthenticator.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.team.prezel.core.network.auth - -import io.ktor.http.HttpHeaders -import kotlinx.coroutines.runBlocking -import okhttp3.Authenticator -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -internal class TokenRefreshAuthenticator @Inject constructor( - private val authTokenRefresher: AuthTokenRefresher, -) : Authenticator { - override fun authenticate( - route: Route?, - response: Response, - ): Request? { - if (!AuthPathPolicy.requiresAuthorization(response.request.url.encodedPath)) { - return null - } - if (responseCount(response) >= MAX_AUTH_RETRY_COUNT) return null - - val refreshedAccessToken = - runBlocking { authTokenRefresher.refreshAccessToken() } ?: return null - - return response.request - .newBuilder() - .removeHeader(HttpHeaders.Authorization) - .addHeader(HttpHeaders.Authorization, "Bearer $refreshedAccessToken") - .build() - } - - private fun responseCount(response: Response): Int { - var count = 1 - var current = response.priorResponse - while (current != null) { - count++ - current = current.priorResponse - } - return count - } - - private companion object { - const val MAX_AUTH_RETRY_COUNT = 2 - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 2e5fb055..48fdc5e8 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,10 +1,11 @@ package com.team.prezel.core.network.di +import android.os.Build import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.auth.AuthPathPolicy -import com.team.prezel.core.network.auth.TokenRefreshAuthenticator +import com.team.prezel.core.network.auth.AuthTokenRefresher import com.team.prezel.core.network.service.AuthService import com.team.prezel.core.network.service.createAuthService import dagger.Module @@ -15,6 +16,11 @@ import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel @@ -47,26 +53,38 @@ object NetworkModule { internal fun provideHttpClient( json: Json, authTokenStore: AuthTokenStore, - tokenRefreshAuthenticator: TokenRefreshAuthenticator, + authTokenRefresher: AuthTokenRefresher, ): HttpClient = HttpClient(OkHttp) { - engine { - config { - authenticator(tokenRefreshAuthenticator) - } - } - configureBaseClient(json) - defaultRequest { - contentType(ContentType.Application.Json) + install(Auth) { + bearer { + loadTokens { + val accessToken = authTokenStore.getAccessToken() + val refreshToken = authTokenStore.getRefreshToken() + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + null + } else { + BearerTokens(accessToken = accessToken, refreshToken = refreshToken) + } + } + + refreshTokens { + val refreshedAccessToken = authTokenRefresher.refreshAccessToken() ?: return@refreshTokens null + val refreshedRefreshToken = authTokenStore.getRefreshToken() ?: return@refreshTokens null + BearerTokens(accessToken = refreshedAccessToken, refreshToken = refreshedRefreshToken) + } - if (headers[HttpHeaders.Authorization] == null && AuthPathPolicy.requiresAuthorization(url.encodedPath)) { - authTokenStore.getAccessToken()?.let { accessToken -> - headers.append(HttpHeaders.Authorization, "Bearer $accessToken") + sendWithoutRequest { request -> + AuthPathPolicy.requiresAuthorization(request.url.encodedPath) } } } + + defaultRequest { + contentType(ContentType.Application.Json) + } } @Provides @@ -122,6 +140,16 @@ object NetworkModule { json(json) } + install(HttpTimeout) { + requestTimeoutMillis = REQUEST_TIMEOUT_MILLIS + connectTimeoutMillis = CONNECT_TIMEOUT_MILLIS + socketTimeoutMillis = SOCKET_TIMEOUT_MILLIS + } + + install(UserAgent) { + agent = buildUserAgent() + } + install(Logging) { logger = object : Logger { override fun log(message: String) { @@ -132,4 +160,11 @@ object NetworkModule { level = if (BuildConfig.DEBUG) LogLevel.HEADERS else LogLevel.NONE } } + + private const val REQUEST_TIMEOUT_MILLIS = 15_000L + private const val CONNECT_TIMEOUT_MILLIS = 10_000L + private const val SOCKET_TIMEOUT_MILLIS = 15_000L + + private fun buildUserAgent(): String = + "Prezel-Android/${BuildConfig.BUILD_TYPE} (Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})" } diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index b2083a2d..34f79f51 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -58,6 +58,7 @@ javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInjec ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } From a2e5c0158c5a2e113dd8e20b0b1eaa0ccb9856df Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 21 Apr 2026 11:54:56 +0900 Subject: [PATCH 30/63] build: merge origin/develop to feat/#103-auth-api2 --- .../prezel/core/data/di/RepositoryModule.kt | 7 +- .../feature/login/impl/landing/LoginScreen.kt | 1 - .../login/impl/landing/LoginViewModel.kt | 4 +- .../impl/landing/contract/LoginUiEffect.kt | 2 - .../impl/navigation/LoginEntryBuilder.kt | 3 - Prezel/feature/my/impl/build.gradle.kts | 5 + .../team/prezel/feature/my/impl/MyScreen.kt | 90 +++++++++++++++-- .../team/prezel/feature/my/impl/MyUiState.kt | 7 -- .../prezel/feature/my/impl/MyViewModel.kt | 97 ++++++++++++++++++- .../feature/my/impl/contract/MyUiEffect.kt | 11 +++ .../feature/my/impl/contract/MyUiIntent.kt | 7 ++ .../feature/my/impl/contract/MyUiState.kt | 7 ++ .../feature/my/impl/model/MyUiMessage.kt | 7 ++ .../my/impl/src/main/res/values/strings.xml | 6 +- .../profile/impl/model/ProfileUiMessage.kt | 3 - Prezel/gradle/libs.versions.toml | 1 - 16 files changed, 227 insertions(+), 31 deletions(-) delete mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt create mode 100644 Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/MyUiMessage.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index 7c41d664..b027abf2 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,9 +1,9 @@ package com.team.prezel.core.data.di -import com.team.prezel.core.data.repository.UserRepositoryImpl -import com.team.prezel.core.domain.repository.profile.UserRepository import com.team.prezel.core.data.repository.AuthRepositoryImpl +import com.team.prezel.core.data.repository.UserRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.repository.profile.UserRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -16,5 +16,8 @@ internal abstract class RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index 61fa0505..7f4bb243 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -57,7 +57,6 @@ internal fun SharedTransitionScope.LoginScreen( authManager: AuthManager, navigateToHome: () -> Unit, navigateToTerms: () -> Unit, - navigateToHome: () -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel(), ) { diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 1ce08a92..fb65664a 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -78,7 +78,7 @@ internal class LoginViewModel @Inject constructor( private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = when (this) { - AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited - AuthResult.Failure.Unknown -> LoginUiMessage.LoginFailedUnknown + AuthResult.Failure.RateLimited -> LoginUiMessage.LOGIN_FAILED_RATE_LIMITED + AuthResult.Failure.Unknown -> LoginUiMessage.LOGIN_FAILED_UNKNOWN } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index e9cca2b6..db8c403c 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -13,8 +13,6 @@ internal sealed interface LoginUiEffect : UiEffect { data object NavigateToTerms : LoginUiEffect - data object NavigateToHome : LoginUiEffect - data class ShowMessage( val message: LoginUiMessage, ) : LoginUiEffect diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt index a04b9e0d..a826dc45 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt @@ -29,9 +29,6 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au navigateToTerms = { navigator.navigate(LoginTermsNavKey) }, - navigateToHome = { - navigator.replaceRoot(HomeNavKey) - }, ) } } diff --git a/Prezel/feature/my/impl/build.gradle.kts b/Prezel/feature/my/impl/build.gradle.kts index 36c7169b..f7fa170c 100644 --- a/Prezel/feature/my/impl/build.gradle.kts +++ b/Prezel/feature/my/impl/build.gradle.kts @@ -7,5 +7,10 @@ android { } dependencies { + implementation(projects.coreAuth) + implementation(projects.coreDomain) + implementation(projects.coreModel) + + implementation(projects.featureLoginApi) implementation(projects.featureMyApi) } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt index d98dcb14..3f082e57 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt @@ -1,18 +1,96 @@ package com.team.prezel.feature.my.impl -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.core.ui.LocalSnackbarHostState +import com.team.prezel.feature.login.api.LoginNavKey +import com.team.prezel.feature.my.impl.contract.MyUiEffect +import com.team.prezel.feature.my.impl.contract.MyUiState +import com.team.prezel.feature.my.impl.model.MyUiMessage @Composable -fun MyScreen(modifier: Modifier = Modifier) { - Box( +internal fun MyScreen( + modifier: Modifier = Modifier, + viewModel: MyViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val navigator = LocalNavigator.current + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + MyUiEffect.NavigateToLogin -> navigator.replaceRoot(LoginNavKey) + is MyUiEffect.ShowMessage -> + snackbarHostState.showPrezelSnackbar(resources.getString(effect.message.toTextRes())) + } + } + } + + MyScreenContent( + uiState = uiState, + onLogout = viewModel::logout, + onWithdraw = viewModel::withdraw, + modifier = modifier, + ) +} + +@Composable +private fun MyScreenContent( + uiState: MyUiState, + onLogout: () -> Unit, + onWithdraw: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, ) { - Text("My") + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Center, + ) { + Button( + onClick = onLogout, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("로그아웃") + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onWithdraw, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("회원탈퇴") + } + } } } + +private fun MyUiMessage.toTextRes(): Int = + when (this) { + MyUiMessage.AUTHENTICATION_EXPIRED -> R.string.feature_my_impl_authentication_expired + MyUiMessage.LOGOUT_FAILED -> R.string.feature_my_impl_logout_failed + MyUiMessage.WITHDRAW_FAILED -> R.string.feature_my_impl_withdraw_failed + } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt deleted file mode 100644 index c5d3a633..00000000 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.feature.my.impl - -sealed interface MyUiState { - data object Loading : MyUiState - - data object LoadFailed : MyUiState -} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt index 657f39f1..72d09491 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -1,10 +1,101 @@ package com.team.prezel.feature.my.impl import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.auth.AuthManager +import com.team.prezel.core.domain.result.auth.AuthActionResult +import com.team.prezel.core.domain.usecase.auth.LogoutUseCase +import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase +import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.feature.my.impl.contract.MyUiEffect +import com.team.prezel.feature.my.impl.contract.MyUiState +import com.team.prezel.feature.my.impl.model.MyUiMessage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel -class MyViewModel - @Inject - constructor() : ViewModel() +internal class MyViewModel @Inject constructor( + private val authManager: AuthManager, + private val logoutUseCase: LogoutUseCase, + private val withdrawUseCase: WithdrawUseCase, +) : ViewModel() { + private val _uiState = MutableStateFlow(MyUiState()) + val uiState = _uiState.asStateFlow() + + private val _uiEffect = MutableSharedFlow() + val uiEffect = _uiEffect.asSharedFlow() + + fun logout() { + if (_uiState.value.isLoading) return + + viewModelScope.launch { + try { + _uiState.update { it.copy(isLoading = true) } + val result = logoutUseCase() + handleAuthActionResult( + result = result, + failureLog = "로그아웃에 실패했습니다.", + failureMessage = MyUiMessage.LOGOUT_FAILED, + ) + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } + + fun withdraw() { + if (_uiState.value.isLoading) return + + viewModelScope.launch { + try { + _uiState.update { it.copy(isLoading = true) } + val result = + withdrawUseCase( + reason = WithdrawReason.Other("임시 테스트 탈퇴"), + ) + handleAuthActionResult( + result = result, + failureLog = "회원탈퇴에 실패했습니다.", + failureMessage = MyUiMessage.WITHDRAW_FAILED, + ) + } finally { + _uiState.update { it.copy(isLoading = false) } + } + } + } + + private suspend fun handleAuthActionResult( + result: AuthActionResult, + failureLog: String, + failureMessage: MyUiMessage, + ) { + when (result) { + AuthActionResult.Success -> { + authManager + .logout() + .onFailure { throwable -> + Timber.w(throwable, "로컬 인증 세션 정리에 실패했습니다.") + } + _uiEffect.emit(MyUiEffect.NavigateToLogin) + } + + AuthActionResult.AuthenticationRequired -> { + authManager.clearCurrentProvider() + _uiEffect.emit(MyUiEffect.ShowMessage(MyUiMessage.AUTHENTICATION_EXPIRED)) + _uiEffect.emit(MyUiEffect.NavigateToLogin) + } + + is AuthActionResult.Failure -> { + Timber.e(result.throwable, failureLog) + _uiEffect.emit(MyUiEffect.ShowMessage(failureMessage)) + } + } + } +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt new file mode 100644 index 00000000..21d82549 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt @@ -0,0 +1,11 @@ +package com.team.prezel.feature.my.impl.contract + +import com.team.prezel.feature.my.impl.model.MyUiMessage + +sealed interface MyUiEffect { + data object NavigateToLogin : MyUiEffect + + data class ShowMessage( + val message: MyUiMessage, + ) : MyUiEffect +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt new file mode 100644 index 00000000..3dbe590c --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.impl.contract + +import com.team.prezel.core.ui.UiIntent + +internal sealed interface MyUiIntent : UiIntent { + data object FetchData : MyUiIntent +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt new file mode 100644 index 00000000..82db9008 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.impl.contract + +import com.team.prezel.core.ui.UiState + +internal data class MyUiState( + val isLoading: Boolean = false, +) : UiState diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/MyUiMessage.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/MyUiMessage.kt new file mode 100644 index 00000000..f47f3e05 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/MyUiMessage.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.impl.model + +enum class MyUiMessage { + LOGOUT_FAILED, + WITHDRAW_FAILED, + AUTHENTICATION_EXPIRED, +} diff --git a/Prezel/feature/my/impl/src/main/res/values/strings.xml b/Prezel/feature/my/impl/src/main/res/values/strings.xml index 545704f2..4547c141 100644 --- a/Prezel/feature/my/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/my/impl/src/main/res/values/strings.xml @@ -1,2 +1,6 @@ - + + 로그아웃에 실패했습니다. + 회원탈퇴에 실패했습니다. + 인증이 만료되었어요. 다시 로그인해 주세요. + diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt index 942f0ed1..150e2d83 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUiMessage.kt @@ -3,7 +3,4 @@ package com.team.prezel.feature.profile.impl.model enum class ProfileUiMessage { CHECK_NICKNAME_FAILED, FETCH_USER_INFO_FAILED, - LOGOUT_FAILED, - WITHDRAW_FAILED, - AUTHENTICATION_EXPIRED, } diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 515a984b..475d3217 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -4,7 +4,6 @@ kotlin = "2.3.0" javaxInject = "1" coil = "2.7.0" coreKtx = "1.17.0" -javaxInject = "1" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" From e9e480112b86a5a1dbe6cbee1c40afbc33d064b7 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 21 Apr 2026 12:07:17 +0900 Subject: [PATCH 31/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: Ktor Bearer 인증 토큰 로드 및 갱신 로직 개선** * `loadTokens` 단계에서 Access Token 존재 여부만 필수적으로 확인하도록 변경했습니다. (Refresh Token은 `orEmpty()`로 처리) * `refreshTokens` 과정에서 Refresh Token이 null인 경우에도 `BearerTokens`를 생성할 수 있도록 방어 로직을 수정했습니다. * `core:network` 모듈 내 `NetworkModule`의 토큰 처리 구조를 안정화했습니다. * **feat: 앱 실행 시 AuthTokenStore 조기 초기화 적용** * `PrezelApplication`에서 `AuthTokenStore`를 주입받아 `onCreate` 시점에 `getAccessToken()`을 호출하도록 추가했습니다. * 이를 통해 DataStore 기반의 토큰 저장소가 앱 런타임 시작과 동시에 백그라운드에서 미리 초기화되도록 개선했습니다. * **build: app 모듈 의존성 추가** * `app/build.gradle.kts`에 `core:datastore` 의존성을 추가하여 `AuthTokenStore` 접근을 허용했습니다. --- Prezel/app/build.gradle.kts | 1 + .../main/java/com/team/prezel/PrezelApplication.kt | 8 ++++++++ .../team/prezel/core/network/di/NetworkModule.kt | 14 +++++++++----- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 6ac709f8..08afd329 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -32,6 +32,7 @@ android { dependencies { implementation(projects.coreAuth) implementation(projects.coreData) + implementation(projects.coreDatastore) implementation(projects.coreDesignsystem) implementation(projects.coreNavigation) implementation(projects.coreUi) diff --git a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt index 4a7ff0bb..d96b956c 100644 --- a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt +++ b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt @@ -2,17 +2,25 @@ package com.team.prezel import android.app.Application import com.team.prezel.core.auth.AuthInitializer +import com.team.prezel.core.datastore.auth.AuthTokenStore import dagger.hilt.android.HiltAndroidApp import timber.log.Timber +import javax.inject.Inject @HiltAndroidApp class PrezelApplication : Application() { + @Inject + lateinit var authTokenStore: AuthTokenStore + override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } + // Eagerly create the token store so its background initialization starts at app launch. + authTokenStore.getAccessToken() + AuthInitializer.init(this) } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 48fdc5e8..efa4f91a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -62,18 +62,22 @@ object NetworkModule { bearer { loadTokens { val accessToken = authTokenStore.getAccessToken() - val refreshToken = authTokenStore.getRefreshToken() - if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + if (accessToken.isNullOrBlank()) { null } else { - BearerTokens(accessToken = accessToken, refreshToken = refreshToken) + BearerTokens( + accessToken = accessToken, + refreshToken = authTokenStore.getRefreshToken().orEmpty(), + ) } } refreshTokens { val refreshedAccessToken = authTokenRefresher.refreshAccessToken() ?: return@refreshTokens null - val refreshedRefreshToken = authTokenStore.getRefreshToken() ?: return@refreshTokens null - BearerTokens(accessToken = refreshedAccessToken, refreshToken = refreshedRefreshToken) + BearerTokens( + accessToken = refreshedAccessToken, + refreshToken = authTokenStore.getRefreshToken().orEmpty(), + ) } sendWithoutRequest { request -> From 0af079151d581a494e2dba9af6bab092bb84456e Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 21 Apr 2026 13:01:10 +0900 Subject: [PATCH 32/63] =?UTF-8?q?feat:=20AuthLocalDataSource=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20=EB=A1=9C=EC=BB=AC=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EC=83=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthLocalDataSource` 인터페이스 및 구현체 추가** * `AuthTokenStore`에 직접 의존하던 로직을 추상화하기 위해 `AuthLocalDataSource` 레이어를 추가했습니다. * 토큰 조회(`getAccessToken`, `getRefreshToken`), 저장(`saveTokens`), 초기화 대기(`awaitInitialized`), 삭제(`clear`) 기능을 포함합니다. * Hilt를 사용하여 `AuthLocalDataSourceImpl`을 `Singleton`으로 주입하도록 `LocalDataSourceModule`을 구성했습니다. * **refactor: `AuthRepositoryImpl` 내 데이터 소스 참조 변경** * 기존에 `AuthTokenStore`를 직접 참조하여 토큰을 관리하던 로직을 새로 추가된 `AuthLocalDataSource`를 거치도록 변경했습니다. * 로그인 상태 확인(`checkLoginStatus`), 로그아웃(`logout`), 회원 탈퇴(`withdraw`) 시 토큰 조작 로직을 일관성 있게 업데이트했습니다. --- .../data/datasource/AuthLocalDataSource.kt | 16 ++++++++++ .../datasource/AuthLocalDataSourceImpl.kt | 30 +++++++++++++++++++ .../core/data/di/LocalDataSourceModule.kt | 17 +++++++++++ .../data/repository/AuthRepositoryImpl.kt | 26 ++++++++-------- 4 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt new file mode 100644 index 00000000..2caeeb31 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt @@ -0,0 +1,16 @@ +package com.team.prezel.core.data.datasource + +internal interface AuthLocalDataSource { + suspend fun awaitInitialized() + + fun getAccessToken(): String? + + fun getRefreshToken(): String? + + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) + + suspend fun clear() +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt new file mode 100644 index 00000000..14674867 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt @@ -0,0 +1,30 @@ +package com.team.prezel.core.data.datasource + +import com.team.prezel.core.datastore.auth.AuthTokenStore +import javax.inject.Inject + +internal class AuthLocalDataSourceImpl @Inject constructor( + private val authTokenStore: AuthTokenStore, +) : AuthLocalDataSource { + override suspend fun awaitInitialized() { + authTokenStore.awaitInitialized() + } + + override fun getAccessToken(): String? = authTokenStore.getAccessToken() + + override fun getRefreshToken(): String? = authTokenStore.getRefreshToken() + + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + authTokenStore.saveTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + } + + override suspend fun clear() { + authTokenStore.clear() + } +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt new file mode 100644 index 00000000..13a0e351 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.data.di + +import com.team.prezel.core.data.datasource.AuthLocalDataSource +import com.team.prezel.core.data.datasource.AuthLocalDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class LocalDataSourceModule { + @Binds + @Singleton + abstract fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index d3cc3528..61e73de0 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -1,7 +1,7 @@ package com.team.prezel.core.data.repository +import com.team.prezel.core.data.datasource.AuthLocalDataSource import com.team.prezel.core.data.toResult -import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.result.auth.LoginStatusResult @@ -14,15 +14,15 @@ import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, - private val authTokenStore: AuthTokenStore, + private val authLocalDataSource: AuthLocalDataSource, ) : AuthRepository { override suspend fun checkLoginStatus(): LoginStatusResult { - authTokenStore.awaitInitialized() + authLocalDataSource.awaitInitialized() - val accessToken = authTokenStore.getAccessToken() + val accessToken = authLocalDataSource.getAccessToken() if (!accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated - val refreshToken = authTokenStore.getRefreshToken() + val refreshToken = authLocalDataSource.getRefreshToken() if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated return when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { @@ -33,7 +33,7 @@ internal class AuthRepositoryImpl @Inject constructor( is ApiResponse.Failure.HttpError -> { if (response.error?.code == AUTHENTICATION_REQUIRED_CODE) { - authTokenStore.clear() + authLocalDataSource.clear() LoginStatusResult.Unauthenticated } else { LoginStatusResult.RetryableFailure(response.throwable) @@ -45,11 +45,11 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun logout(): AuthActionResult { - if (authTokenStore.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() + if (authLocalDataSource.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() return when (val response = authRemoteDataSource.logout()) { is ApiResponse.Success -> { - authTokenStore.clear() + authLocalDataSource.clear() AuthActionResult.Success } @@ -67,7 +67,7 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { - if (authTokenStore.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() + if (authLocalDataSource.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() return when ( val response = @@ -77,7 +77,7 @@ internal class AuthRepositoryImpl @Inject constructor( ) ) { is ApiResponse.Success -> { - authTokenStore.clear() + authLocalDataSource.clear() AuthActionResult.Success } @@ -90,7 +90,7 @@ internal class AuthRepositoryImpl @Inject constructor( response .toAuthToken() .also { token -> - authTokenStore.saveTokens( + authLocalDataSource.saveTokens( accessToken = token.accessToken, refreshToken = token.refreshToken, ) @@ -104,14 +104,14 @@ internal class AuthRepositoryImpl @Inject constructor( private suspend fun ApiResponse.Failure.HttpError.toAuthActionResult(): AuthActionResult = if (error?.code == AUTHENTICATION_REQUIRED_CODE) { - authTokenStore.clear() + authLocalDataSource.clear() AuthActionResult.AuthenticationRequired } else { AuthActionResult.Failure(throwable) } private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult { - authTokenStore.clear() + authLocalDataSource.clear() return AuthActionResult.AuthenticationRequired } From 291d98f06bc7c94a6a278d7ff31c5e272e246d44 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Tue, 21 Apr 2026 13:04:56 +0900 Subject: [PATCH 33/63] =?UTF-8?q?refactor:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=AA=A8=EB=93=88=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20`@RefreshNetwork`=20=ED=95=9C=EC=A0=95?= =?UTF-8?q?=EC=9E=90=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `@Named("refresh")`를 커스텀 한정자 `@RefreshNetwork`로 대체** * 의존성 주입 시 문자열 기반의 `@Named` 대신 타입 안정성을 보장하는 `@RefreshNetwork` 커스텀 한정자(Qualifier)를 정의하고 적용했습니다. * `NetworkModule` 및 `AuthTokenRefresher`에서 토큰 재발급용 클라이언트 식별 방식을 변경했습니다. * **refactor: `NetworkModule` 내 HTTP 클라이언트 및 Ktorfit 설정 로직 구조화** * 중복되는 클라이언트 생성 로직을 `createHttpClient` 및 `createKtorfit` 공통 함수로 추출하여 가독성을 높였습니다. * 인증이 필요한 클라이언트 설정을 `configureAuthenticatedClient` 확장 함수로 분리했습니다. * `AuthTokenStore`의 토큰 정보를 `BearerTokens`로 변환하는 `toBearerTokens` 도우미 함수를 추가하여 코드를 간결화했습니다. * **refactor: UserAgent 빌더 로직 개선** * `buildUserAgent` 함수 내 문자열 결합 방식을 `buildString`을 사용하는 구조로 개선했습니다. * **cleanup: `LoginViewModel` 내 불필요한 메서드 제거** * 사용되지 않는 `fetchMyInfo` 메서드를 삭제하여 코드를 정리했습니다. --- .../core/network/auth/AuthTokenRefresher.kt | 4 +- .../prezel/core/network/di/NetworkModule.kt | 131 +++++++++--------- .../prezel/core/network/di/RefreshNetwork.kt | 7 + .../login/impl/landing/LoginViewModel.kt | 8 -- 4 files changed, 77 insertions(+), 73 deletions(-) create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 323c238e..f92a259c 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -2,6 +2,7 @@ package com.team.prezel.core.network.auth import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.di.RefreshNetwork import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.ReissueTokenRequest @@ -10,12 +11,11 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject -import javax.inject.Named import javax.inject.Singleton @Singleton internal class AuthTokenRefresher @Inject constructor( - @param:Named("refresh") private val authService: AuthService, + @param:RefreshNetwork private val authService: AuthService, private val authTokenStore: AuthTokenStore, ) { private val mutex = Mutex() diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index efa4f91a..52336710 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -33,7 +33,6 @@ import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import timber.log.Timber -import javax.inject.Named import javax.inject.Singleton @Module @@ -54,71 +53,73 @@ object NetworkModule { json: Json, authTokenStore: AuthTokenStore, authTokenRefresher: AuthTokenRefresher, - ): HttpClient = - HttpClient(OkHttp) { - configureBaseClient(json) + ): HttpClient = createHttpClient(json) { configureAuthenticatedClient(authTokenStore, authTokenRefresher) } - install(Auth) { - bearer { - loadTokens { - val accessToken = authTokenStore.getAccessToken() - if (accessToken.isNullOrBlank()) { - null - } else { - BearerTokens( - accessToken = accessToken, - refreshToken = authTokenStore.getRefreshToken().orEmpty(), - ) - } - } - - refreshTokens { - val refreshedAccessToken = authTokenRefresher.refreshAccessToken() ?: return@refreshTokens null - BearerTokens( - accessToken = refreshedAccessToken, - refreshToken = authTokenStore.getRefreshToken().orEmpty(), - ) - } - - sendWithoutRequest { request -> - AuthPathPolicy.requiresAuthorization(request.url.encodedPath) - } - } - } + @Provides + @Singleton + @RefreshNetwork + fun provideRefreshHttpClient(json: Json): HttpClient = createHttpClient(json) - defaultRequest { - contentType(ContentType.Application.Json) - } - } + @Provides + @Singleton + fun provideKtorfit(httpClient: HttpClient): Ktorfit = createKtorfit(httpClient) @Provides @Singleton - @Named("refresh") - fun provideRefreshHttpClient(json: Json): HttpClient = + @RefreshNetwork + fun provideRefreshKtorfit( + @RefreshNetwork httpClient: HttpClient, + ): Ktorfit = createKtorfit(httpClient) + + @Provides + @Singleton + internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() + + @Provides + @Singleton + @RefreshNetwork + internal fun provideRefreshAuthService( + @RefreshNetwork ktorfit: Ktorfit, + ): AuthService = ktorfit.createAuthService() + + private fun createHttpClient( + json: Json, + configure: HttpClientConfig<*>.() -> Unit = {}, + ): HttpClient = HttpClient(OkHttp) { configureBaseClient(json) - + configure() defaultRequest { contentType(ContentType.Application.Json) } } - @Provides - @Singleton - fun provideKtorfit(httpClient: HttpClient): Ktorfit = - Ktorfit - .Builder() - .baseUrl(BuildConfig.BASE_URL) - .httpClient(httpClient) - .converterFactories(ApiResponseConverterFactory()) - .build() + private fun HttpClientConfig<*>.configureAuthenticatedClient( + authTokenStore: AuthTokenStore, + authTokenRefresher: AuthTokenRefresher, + ) { + install(Auth) { + bearer { + loadTokens { + authTokenStore.toBearerTokens() + } - @Provides - @Singleton - @Named("refresh") - fun provideRefreshKtorfit( - @Named("refresh") httpClient: HttpClient, - ): Ktorfit = + refreshTokens { + val refreshedAccessToken = authTokenRefresher.refreshAccessToken() ?: return@refreshTokens null + BearerTokens( + accessToken = refreshedAccessToken, + refreshToken = authTokenStore.getRefreshToken().orEmpty(), + ) + } + + sendWithoutRequest { request -> + AuthPathPolicy.requiresAuthorization(request.url.encodedPath) + } + } + } + } + + private fun createKtorfit(httpClient: HttpClient): Ktorfit = Ktorfit .Builder() .baseUrl(BuildConfig.BASE_URL) @@ -126,16 +127,15 @@ object NetworkModule { .converterFactories(ApiResponseConverterFactory()) .build() - @Provides - @Singleton - internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() + private fun AuthTokenStore.toBearerTokens(): BearerTokens? { + val accessToken = getAccessToken() + if (accessToken.isNullOrBlank()) return null - @Provides - @Singleton - @Named("refresh") - internal fun provideRefreshAuthService( - @Named("refresh") ktorfit: Ktorfit, - ): AuthService = ktorfit.createAuthService() + return BearerTokens( + accessToken = accessToken, + refreshToken = getRefreshToken().orEmpty(), + ) + } private fun HttpClientConfig<*>.configureBaseClient(json: Json) { expectSuccess = true @@ -170,5 +170,10 @@ object NetworkModule { private const val SOCKET_TIMEOUT_MILLIS = 15_000L private fun buildUserAgent(): String = - "Prezel-Android/${BuildConfig.BUILD_TYPE} (Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})" + buildString { + append("Prezel-Android/${BuildConfig.BUILD_TYPE} ") + append("(Android ${Build.VERSION.RELEASE}; ") + append("SDK ${Build.VERSION.SDK_INT}; ") + append("${Build.MANUFACTURER} ${Build.MODEL})") + } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt new file mode 100644 index 00000000..9b260ac7 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt @@ -0,0 +1,7 @@ +package com.team.prezel.core.network.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class RefreshNetwork diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index fb65664a..601a756c 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -55,14 +55,6 @@ internal class LoginViewModel @Inject constructor( } } - private fun fetchMyInfo() { - viewModelScope - .launch { - val isProfileCreateComplete = true - if (isProfileCreateComplete) sendEffect(LoginUiEffect.NavigateToHome) else sendEffect(LoginUiEffect.NavigateToTerms) - }.invokeOnCompletion { updateState { copy(isLoading = false) } } - } - private suspend fun handleServerLogin(idToken: String) { loginUseCase(idToken = idToken).fold( onSuccess = { From df56a25e501814d885e62654c793fafe6d7cf28c Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 00:45:01 +0900 Subject: [PATCH 34/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20Ktor=20BearerAuth=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `AuthTokenStore` 및 `AuthLocalDataSource` 구조 개선** * `AuthTokenStore`에서 메모리 캐싱 및 `awaitInitialized` 로직을 제거하고 DataStore를 통한 비동기 조회(`getToken`) 방식으로 단순화했습니다. * `core:data` 모듈에 있던 `AuthLocalDataSource`를 `core:network` 모듈로 이동하고, `AuthToken` 도메인 모델을 사용하도록 변경했습니다. * `core:datastore` 모듈에 `core:model` 의존성을 추가했습니다. * **refactor: Ktor Bearer Auth 및 토큰 재발급 로직 고도화** * Ktor `BearerAuthProvider`의 `cacheTokens = true` 설정을 적용하여 라이브러리 수준의 캐싱을 활용하도록 개선했습니다. * `AuthTokenRefresher`가 `String?` 대신 `BearerTokens?`를 반환하도록 변경하여 갱신된 Access/Refresh 토큰 쌍을 한 번에 적용합니다. * `NetworkModule` 내에서 `AuthLocalDataSource`를 직접 주입받아 인증 클라이언트를 구성하도록 수정했습니다. * **refactor: `AuthRepositoryImpl` 내 토큰 관리 및 HttpClient 연동** * 토큰 저장(`saveToken`) 또는 삭제(`clearTokens`) 시 `httpClient.clearAuthTokens()`를 호출하여 Ktor 클라이언트의 인증 캐시를 동기화하도록 개선했습니다. * 인증이 필요한 동작(로그아웃, 탈퇴) 전 토큰 존재 여부를 확인하는 로직을 `AuthToken` 모델 기반으로 수정했습니다. * **build: 의존성 및 초기화 로직 정리** * `ktor` 버전을 `3.3.3`에서 `3.4.3`으로 업데이트했습니다. * `PrezelApplication`에서 불필요해진 `AuthTokenStore` 강제 초기화 로직을 제거했습니다. * `core:data` 및 `app` 모듈의 불필요한 의존성을 정리했습니다. --- Prezel/app/build.gradle.kts | 1 - .../java/com/team/prezel/PrezelApplication.kt | 8 --- Prezel/core/data/build.gradle.kts | 1 + .../data/datasource/AuthLocalDataSource.kt | 16 ----- .../datasource/AuthLocalDataSourceImpl.kt | 30 --------- .../core/data/di/LocalDataSourceModule.kt | 17 ----- .../data/repository/AuthRepositoryImpl.kt | 38 +++++++----- Prezel/core/datastore/build.gradle.kts | 2 + .../core/datastore/auth/AuthTokenStore.kt | 33 +++------- .../datastore/auth/DataStoreAuthTokenStore.kt | 62 +++++-------------- Prezel/core/network/build.gradle.kts | 1 + .../core/network/auth/AuthTokenRefresher.kt | 20 +++--- .../network/datasource/AuthLocalDataSource.kt | 11 ++++ .../datasource/AuthLocalDataSourceImpl.kt | 19 ++++++ .../core/network/di/DataSourceModule.kt | 6 ++ .../prezel/core/network/di/NetworkModule.kt | 36 +++++------ .../login/impl/landing/LoginViewModel.kt | 3 +- .../impl/landing/contract/LoginUiState.kt | 2 +- Prezel/gradle/libs.versions.toml | 2 +- 19 files changed, 117 insertions(+), 191 deletions(-) delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 08afd329..6ac709f8 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -32,7 +32,6 @@ android { dependencies { implementation(projects.coreAuth) implementation(projects.coreData) - implementation(projects.coreDatastore) implementation(projects.coreDesignsystem) implementation(projects.coreNavigation) implementation(projects.coreUi) diff --git a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt index d96b956c..4a7ff0bb 100644 --- a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt +++ b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt @@ -2,25 +2,17 @@ package com.team.prezel import android.app.Application import com.team.prezel.core.auth.AuthInitializer -import com.team.prezel.core.datastore.auth.AuthTokenStore import dagger.hilt.android.HiltAndroidApp import timber.log.Timber -import javax.inject.Inject @HiltAndroidApp class PrezelApplication : Application() { - @Inject - lateinit var authTokenStore: AuthTokenStore - override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } - // Eagerly create the token store so its background initialization starts at app launch. - authTokenStore.getAccessToken() - AuthInitializer.init(this) } } diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index 7b97b97a..c2e4a813 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -15,4 +15,5 @@ dependencies { implementation(projects.coreNetwork) implementation(libs.kotlinx.coroutines.core) + implementation(libs.ktor.client.auth) } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt deleted file mode 100644 index 2caeeb31..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSource.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.team.prezel.core.data.datasource - -internal interface AuthLocalDataSource { - suspend fun awaitInitialized() - - fun getAccessToken(): String? - - fun getRefreshToken(): String? - - suspend fun saveTokens( - accessToken: String, - refreshToken: String, - ) - - suspend fun clear() -} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt deleted file mode 100644 index 14674867..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/datasource/AuthLocalDataSourceImpl.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.team.prezel.core.data.datasource - -import com.team.prezel.core.datastore.auth.AuthTokenStore -import javax.inject.Inject - -internal class AuthLocalDataSourceImpl @Inject constructor( - private val authTokenStore: AuthTokenStore, -) : AuthLocalDataSource { - override suspend fun awaitInitialized() { - authTokenStore.awaitInitialized() - } - - override fun getAccessToken(): String? = authTokenStore.getAccessToken() - - override fun getRefreshToken(): String? = authTokenStore.getRefreshToken() - - override suspend fun saveTokens( - accessToken: String, - refreshToken: String, - ) { - authTokenStore.saveTokens( - accessToken = accessToken, - refreshToken = refreshToken, - ) - } - - override suspend fun clear() { - authTokenStore.clear() - } -} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt deleted file mode 100644 index 13a0e351..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/LocalDataSourceModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.team.prezel.core.data.di - -import com.team.prezel.core.data.datasource.AuthLocalDataSource -import com.team.prezel.core.data.datasource.AuthLocalDataSourceImpl -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal abstract class LocalDataSourceModule { - @Binds - @Singleton - abstract fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource -} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 61e73de0..52f1d846 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -1,28 +1,29 @@ package com.team.prezel.core.data.repository -import com.team.prezel.core.data.datasource.AuthLocalDataSource import com.team.prezel.core.data.toResult import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.clearAuthTokens import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, private val authLocalDataSource: AuthLocalDataSource, + private val httpClient: HttpClient, ) : AuthRepository { override suspend fun checkLoginStatus(): LoginStatusResult { - authLocalDataSource.awaitInitialized() + val token = authLocalDataSource.getToken() + if (!token?.accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated - val accessToken = authLocalDataSource.getAccessToken() - if (!accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated - - val refreshToken = authLocalDataSource.getRefreshToken() + val refreshToken = token?.refreshToken if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated return when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { @@ -33,7 +34,7 @@ internal class AuthRepositoryImpl @Inject constructor( is ApiResponse.Failure.HttpError -> { if (response.error?.code == AUTHENTICATION_REQUIRED_CODE) { - authLocalDataSource.clear() + clearTokens() LoginStatusResult.Unauthenticated } else { LoginStatusResult.RetryableFailure(response.throwable) @@ -45,11 +46,11 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun logout(): AuthActionResult { - if (authLocalDataSource.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() + if (authLocalDataSource.getToken()?.accessToken.isNullOrBlank()) return clearTokensAndAuthenticationRequired() return when (val response = authRemoteDataSource.logout()) { is ApiResponse.Success -> { - authLocalDataSource.clear() + clearTokens() AuthActionResult.Success } @@ -67,7 +68,7 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { - if (authLocalDataSource.getAccessToken().isNullOrBlank()) return clearTokensAndAuthenticationRequired() + if (authLocalDataSource.getToken()?.accessToken.isNullOrBlank()) return clearTokensAndAuthenticationRequired() return when ( val response = @@ -77,7 +78,7 @@ internal class AuthRepositoryImpl @Inject constructor( ) ) { is ApiResponse.Success -> { - authLocalDataSource.clear() + clearTokens() AuthActionResult.Success } @@ -90,10 +91,8 @@ internal class AuthRepositoryImpl @Inject constructor( response .toAuthToken() .also { token -> - authLocalDataSource.saveTokens( - accessToken = token.accessToken, - refreshToken = token.refreshToken, - ) + authLocalDataSource.saveToken(token) + httpClient.clearAuthTokens() } private fun LoginResponse.toAuthToken(): AuthToken = @@ -104,17 +103,22 @@ internal class AuthRepositoryImpl @Inject constructor( private suspend fun ApiResponse.Failure.HttpError.toAuthActionResult(): AuthActionResult = if (error?.code == AUTHENTICATION_REQUIRED_CODE) { - authLocalDataSource.clear() + clearTokens() AuthActionResult.AuthenticationRequired } else { AuthActionResult.Failure(throwable) } private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult { - authLocalDataSource.clear() + clearTokens() return AuthActionResult.AuthenticationRequired } + private suspend fun clearTokens() { + authLocalDataSource.clear() + httpClient.clearAuthTokens() + } + private fun WithdrawReason.toCategory(): String = when (this) { WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" diff --git a/Prezel/core/datastore/build.gradle.kts b/Prezel/core/datastore/build.gradle.kts index df42312e..623c0787 100644 --- a/Prezel/core/datastore/build.gradle.kts +++ b/Prezel/core/datastore/build.gradle.kts @@ -9,6 +9,8 @@ android { } dependencies { + implementation(projects.coreModel) + implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutines.core) } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt index e4837424..8c9d7128 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt @@ -1,36 +1,17 @@ package com.team.prezel.core.datastore.auth +import com.team.prezel.core.model.auth.AuthToken + /** - * 인증 토큰의 메모리 캐시와 영속 저장소를 함께 관리하는 저장소 계약입니다. - * - * ### 초기화 계약 - * - 구현체는 생성 직후 영속 저장소의 값을 메모리 캐시에 적재할 수 있습니다. - * - 호출부는 동기 getter의 초기 상태가 필요할 때 먼저 [awaitInitialized]를 호출해야 합니다. - * - [awaitInitialized]가 정상 반환된 이후에는 동기 getter가 영속 저장소와 동기화된 최신 캐시 값을 반환해야 합니다. - * - * ### 동기 조회 계약 - * - [getAccessToken], [getRefreshToken]은 메모리 캐시의 현재 값을 즉시 반환해야 합니다. - * - 호출 시점에 디스크 I/O나 네트워크 I/O를 유발하지 않아야 합니다. - * - 따라서 OkHttp `Authenticator`와 같이 네트워크 스레드에서 호출되는 환경에서도 블로킹 없이 동작해야 합니다. + * 인증 토큰의 영속 저장소를 관리하는 저장소 계약입니다. * - * ### 갱신 계약 - * - [saveTokens], [clear]는 영속 저장소 반영과 메모리 캐시 갱신을 함께 수행합니다. - * - 두 함수가 정상적으로 반환된 직후에는 동기 getter가 항상 최신 값을 반환해야 합니다. + * Ktor BearerAuthProvider가 `loadTokens` 결과를 캐싱하므로, 이 저장소는 DataStore에 + * 저장된 값을 읽고 쓰는 역할만 담당합니다. */ interface AuthTokenStore { - fun getAccessToken(): String? - - fun getRefreshToken(): String? - - /** - * 영속 저장소의 초기값을 메모리 캐시에 반영할 때까지 대기합니다. - */ - suspend fun awaitInitialized() + suspend fun getToken(): AuthToken? - suspend fun saveTokens( - accessToken: String, - refreshToken: String, - ) + suspend fun saveToken(token: AuthToken) suspend fun clear() } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt index b458c903..c8a01c8a 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt @@ -9,14 +9,11 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import com.team.prezel.core.datastore.di.ApplicationScope +import com.team.prezel.core.model.auth.AuthToken import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -32,54 +29,29 @@ internal class DataStoreAuthTokenStore @Inject constructor( produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, ) - @Volatile - private var accessToken: String? = null + override suspend fun getToken(): AuthToken? { + val preferences = readPreferences() + val accessToken = preferences[KEY_ACCESS_TOKEN] + val refreshToken = preferences[KEY_REFRESH_TOKEN] + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) return null - @Volatile - private var refreshToken: String? = null - - private val mutex = Mutex() - private val initializationJob: Job = - applicationScope.launch { - mutex.withLock { - val preferences = readPreferences() - accessToken = preferences[KEY_ACCESS_TOKEN] - refreshToken = preferences[KEY_REFRESH_TOKEN] - } - } - - override fun getAccessToken(): String? = accessToken - - override fun getRefreshToken(): String? = refreshToken - - override suspend fun awaitInitialized() { - initializationJob.join() + return AuthToken( + accessToken = accessToken, + refreshToken = refreshToken, + ) } - override suspend fun saveTokens( - accessToken: String, - refreshToken: String, - ) { - awaitInitialized() - mutex.withLock { - dataStore.edit { preferences -> - preferences[KEY_ACCESS_TOKEN] = accessToken - preferences[KEY_REFRESH_TOKEN] = refreshToken - } - this.accessToken = accessToken - this.refreshToken = refreshToken + override suspend fun saveToken(token: AuthToken) { + dataStore.edit { preferences -> + preferences[KEY_ACCESS_TOKEN] = token.accessToken + preferences[KEY_REFRESH_TOKEN] = token.refreshToken } } override suspend fun clear() { - awaitInitialized() - mutex.withLock { - dataStore.edit { preferences -> - preferences.remove(KEY_ACCESS_TOKEN) - preferences.remove(KEY_REFRESH_TOKEN) - } - accessToken = null - refreshToken = null + dataStore.edit { preferences -> + preferences.remove(KEY_ACCESS_TOKEN) + preferences.remove(KEY_REFRESH_TOKEN) } } diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index b7d13d79..3d6dd1a7 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -18,6 +18,7 @@ android { dependencies { implementation(projects.coreDatastore) + implementation(projects.coreModel) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.auth) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index f92a259c..5d89a7cd 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -1,12 +1,14 @@ package com.team.prezel.core.network.auth -import com.team.prezel.core.datastore.auth.AuthTokenStore +import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.di.RefreshNetwork import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.ReissueTokenRequest import com.team.prezel.core.network.service.AuthService +import io.ktor.client.plugins.auth.providers.BearerTokens import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -16,13 +18,13 @@ import javax.inject.Singleton @Singleton internal class AuthTokenRefresher @Inject constructor( @param:RefreshNetwork private val authService: AuthService, - private val authTokenStore: AuthTokenStore, + private val authLocalDataSource: AuthLocalDataSource, ) { private val mutex = Mutex() - suspend fun refreshAccessToken(): String? = + suspend fun refreshTokens(): BearerTokens? = mutex.withLock { - val refreshToken = authTokenStore.getRefreshToken() ?: return@withLock null + val refreshToken = authLocalDataSource.getToken()?.refreshToken ?: return@withLock null when ( val response = @@ -31,19 +33,23 @@ internal class AuthTokenRefresher @Inject constructor( ) ) { is ApiResponse.Success -> { - authTokenStore.saveTokens( + val token = AuthToken( accessToken = response.data.accessToken, refreshToken = response.data.refreshToken, ) + authLocalDataSource.saveToken(token) if (BuildConfig.DEBUG) { Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") } - response.data.accessToken + BearerTokens( + accessToken = token.accessToken, + refreshToken = token.refreshToken, + ) } is ApiResponse.Failure.HttpError -> { if (response.error.isSessionRecoveryUnrecoverable()) { - authTokenStore.clear() + authLocalDataSource.clear() } Timber.e(response.throwable, "토큰 재발급에 실패했습니다.") null diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt new file mode 100644 index 00000000..cb208857 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.model.auth.AuthToken + +interface AuthLocalDataSource { + suspend fun getToken(): AuthToken? + + suspend fun saveToken(token: AuthToken) + + suspend fun clear() +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt new file mode 100644 index 00000000..da6fc9b0 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.datastore.auth.AuthTokenStore +import com.team.prezel.core.model.auth.AuthToken +import javax.inject.Inject + +internal class AuthLocalDataSourceImpl @Inject constructor( + private val authTokenStore: AuthTokenStore, +) : AuthLocalDataSource { + override suspend fun getToken(): AuthToken? = authTokenStore.getToken() + + override suspend fun saveToken(token: AuthToken) { + authTokenStore.saveToken(token) + } + + override suspend fun clear() { + authTokenStore.clear() + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt index 573e58bd..221a43ca 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt @@ -1,5 +1,7 @@ package com.team.prezel.core.network.di +import com.team.prezel.core.network.datasource.AuthLocalDataSource +import com.team.prezel.core.network.datasource.AuthLocalDataSourceImpl import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSourceImpl import dagger.Binds @@ -11,6 +13,10 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal abstract class DataSourceModule { + @Binds + @Singleton + abstract fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource + @Binds @Singleton abstract fun bindAuthRemoteDataSource(impl: AuthRemoteDataSourceImpl): AuthRemoteDataSource diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 52336710..f6438cfe 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,11 +1,11 @@ package com.team.prezel.core.network.di import android.os.Build -import com.team.prezel.core.datastore.auth.AuthTokenStore import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.auth.AuthPathPolicy import com.team.prezel.core.network.auth.AuthTokenRefresher +import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.service.AuthService import com.team.prezel.core.network.service.createAuthService import dagger.Module @@ -51,9 +51,9 @@ object NetworkModule { @Singleton internal fun provideHttpClient( json: Json, - authTokenStore: AuthTokenStore, + authLocalDataSource: AuthLocalDataSource, authTokenRefresher: AuthTokenRefresher, - ): HttpClient = createHttpClient(json) { configureAuthenticatedClient(authTokenStore, authTokenRefresher) } + ): HttpClient = createHttpClient(json) { configureAuthenticatedClient(authLocalDataSource, authTokenRefresher) } @Provides @Singleton @@ -95,21 +95,18 @@ object NetworkModule { } private fun HttpClientConfig<*>.configureAuthenticatedClient( - authTokenStore: AuthTokenStore, + authLocalDataSource: AuthLocalDataSource, authTokenRefresher: AuthTokenRefresher, ) { install(Auth) { bearer { + cacheTokens = true loadTokens { - authTokenStore.toBearerTokens() + authLocalDataSource.toBearerTokens() } refreshTokens { - val refreshedAccessToken = authTokenRefresher.refreshAccessToken() ?: return@refreshTokens null - BearerTokens( - accessToken = refreshedAccessToken, - refreshToken = authTokenStore.getRefreshToken().orEmpty(), - ) + authTokenRefresher.refreshTokens() ?: return@refreshTokens null } sendWithoutRequest { request -> @@ -119,6 +116,15 @@ object NetworkModule { } } + private suspend fun AuthLocalDataSource.toBearerTokens(): BearerTokens? { + val token = getToken() ?: return null + + return BearerTokens( + accessToken = token.accessToken, + refreshToken = token.refreshToken, + ) + } + private fun createKtorfit(httpClient: HttpClient): Ktorfit = Ktorfit .Builder() @@ -127,16 +133,6 @@ object NetworkModule { .converterFactories(ApiResponseConverterFactory()) .build() - private fun AuthTokenStore.toBearerTokens(): BearerTokens? { - val accessToken = getAccessToken() - if (accessToken.isNullOrBlank()) return null - - return BearerTokens( - accessToken = accessToken, - refreshToken = getRefreshToken().orEmpty(), - ) - } - private fun HttpClientConfig<*>.configureBaseClient(json: Json) { expectSuccess = true diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 37973bb6..c0de0d9b 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -3,10 +3,9 @@ package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult -import com.team.prezel.core.ui.base.BaseViewModel -import com.team.prezel.feature.login.impl.BuildConfig import com.team.prezel.core.domain.usecase.auth.LoginUseCase import com.team.prezel.core.ui.BaseViewModel +import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent import com.team.prezel.feature.login.impl.landing.contract.LoginUiState diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt index cc7e4ba0..6adbca2f 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt @@ -1,8 +1,8 @@ package com.team.prezel.feature.login.impl.landing.contract import androidx.compose.runtime.Immutable -import com.team.prezel.core.ui.base.UiState import com.team.prezel.core.auth.model.AuthProvider +import com.team.prezel.core.ui.base.UiState @Immutable internal data class LoginUiState( diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 475d3217..dc674654 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -17,7 +17,7 @@ ksp = "2.3.4" hilt = "2.58" androidxHilt = "1.3.0" desugarJdk = "2.1.5" -ktor = "3.3.3" +ktor = "3.4.3" ktorfit = "2.7.2" timber = "5.0.1" navigation3 = "1.0.0" From 6e83339a40ec5301b58a8c60b7519df9ce7da997 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 01:20:36 +0900 Subject: [PATCH 35/63] =?UTF-8?q?refactor:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `AuthTokenRefresher`의 토큰 재발급 방식 변경** * 전용 `AuthService` 대신 Ktor `RefreshTokensParams`에서 제공하는 클라이언트를 사용하여 직접 API를 호출하도록 변경했습니다. * `@RefreshNetwork` 한정자 및 관련 의존성 주입 설정을 제거하여 구조를 단순화했습니다. * `RefreshTokensParams`의 기존 토큰 정보를 활용하도록 개선하고, 재발급 요청 시 `markAsRefreshTokenRequest()`를 호출하여 무한 루프를 방지합니다. * `ResponseException` 발생 시 에러 응답 코드를 분석하여 세션 복구 불가능 여부를 판단하고 토큰을 삭제하는 예외 처리 로직을 강화했습니다. * **refactor: `NetworkModule` 내 중복 클라이언트 제거 및 설정 조정** * 토큰 재발급을 위해 별도로 유지하던 `provideRefreshHttpClient`, `provideRefreshKtorfit`, `provideRefreshAuthService` 등을 모두 삭제했습니다. * `HttpTimeout` 설정의 하드코딩된 밀리초 값을 제거하고 기본 설정을 사용하도록 변경했습니다. * **cleanup: 불필요한 파일 삭제** * 더 이상 사용되지 않는 `@RefreshNetwork` 어노테이션 정의 파일(`RefreshNetwork.kt`)을 삭제했습니다. --- .../core/network/auth/AuthTokenRefresher.kt | 94 +++++++++++-------- .../prezel/core/network/di/NetworkModule.kt | 31 +----- .../prezel/core/network/di/RefreshNetwork.kt | 7 -- 3 files changed, 59 insertions(+), 73 deletions(-) delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 5d89a7cd..dc468f50 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -3,66 +3,86 @@ package com.team.prezel.core.network.auth import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.datasource.AuthLocalDataSource -import com.team.prezel.core.network.di.RefreshNetwork import com.team.prezel.core.network.model.ApiErrorResponse -import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.auth.LoginResponse import com.team.prezel.core.network.model.auth.ReissueTokenRequest -import com.team.prezel.core.network.service.AuthService +import io.ktor.client.call.body +import io.ktor.client.plugins.ResponseException import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException @Singleton internal class AuthTokenRefresher @Inject constructor( - @param:RefreshNetwork private val authService: AuthService, + private val json: Json, private val authLocalDataSource: AuthLocalDataSource, ) { private val mutex = Mutex() - suspend fun refreshTokens(): BearerTokens? = + suspend fun refreshTokens(params: RefreshTokensParams): BearerTokens? = mutex.withLock { - val refreshToken = authLocalDataSource.getToken()?.refreshToken ?: return@withLock null + val refreshToken = params.oldTokens?.refreshToken + ?: authLocalDataSource.getToken()?.refreshToken + ?: return@withLock null - when ( - val response = - authService.reissueToken( - request = ReissueTokenRequest(refreshToken = refreshToken), - ) - ) { - is ApiResponse.Success -> { - val token = AuthToken( - accessToken = response.data.accessToken, - refreshToken = response.data.refreshToken, - ) - authLocalDataSource.saveToken(token) - if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") - } - BearerTokens( - accessToken = token.accessToken, - refreshToken = token.refreshToken, - ) - } + try { + val response = params.client + .post("${BuildConfig.BASE_URL}auth/reissue") { + with(params) { + markAsRefreshTokenRequest() + } + setBody(ReissueTokenRequest(refreshToken = refreshToken)) + }.body() - is ApiResponse.Failure.HttpError -> { - if (response.error.isSessionRecoveryUnrecoverable()) { - authLocalDataSource.clear() - } - Timber.e(response.throwable, "토큰 재발급에 실패했습니다.") - null + val token = AuthToken( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + authLocalDataSource.saveToken(token) + if (BuildConfig.DEBUG) { + Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") } - - is ApiResponse.Failure.NetworkError -> { - Timber.e(response.throwable, "토큰 재발급에 실패했습니다.") - null + BearerTokens( + accessToken = token.accessToken, + refreshToken = token.refreshToken, + ) + } catch (t: Throwable) { + t.rethrowIfCancellation() + if (t.isSessionRecoveryUnrecoverable()) { + authLocalDataSource.clear() } + Timber.e(t, "토큰 재발급에 실패했습니다.") + null } } - private fun ApiErrorResponse?.isSessionRecoveryUnrecoverable(): Boolean = this?.code == TOKEN_INVALID_CODE || this?.code == USER_NOT_FOUND_CODE + private suspend fun Throwable.isSessionRecoveryUnrecoverable(): Boolean { + if (this !is ResponseException) return false + + val error = parseErrorResponse() + return error?.code == TOKEN_INVALID_CODE || error?.code == USER_NOT_FOUND_CODE + } + + private suspend fun ResponseException.parseErrorResponse(): ApiErrorResponse? = + try { + json.decodeFromString(response.bodyAsText()) + } catch (t: Throwable) { + t.rethrowIfCancellation() + null + } + + private fun Throwable.rethrowIfCancellation() { + if (this is CancellationException) throw this + } private companion object { const val TOKEN_INVALID_CODE = "T001" diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index f6438cfe..579e5cf5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -55,33 +55,14 @@ object NetworkModule { authTokenRefresher: AuthTokenRefresher, ): HttpClient = createHttpClient(json) { configureAuthenticatedClient(authLocalDataSource, authTokenRefresher) } - @Provides - @Singleton - @RefreshNetwork - fun provideRefreshHttpClient(json: Json): HttpClient = createHttpClient(json) - @Provides @Singleton fun provideKtorfit(httpClient: HttpClient): Ktorfit = createKtorfit(httpClient) - @Provides - @Singleton - @RefreshNetwork - fun provideRefreshKtorfit( - @RefreshNetwork httpClient: HttpClient, - ): Ktorfit = createKtorfit(httpClient) - @Provides @Singleton internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() - @Provides - @Singleton - @RefreshNetwork - internal fun provideRefreshAuthService( - @RefreshNetwork ktorfit: Ktorfit, - ): AuthService = ktorfit.createAuthService() - private fun createHttpClient( json: Json, configure: HttpClientConfig<*>.() -> Unit = {}, @@ -106,7 +87,7 @@ object NetworkModule { } refreshTokens { - authTokenRefresher.refreshTokens() ?: return@refreshTokens null + authTokenRefresher.refreshTokens(this) ?: return@refreshTokens null } sendWithoutRequest { request -> @@ -140,11 +121,7 @@ object NetworkModule { json(json) } - install(HttpTimeout) { - requestTimeoutMillis = REQUEST_TIMEOUT_MILLIS - connectTimeoutMillis = CONNECT_TIMEOUT_MILLIS - socketTimeoutMillis = SOCKET_TIMEOUT_MILLIS - } + install(HttpTimeout) install(UserAgent) { agent = buildUserAgent() @@ -161,10 +138,6 @@ object NetworkModule { } } - private const val REQUEST_TIMEOUT_MILLIS = 15_000L - private const val CONNECT_TIMEOUT_MILLIS = 10_000L - private const val SOCKET_TIMEOUT_MILLIS = 15_000L - private fun buildUserAgent(): String = buildString { append("Prezel-Android/${BuildConfig.BUILD_TYPE} ") diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt deleted file mode 100644 index 9b260ac7..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/RefreshNetwork.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.core.network.di - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -internal annotation class RefreshNetwork From 4495ee54b079af0d03dd9f21d26f5e40d725f6cc Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 01:59:22 +0900 Subject: [PATCH 36/63] =?UTF-8?q?refactor:=20AuthTokenRefresher=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EB=B0=8F=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthTokenRefreshResult` sealed interface 추가** * 토큰 재발급 결과를 명확하게 처리하기 위해 `Success`, `Failure.Retryable`, `Failure.Unrecoverable` 상태를 정의했습니다. * **refactor: `AuthTokenRefresher` 로직 분리 및 가시성 변경** * 클래스 가시성을 `internal`에서 `public`으로 변경했습니다. * Ktor `Authenticator` 내부에서만 사용되던 로직을 외부에서도 호출 가능하도록 `refreshToken` 메서드로 추출했습니다. * `isSessionRecoveryUnrecoverable` 판단 로직에 인증 필요 코드(`U001`)를 추가하여 세션 만료 처리를 강화했습니다. * **refactor: `AuthRepositoryImpl` 내 로그인 상태 확인 로직 개선** * 기존 `authRemoteDataSource.reissueToken`을 직접 호출하던 방식에서 `AuthTokenRefresher`를 사용하도록 변경했습니다. * `AuthTokenRefreshResult`에 따라 로컬 토큰 삭제 및 인증 상태(`LoginStatusResult`) 반환 로직을 일관성 있게 수정했습니다. * **style: 불필요한 코드 정리** * `AuthRepositoryImpl`에서 사용하지 않는 `AUTHENTICATION_REQUIRED_CODE` 상수를 제거하고 `AuthTokenRefresher` 내부 상수를 사용하도록 변경했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 21 ++-- .../network/auth/AuthTokenRefreshResult.kt | 21 ++++ .../core/network/auth/AuthTokenRefresher.kt | 100 +++++++++++++----- 3 files changed, 103 insertions(+), 39 deletions(-) create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 52f1d846..d535e8f2 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -6,6 +6,8 @@ import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.core.network.auth.AuthTokenRefreshResult +import com.team.prezel.core.network.auth.AuthTokenRefresher import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.model.ApiResponse @@ -17,6 +19,7 @@ import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, private val authLocalDataSource: AuthLocalDataSource, + private val authTokenRefresher: AuthTokenRefresher, private val httpClient: HttpClient, ) : AuthRepository { override suspend fun checkLoginStatus(): LoginStatusResult { @@ -26,22 +29,18 @@ internal class AuthRepositoryImpl @Inject constructor( val refreshToken = token?.refreshToken if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated - return when (val response = authRemoteDataSource.reissueToken(refreshToken = refreshToken)) { - is ApiResponse.Success -> { - saveTokens(response.data) + return when (val result = authTokenRefresher.refreshToken(httpClient, refreshToken)) { + is AuthTokenRefreshResult.Success -> { + httpClient.clearAuthTokens() LoginStatusResult.Authenticated } - is ApiResponse.Failure.HttpError -> { - if (response.error?.code == AUTHENTICATION_REQUIRED_CODE) { - clearTokens() - LoginStatusResult.Unauthenticated - } else { - LoginStatusResult.RetryableFailure(response.throwable) - } + is AuthTokenRefreshResult.Failure.Unrecoverable -> { + httpClient.clearAuthTokens() + LoginStatusResult.Unauthenticated } - is ApiResponse.Failure.NetworkError -> LoginStatusResult.RetryableFailure(response.throwable) + is AuthTokenRefreshResult.Failure.Retryable -> LoginStatusResult.RetryableFailure(result.throwable) } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt new file mode 100644 index 00000000..3017cc4a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt @@ -0,0 +1,21 @@ +package com.team.prezel.core.network.auth + +import com.team.prezel.core.model.auth.AuthToken + +sealed interface AuthTokenRefreshResult { + data class Success( + val token: AuthToken, + ) : AuthTokenRefreshResult + + sealed interface Failure : AuthTokenRefreshResult { + val throwable: Throwable + + data class Retryable( + override val throwable: Throwable, + ) : Failure + + data class Unrecoverable( + override val throwable: Throwable, + ) : Failure + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index dc468f50..8025173e 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -6,10 +6,13 @@ import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.auth.LoginResponse import com.team.prezel.core.network.model.auth.ReissueTokenRequest +import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.ResponseException +import io.ktor.client.plugins.auth.AuthCircuitBreaker import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText @@ -22,7 +25,7 @@ import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException @Singleton -internal class AuthTokenRefresher @Inject constructor( +class AuthTokenRefresher @Inject constructor( private val json: Json, private val authLocalDataSource: AuthLocalDataSource, ) { @@ -34,34 +37,72 @@ internal class AuthTokenRefresher @Inject constructor( ?: authLocalDataSource.getToken()?.refreshToken ?: return@withLock null - try { - val response = params.client - .post("${BuildConfig.BASE_URL}auth/reissue") { - with(params) { - markAsRefreshTokenRequest() - } - setBody(ReissueTokenRequest(refreshToken = refreshToken)) - }.body() + when ( + val result = + refreshToken( + client = params.client, + refreshToken = refreshToken, + markAsRefreshTokenRequest = { + with(params) { + markAsRefreshTokenRequest() + } + }, + ) + ) { + is AuthTokenRefreshResult.Success -> + BearerTokens( + accessToken = result.token.accessToken, + refreshToken = result.token.refreshToken, + ) - val token = AuthToken( - accessToken = response.accessToken, - refreshToken = response.refreshToken, - ) - authLocalDataSource.saveToken(token) - if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") - } - BearerTokens( - accessToken = token.accessToken, - refreshToken = token.refreshToken, - ) - } catch (t: Throwable) { - t.rethrowIfCancellation() - if (t.isSessionRecoveryUnrecoverable()) { - authLocalDataSource.clear() - } + is AuthTokenRefreshResult.Failure -> null + } + } + + suspend fun refreshToken( + client: HttpClient, + refreshToken: String, + ): AuthTokenRefreshResult = + mutex.withLock { + refreshToken( + client = client, + refreshToken = refreshToken, + markAsRefreshTokenRequest = { + attributes.put(AuthCircuitBreaker, Unit) + }, + ) + } + + private suspend fun refreshToken( + client: HttpClient, + refreshToken: String, + markAsRefreshTokenRequest: HttpRequestBuilder.() -> Unit, + ): AuthTokenRefreshResult = + try { + val response = client + .post("${BuildConfig.BASE_URL}auth/reissue") { + markAsRefreshTokenRequest() + setBody(ReissueTokenRequest(refreshToken = refreshToken)) + }.body() + + val token = AuthToken( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + authLocalDataSource.saveToken(token) + if (BuildConfig.DEBUG) { + Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") + } + AuthTokenRefreshResult.Success(token) + } catch (t: Throwable) { + t.rethrowIfCancellation() + if (t.isSessionRecoveryUnrecoverable()) { + authLocalDataSource.clear() + Timber.e(t, "토큰 재발급에 실패했습니다.") + AuthTokenRefreshResult.Failure.Unrecoverable(t) + } else { Timber.e(t, "토큰 재발급에 실패했습니다.") - null + AuthTokenRefreshResult.Failure.Retryable(t) } } @@ -69,7 +110,9 @@ internal class AuthTokenRefresher @Inject constructor( if (this !is ResponseException) return false val error = parseErrorResponse() - return error?.code == TOKEN_INVALID_CODE || error?.code == USER_NOT_FOUND_CODE + return error?.code == TOKEN_INVALID_CODE || + error?.code == AUTHENTICATION_REQUIRED_CODE || + error?.code == USER_NOT_FOUND_CODE } private suspend fun ResponseException.parseErrorResponse(): ApiErrorResponse? = @@ -86,6 +129,7 @@ internal class AuthTokenRefresher @Inject constructor( private companion object { const val TOKEN_INVALID_CODE = "T001" + const val AUTHENTICATION_REQUIRED_CODE = "U001" const val USER_NOT_FOUND_CODE = "U003" } } From 9026f75b4b1873db1cebd0b4438324d4f47291e6 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 02:03:34 +0900 Subject: [PATCH 37/63] =?UTF-8?q?refactor:=20UI=20=EB=B0=8F=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 디자인 시스템 및 UI 모듈 패키지 경로 변경** * 디자인 시스템의 스낵바 경로를 `component.modal.snackbar`에서 `component.feedback.snackbar`로 변경했습니다. * `UiIntent`, `UiState`, `BaseViewModel` 등 공통 UI 기반 클래스들의 패키지를 `core.ui`에서 `core.ui.base`로 이동했습니다. * `LocalSnackbarHostState`의 패키지 경로를 `core.ui`에서 `core.ui.state`로 변경했습니다. * **refactor: Feature 모듈 내 변경된 패키지 참조 수정** * `splash`, `my`, `login` 기능 모듈에서 변경된 `BaseViewModel`, `UiIntent`, `UiState`, `showPrezelSnackbar` 등의 import 경로를 일괄 업데이트했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 2 +- .../core/network/auth/AuthTokenRefresher.kt | 10 +++++----- .../prezel/core/network/di/NetworkModule.kt | 5 +---- .../prezel/core/ui/component/StatusView.kt | 20 ------------------- .../login/impl/landing/LoginViewModel.kt | 1 - .../team/prezel/feature/my/impl/MyScreen.kt | 4 ++-- .../feature/my/impl/contract/MyUiIntent.kt | 2 +- .../feature/my/impl/contract/MyUiState.kt | 2 +- .../feature/splash/impl/SplashScreen.kt | 4 ++-- .../feature/splash/impl/SplashViewModel.kt | 1 - 10 files changed, 13 insertions(+), 38 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index d535e8f2..3958d5d3 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -29,7 +29,7 @@ internal class AuthRepositoryImpl @Inject constructor( val refreshToken = token?.refreshToken if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated - return when (val result = authTokenRefresher.refreshToken(httpClient, refreshToken)) { + return when (val result = authTokenRefresher.reissueToken(httpClient, refreshToken)) { is AuthTokenRefreshResult.Success -> { httpClient.clearAuthTokens() LoginStatusResult.Authenticated diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 8025173e..b73495c4 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -31,7 +31,7 @@ class AuthTokenRefresher @Inject constructor( ) { private val mutex = Mutex() - suspend fun refreshTokens(params: RefreshTokensParams): BearerTokens? = + suspend fun refreshBearerTokens(params: RefreshTokensParams): BearerTokens? = mutex.withLock { val refreshToken = params.oldTokens?.refreshToken ?: authLocalDataSource.getToken()?.refreshToken @@ -39,7 +39,7 @@ class AuthTokenRefresher @Inject constructor( when ( val result = - refreshToken( + requestTokenReissue( client = params.client, refreshToken = refreshToken, markAsRefreshTokenRequest = { @@ -59,12 +59,12 @@ class AuthTokenRefresher @Inject constructor( } } - suspend fun refreshToken( + suspend fun reissueToken( client: HttpClient, refreshToken: String, ): AuthTokenRefreshResult = mutex.withLock { - refreshToken( + requestTokenReissue( client = client, refreshToken = refreshToken, markAsRefreshTokenRequest = { @@ -73,7 +73,7 @@ class AuthTokenRefresher @Inject constructor( ) } - private suspend fun refreshToken( + private suspend fun requestTokenReissue( client: HttpClient, refreshToken: String, markAsRefreshTokenRequest: HttpRequestBuilder.() -> Unit, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 579e5cf5..272b3e44 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -16,7 +16,6 @@ import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.auth.Auth import io.ktor.client.plugins.auth.providers.BearerTokens @@ -87,7 +86,7 @@ object NetworkModule { } refreshTokens { - authTokenRefresher.refreshTokens(this) ?: return@refreshTokens null + authTokenRefresher.refreshBearerTokens(this) ?: return@refreshTokens null } sendWithoutRequest { request -> @@ -121,8 +120,6 @@ object NetworkModule { json(json) } - install(HttpTimeout) - install(UserAgent) { agent = buildUserAgent() } diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/StatusView.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/StatusView.kt index 275a09c6..9510cb8d 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/StatusView.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/StatusView.kt @@ -69,26 +69,6 @@ fun StatusView( } } -@Composable -fun StatusLottie( - @RawRes lottieJsonResId: Int, - modifier: Modifier = Modifier, -) { - val composition by rememberLottieComposition( - LottieCompositionSpec.RawRes(lottieJsonResId), - ) - val progress by animateLottieCompositionAsState( - composition = composition, - iterations = LottieConstants.IterateForever, - ) - - LottieAnimation( - composition = composition, - progress = { progress }, - modifier = modifier.size(80.dp), - ) -} - @BasicPreview @Composable private fun StatusViewEmptyPreview() { diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index c0de0d9b..9702e613 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult import com.team.prezel.core.domain.usecase.auth.LoginUseCase -import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt index 3f082e57..eadacb4f 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt @@ -17,9 +17,9 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.navigation.LocalNavigator -import com.team.prezel.core.ui.LocalSnackbarHostState +import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.feature.my.impl.contract.MyUiEffect import com.team.prezel.feature.my.impl.contract.MyUiState diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt index 3dbe590c..0247d882 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt @@ -1,6 +1,6 @@ package com.team.prezel.feature.my.impl.contract -import com.team.prezel.core.ui.UiIntent +import com.team.prezel.core.ui.base.UiIntent internal sealed interface MyUiIntent : UiIntent { data object FetchData : MyUiIntent diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt index 82db9008..b3aaf40a 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt @@ -1,6 +1,6 @@ package com.team.prezel.feature.my.impl.contract -import com.team.prezel.core.ui.UiState +import com.team.prezel.core.ui.base.UiState internal data class MyUiState( val isLoading: Boolean = false, diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt index dd374451..00f3fee8 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt @@ -16,10 +16,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.ui.LocalSnackbarHostState +import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index 6efab8a1..cc89c7d4 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -3,7 +3,6 @@ package com.team.prezel.feature.splash.impl import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.domain.usecase.auth.CheckLoginStatusUseCase -import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent From fb6db595293dbd9eeef5eda41d78a6da20a93b0b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 13:34:27 +0900 Subject: [PATCH 38/63] =?UTF-8?q?refactor:=20AuthLocalDataSource=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: AuthTokenStore와 AuthLocalDataSource 통합 및 위치 변경** * `core:network`에 위치하던 `AuthLocalDataSource` 인터페이스 및 구현체를 `core:datastore` 모듈로 이동하고 `AuthTokenStore`를 흡수 통합했습니다. * `AuthLocalDataSource.getToken()`의 반환 타입을 단일 객체(`AuthToken?`)에서 `Flow`으로 변경하여 데이터 스트림을 지원하도록 개선했습니다. * `saveToken` 및 `clear` 메서드의 반환 타입을 `Result`으로 변경하여 작업 성공 여부를 캡처할 수 있도록 강화했습니다. * **feat: JSON 직렬화 기반 토큰 저장 방식 도입** * `AuthToken` 모델에 `@Serializable` 어노테이션을 추가하고, DataStore 저장 시 개별 키 저장 방식에서 JSON 문자열로 직렬화하여 하나의 키(`auth_token`)로 관리하도록 변경했습니다. * `core:model` 및 `core:datastore` 모듈에 `kotlinx-serialization` 의존성을 추가했습니다. * **refactor: AuthRepositoryImpl 및 AuthTokenRefresher 로직 수정** * `AuthLocalDataSource`의 인터페이스 변경(`Flow` 및 `Result` 반환)에 따라 인증 체크, 로그인, 로그아웃, 탈퇴 로직을 리팩터링했습니다. * `AuthTokenRefresher`에서 재발급된 토큰 저장 실패 시 예외 처리 및 로그 출력을 추가했습니다. * `NetworkModule`에서 토큰 로드 시 `getToken().first()`를 사용하여 최신 토큰을 가져오도록 수정했습니다. * **build: 의존성 주입(Hilt) 모듈 정리** * `TokenStoreModule`을 제거하고 `DataSourceModule`을 통해 `AuthLocalDataSource`를 주입하도록 설정을 업데이트했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 90 +++++++++++-------- Prezel/core/datastore/build.gradle.kts | 1 + .../datastore/auth/AuthLocalDataSource.kt | 15 ++++ .../datastore/auth/AuthLocalDataSourceImpl.kt | 82 +++++++++++++++++ .../core/datastore/auth/AuthTokenStore.kt | 17 ---- .../datastore/auth/DataStoreAuthTokenStore.kt | 69 -------------- ...okenStoreModule.kt => DataSourceModule.kt} | 8 +- Prezel/core/model/build.gradle.kts | 2 + .../team/prezel/core/model/auth/AuthToken.kt | 3 + .../core/network/auth/AuthTokenRefresher.kt | 18 +++- .../network/datasource/AuthLocalDataSource.kt | 11 --- .../datasource/AuthLocalDataSourceImpl.kt | 19 ---- .../core/network/di/DataSourceModule.kt | 6 -- .../prezel/core/network/di/NetworkModule.kt | 5 +- 14 files changed, 179 insertions(+), 167 deletions(-) create mode 100644 Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt create mode 100644 Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt delete mode 100644 Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt delete mode 100644 Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt rename Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/{TokenStoreModule.kt => DataSourceModule.kt} (50%) delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 3958d5d3..45866eb9 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -1,6 +1,6 @@ package com.team.prezel.core.data.repository -import com.team.prezel.core.data.toResult +import com.team.prezel.core.datastore.auth.AuthLocalDataSource import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.result.auth.LoginStatusResult @@ -8,12 +8,12 @@ import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason import com.team.prezel.core.network.auth.AuthTokenRefreshResult import com.team.prezel.core.network.auth.AuthTokenRefresher -import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse import io.ktor.client.HttpClient import io.ktor.client.plugins.auth.clearAuthTokens +import kotlinx.coroutines.flow.first import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( @@ -23,7 +23,7 @@ internal class AuthRepositoryImpl @Inject constructor( private val httpClient: HttpClient, ) : AuthRepository { override suspend fun checkLoginStatus(): LoginStatusResult { - val token = authLocalDataSource.getToken() + val token = authLocalDataSource.getToken().first() if (!token?.accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated val refreshToken = token?.refreshToken @@ -45,13 +45,17 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun logout(): AuthActionResult { - if (authLocalDataSource.getToken()?.accessToken.isNullOrBlank()) return clearTokensAndAuthenticationRequired() + if (authLocalDataSource + .getToken() + .first() + ?.accessToken + .isNullOrBlank() + ) { + return clearTokensAndAuthenticationRequired() + } return when (val response = authRemoteDataSource.logout()) { - is ApiResponse.Success -> { - clearTokens() - AuthActionResult.Success - } + is ApiResponse.Success -> clearTokens().toAuthActionSuccessResult() is ApiResponse.Failure.HttpError -> response.toAuthActionResult() is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) @@ -59,15 +63,21 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun login(idToken: String): Result = - authRemoteDataSource - .login(idToken = idToken) - .toResult { response -> - saveTokens(response) - Unit - } + when (val response = authRemoteDataSource.login(idToken = idToken)) { + is ApiResponse.Success -> saveTokens(response.data).map { Unit } + is ApiResponse.Failure.HttpError -> Result.failure(response.throwable) + is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + } override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { - if (authLocalDataSource.getToken()?.accessToken.isNullOrBlank()) return clearTokensAndAuthenticationRequired() + if (authLocalDataSource + .getToken() + .first() + ?.accessToken + .isNullOrBlank() + ) { + return clearTokensAndAuthenticationRequired() + } return when ( val response = @@ -76,23 +86,22 @@ internal class AuthRepositoryImpl @Inject constructor( reasonText = reason.toReasonText(), ) ) { - is ApiResponse.Success -> { - clearTokens() - AuthActionResult.Success - } + is ApiResponse.Success -> clearTokens().toAuthActionSuccessResult() is ApiResponse.Failure.HttpError -> response.toAuthActionResult() is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) } } - private suspend fun saveTokens(response: LoginResponse): AuthToken = - response - .toAuthToken() - .also { token -> - authLocalDataSource.saveToken(token) + private suspend fun saveTokens(response: LoginResponse): Result { + val token = response.toAuthToken() + + return authLocalDataSource + .saveToken(token) + .onSuccess { httpClient.clearAuthTokens() - } + }.map { token } + } private fun LoginResponse.toAuthToken(): AuthToken = AuthToken( @@ -102,21 +111,32 @@ internal class AuthRepositoryImpl @Inject constructor( private suspend fun ApiResponse.Failure.HttpError.toAuthActionResult(): AuthActionResult = if (error?.code == AUTHENTICATION_REQUIRED_CODE) { - clearTokens() - AuthActionResult.AuthenticationRequired + clearTokens().fold( + onSuccess = { AuthActionResult.AuthenticationRequired }, + onFailure = { throwable -> AuthActionResult.Failure(throwable) }, + ) } else { AuthActionResult.Failure(throwable) } - private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult { - clearTokens() - return AuthActionResult.AuthenticationRequired - } + private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult = + clearTokens().fold( + onSuccess = { AuthActionResult.AuthenticationRequired }, + onFailure = { throwable -> AuthActionResult.Failure(throwable) }, + ) - private suspend fun clearTokens() { - authLocalDataSource.clear() - httpClient.clearAuthTokens() - } + private suspend fun clearTokens(): Result = + authLocalDataSource + .clear() + .onSuccess { + httpClient.clearAuthTokens() + } + + private fun Result.toAuthActionSuccessResult(): AuthActionResult = + fold( + onSuccess = { AuthActionResult.Success }, + onFailure = { throwable -> AuthActionResult.Failure(throwable) }, + ) private fun WithdrawReason.toCategory(): String = when (this) { diff --git a/Prezel/core/datastore/build.gradle.kts b/Prezel/core/datastore/build.gradle.kts index 623c0787..8aadcd21 100644 --- a/Prezel/core/datastore/build.gradle.kts +++ b/Prezel/core/datastore/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt new file mode 100644 index 00000000..fd475220 --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.datastore.auth + +import com.team.prezel.core.model.auth.AuthToken +import kotlinx.coroutines.flow.Flow + +/** + * 인증 토큰을 로컬 저장소에서 읽고 쓰는 데이터 소스 계약입니다. + */ +interface AuthLocalDataSource { + fun getToken(): Flow + + suspend fun saveToken(token: AuthToken): Result + + suspend fun clear(): Result +} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt new file mode 100644 index 00000000..efcec178 --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -0,0 +1,82 @@ +package com.team.prezel.core.datastore.auth + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import com.team.prezel.core.datastore.di.ApplicationScope +import com.team.prezel.core.model.auth.AuthToken +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException + +@Singleton +internal class AuthLocalDataSourceImpl @Inject constructor( + @ApplicationContext context: Context, + @param:ApplicationScope private val applicationScope: CoroutineScope, +) : AuthLocalDataSource { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val dataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = applicationScope, + produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, + ) + + override fun getToken(): Flow = + dataStore.data + .catch { exception -> + if (exception is IOException) emit(emptyPreferences()) else throw exception + }.map { preferences -> + preferences.toAuthToken() + } + + override suspend fun saveToken(token: AuthToken): Result = + runSuspendCatching { + dataStore.edit { preferences -> + preferences[KEY_AUTH_TOKEN] = json.encodeToString(token) + } + } + + override suspend fun clear(): Result = + runSuspendCatching { + dataStore.edit { preferences -> + preferences.remove(KEY_AUTH_TOKEN) + } + } + + private suspend inline fun runSuspendCatching(crossinline block: suspend () -> Unit): Result = + try { + block() + Result.success(Unit) + } catch (t: Throwable) { + if (t is CancellationException) throw t + Result.failure(t) + } + + private fun Preferences.toAuthToken(): AuthToken? = + this[KEY_AUTH_TOKEN] + ?.let { tokenJson -> + runCatching { json.decodeFromString(tokenJson) }.getOrNull() + }?.takeIf { token -> token.accessToken.isNotBlank() && token.refreshToken.isNotBlank() } + + private companion object { + const val PREFERENCES_NAME = "auth_token_preferences" + val KEY_AUTH_TOKEN = stringPreferencesKey("auth_token") + } +} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt deleted file mode 100644 index 8c9d7128..00000000 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenStore.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.team.prezel.core.datastore.auth - -import com.team.prezel.core.model.auth.AuthToken - -/** - * 인증 토큰의 영속 저장소를 관리하는 저장소 계약입니다. - * - * Ktor BearerAuthProvider가 `loadTokens` 결과를 캐싱하므로, 이 저장소는 DataStore에 - * 저장된 값을 읽고 쓰는 역할만 담당합니다. - */ -interface AuthTokenStore { - suspend fun getToken(): AuthToken? - - suspend fun saveToken(token: AuthToken) - - suspend fun clear() -} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt deleted file mode 100644 index c8a01c8a..00000000 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/DataStoreAuthTokenStore.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.team.prezel.core.datastore.auth - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStoreFile -import com.team.prezel.core.datastore.di.ApplicationScope -import com.team.prezel.core.model.auth.AuthToken -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first -import java.io.IOException -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -internal class DataStoreAuthTokenStore @Inject constructor( - @ApplicationContext context: Context, - @param:ApplicationScope private val applicationScope: CoroutineScope, -) : AuthTokenStore { - private val dataStore: DataStore = - PreferenceDataStoreFactory.create( - scope = applicationScope, - produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, - ) - - override suspend fun getToken(): AuthToken? { - val preferences = readPreferences() - val accessToken = preferences[KEY_ACCESS_TOKEN] - val refreshToken = preferences[KEY_REFRESH_TOKEN] - if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) return null - - return AuthToken( - accessToken = accessToken, - refreshToken = refreshToken, - ) - } - - override suspend fun saveToken(token: AuthToken) { - dataStore.edit { preferences -> - preferences[KEY_ACCESS_TOKEN] = token.accessToken - preferences[KEY_REFRESH_TOKEN] = token.refreshToken - } - } - - override suspend fun clear() { - dataStore.edit { preferences -> - preferences.remove(KEY_ACCESS_TOKEN) - preferences.remove(KEY_REFRESH_TOKEN) - } - } - - private suspend fun readPreferences(): Preferences = - dataStore.data - .catch { exception -> - if (exception is IOException) emit(emptyPreferences()) else throw exception - }.first() - - private companion object { - const val PREFERENCES_NAME = "auth_token_preferences" - val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") - val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") - } -} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/TokenStoreModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataSourceModule.kt similarity index 50% rename from Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/TokenStoreModule.kt rename to Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataSourceModule.kt index 2e641e26..a9cc8340 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/TokenStoreModule.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataSourceModule.kt @@ -1,7 +1,7 @@ package com.team.prezel.core.datastore.di -import com.team.prezel.core.datastore.auth.AuthTokenStore -import com.team.prezel.core.datastore.auth.DataStoreAuthTokenStore +import com.team.prezel.core.datastore.auth.AuthLocalDataSource +import com.team.prezel.core.datastore.auth.AuthLocalDataSourceImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -10,8 +10,8 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal abstract class TokenStoreModule { +internal abstract class DataSourceModule { @Binds @Singleton - abstract fun bindAuthTokenStore(impl: DataStoreAuthTokenStore): AuthTokenStore + abstract fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource } diff --git a/Prezel/core/model/build.gradle.kts b/Prezel/core/model/build.gradle.kts index ec4f50d9..3d759f04 100644 --- a/Prezel/core/model/build.gradle.kts +++ b/Prezel/core/model/build.gradle.kts @@ -1,7 +1,9 @@ plugins { alias(libs.plugins.prezel.jvm.library) + alias(libs.plugins.kotlinx.serialization) } dependencies { implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt index c505e6d4..93f21a87 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt @@ -1,5 +1,8 @@ package com.team.prezel.core.model.auth +import kotlinx.serialization.Serializable + +@Serializable data class AuthToken( val accessToken: String, val refreshToken: String, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index b73495c4..543d58ef 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -1,8 +1,8 @@ package com.team.prezel.core.network.auth +import com.team.prezel.core.datastore.auth.AuthLocalDataSource import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.model.ApiErrorResponse import com.team.prezel.core.network.model.auth.LoginResponse import com.team.prezel.core.network.model.auth.ReissueTokenRequest @@ -16,6 +16,7 @@ import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json @@ -34,7 +35,7 @@ class AuthTokenRefresher @Inject constructor( suspend fun refreshBearerTokens(params: RefreshTokensParams): BearerTokens? = mutex.withLock { val refreshToken = params.oldTokens?.refreshToken - ?: authLocalDataSource.getToken()?.refreshToken + ?: authLocalDataSource.getToken().first()?.refreshToken ?: return@withLock null when ( @@ -89,7 +90,12 @@ class AuthTokenRefresher @Inject constructor( accessToken = response.accessToken, refreshToken = response.refreshToken, ) - authLocalDataSource.saveToken(token) + authLocalDataSource + .saveToken(token) + .onFailure { throwable -> + Timber.e(throwable, "재발급된 토큰 저장에 실패했습니다.") + return AuthTokenRefreshResult.Failure.Retryable(throwable) + } if (BuildConfig.DEBUG) { Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") } @@ -97,7 +103,11 @@ class AuthTokenRefresher @Inject constructor( } catch (t: Throwable) { t.rethrowIfCancellation() if (t.isSessionRecoveryUnrecoverable()) { - authLocalDataSource.clear() + authLocalDataSource + .clear() + .onFailure { throwable -> + Timber.e(throwable, "인증 토큰 삭제에 실패했습니다.") + } Timber.e(t, "토큰 재발급에 실패했습니다.") AuthTokenRefreshResult.Failure.Unrecoverable(t) } else { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt deleted file mode 100644 index cb208857..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSource.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.team.prezel.core.network.datasource - -import com.team.prezel.core.model.auth.AuthToken - -interface AuthLocalDataSource { - suspend fun getToken(): AuthToken? - - suspend fun saveToken(token: AuthToken) - - suspend fun clear() -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt deleted file mode 100644 index da6fc9b0..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthLocalDataSourceImpl.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.team.prezel.core.network.datasource - -import com.team.prezel.core.datastore.auth.AuthTokenStore -import com.team.prezel.core.model.auth.AuthToken -import javax.inject.Inject - -internal class AuthLocalDataSourceImpl @Inject constructor( - private val authTokenStore: AuthTokenStore, -) : AuthLocalDataSource { - override suspend fun getToken(): AuthToken? = authTokenStore.getToken() - - override suspend fun saveToken(token: AuthToken) { - authTokenStore.saveToken(token) - } - - override suspend fun clear() { - authTokenStore.clear() - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt index 221a43ca..573e58bd 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt @@ -1,7 +1,5 @@ package com.team.prezel.core.network.di -import com.team.prezel.core.network.datasource.AuthLocalDataSource -import com.team.prezel.core.network.datasource.AuthLocalDataSourceImpl import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSourceImpl import dagger.Binds @@ -13,10 +11,6 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal abstract class DataSourceModule { - @Binds - @Singleton - abstract fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource - @Binds @Singleton abstract fun bindAuthRemoteDataSource(impl: AuthRemoteDataSourceImpl): AuthRemoteDataSource diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 272b3e44..f6037a75 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,11 +1,11 @@ package com.team.prezel.core.network.di import android.os.Build +import com.team.prezel.core.datastore.auth.AuthLocalDataSource import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.auth.AuthPathPolicy import com.team.prezel.core.network.auth.AuthTokenRefresher -import com.team.prezel.core.network.datasource.AuthLocalDataSource import com.team.prezel.core.network.service.AuthService import com.team.prezel.core.network.service.createAuthService import dagger.Module @@ -30,6 +30,7 @@ import io.ktor.http.HttpHeaders import io.ktor.http.contentType import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json import timber.log.Timber import javax.inject.Singleton @@ -97,7 +98,7 @@ object NetworkModule { } private suspend fun AuthLocalDataSource.toBearerTokens(): BearerTokens? { - val token = getToken() ?: return null + val token = getToken().first() ?: return null return BearerTokens( accessToken = token.accessToken, From ea64dc36b5452d8c016253067fa4249601c8bac5 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 14:16:46 +0900 Subject: [PATCH 39/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EB=B0=8F=20Ktor=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=BA=90=EC=8B=9C=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: AuthTokenCacheInvalidator 인터페이스 및 구현체 추가** * Ktor의 `clearAuthTokens()`를 호출하여 메모리 내 인증 토큰 캐시를 비우는 `AuthTokenCacheInvalidator` 인터페이스를 추가했습니다. * `KtorAuthTokenCacheInvalidator`: `HttpClient`를 주입받아 실제 무효화 로직을 수행하는 구현체입니다. * `AuthTokenCacheModule`: Hilt를 사용하여 `AuthTokenCacheInvalidator` 의존성을 바인딩했습니다. * **refactor: AuthLocalDataSource를 통한 토큰 캐시 무효화 일원화** * 토큰 저장(`saveToken`) 또는 삭제(`clear`) 시, `AuthTokenCacheInvalidator`를 호출하여 네트워크 클라이언트의 토큰 상태를 동기화하도록 개선했습니다. * 이로 인해 `AuthRepositoryImpl`에서 수동으로 `httpClient.clearAuthTokens()`를 호출하던 중복 로직을 제거했습니다. * **refactor: AuthRepository 및 RemoteDataSource 내 불필요 로직 제거** * `AuthRepositoryImpl`: `checkLoginStatus`에서 수행하던 수동 토큰 재발급(Reissue) 로직을 제거하고 로컬 토큰 존재 여부만 확인하도록 간소화했습니다. (Ktor `Auth` 플러그인에 역할 위임) * `AuthRemoteDataSource`: 사용하지 않는 `reissueToken` 메서드 및 관련 DTO 의존성을 제거했습니다. * **style: NetworkModule 설정 변경** * `NetworkModule`의 Json 설정에서 불필요한 `prettyPrint = false` 옵션을 제거했습니다. --- .../data/repository/AuthRepositoryImpl.kt | 43 +++---------------- .../datastore/auth/AuthLocalDataSourceImpl.kt | 7 +++ .../auth/AuthTokenCacheInvalidator.kt | 5 +++ .../auth/KtorAuthTokenCacheInvalidator.kt | 15 +++++++ .../datasource/AuthRemoteDataSource.kt | 2 - .../datasource/AuthRemoteDataSourceImpl.kt | 7 --- .../core/network/di/AuthTokenCacheModule.kt | 15 +++++++ .../prezel/core/network/di/NetworkModule.kt | 1 - 8 files changed, 47 insertions(+), 48 deletions(-) create mode 100644 Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 45866eb9..a4f6855f 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -6,42 +6,19 @@ import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.model.auth.WithdrawReason -import com.team.prezel.core.network.auth.AuthTokenRefreshResult -import com.team.prezel.core.network.auth.AuthTokenRefresher import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse -import io.ktor.client.HttpClient -import io.ktor.client.plugins.auth.clearAuthTokens import kotlinx.coroutines.flow.first import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, private val authLocalDataSource: AuthLocalDataSource, - private val authTokenRefresher: AuthTokenRefresher, - private val httpClient: HttpClient, ) : AuthRepository { override suspend fun checkLoginStatus(): LoginStatusResult { val token = authLocalDataSource.getToken().first() - if (!token?.accessToken.isNullOrBlank()) return LoginStatusResult.Authenticated - - val refreshToken = token?.refreshToken - if (refreshToken.isNullOrBlank()) return LoginStatusResult.Unauthenticated - - return when (val result = authTokenRefresher.reissueToken(httpClient, refreshToken)) { - is AuthTokenRefreshResult.Success -> { - httpClient.clearAuthTokens() - LoginStatusResult.Authenticated - } - - is AuthTokenRefreshResult.Failure.Unrecoverable -> { - httpClient.clearAuthTokens() - LoginStatusResult.Unauthenticated - } - - is AuthTokenRefreshResult.Failure.Retryable -> LoginStatusResult.RetryableFailure(result.throwable) - } + return if (token == null) LoginStatusResult.Unauthenticated else LoginStatusResult.Authenticated } override suspend fun logout(): AuthActionResult { @@ -64,7 +41,7 @@ internal class AuthRepositoryImpl @Inject constructor( override suspend fun login(idToken: String): Result = when (val response = authRemoteDataSource.login(idToken = idToken)) { - is ApiResponse.Success -> saveTokens(response.data).map { Unit } + is ApiResponse.Success -> saveTokens(response.data) is ApiResponse.Failure.HttpError -> Result.failure(response.throwable) is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) } @@ -93,14 +70,9 @@ internal class AuthRepositoryImpl @Inject constructor( } } - private suspend fun saveTokens(response: LoginResponse): Result { + private suspend fun saveTokens(response: LoginResponse): Result { val token = response.toAuthToken() - - return authLocalDataSource - .saveToken(token) - .onSuccess { - httpClient.clearAuthTokens() - }.map { token } + return authLocalDataSource.saveToken(token) } private fun LoginResponse.toAuthToken(): AuthToken = @@ -125,12 +97,7 @@ internal class AuthRepositoryImpl @Inject constructor( onFailure = { throwable -> AuthActionResult.Failure(throwable) }, ) - private suspend fun clearTokens(): Result = - authLocalDataSource - .clear() - .onSuccess { - httpClient.clearAuthTokens() - } + private suspend fun clearTokens(): Result = authLocalDataSource.clear() private fun Result.toAuthActionSuccessResult(): AuthActionResult = fold( diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt index efcec178..7ae9eea9 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -26,6 +26,7 @@ import kotlin.coroutines.cancellation.CancellationException internal class AuthLocalDataSourceImpl @Inject constructor( @ApplicationContext context: Context, @param:ApplicationScope private val applicationScope: CoroutineScope, + private val authTokenCacheInvalidator: AuthTokenCacheInvalidator, ) : AuthLocalDataSource { private val json = Json { ignoreUnknownKeys = true @@ -51,6 +52,7 @@ internal class AuthLocalDataSourceImpl @Inject constructor( dataStore.edit { preferences -> preferences[KEY_AUTH_TOKEN] = json.encodeToString(token) } + invalidateAuthTokenCaches() } override suspend fun clear(): Result = @@ -58,6 +60,7 @@ internal class AuthLocalDataSourceImpl @Inject constructor( dataStore.edit { preferences -> preferences.remove(KEY_AUTH_TOKEN) } + invalidateAuthTokenCaches() } private suspend inline fun runSuspendCatching(crossinline block: suspend () -> Unit): Result = @@ -75,6 +78,10 @@ internal class AuthLocalDataSourceImpl @Inject constructor( runCatching { json.decodeFromString(tokenJson) }.getOrNull() }?.takeIf { token -> token.accessToken.isNotBlank() && token.refreshToken.isNotBlank() } + private fun invalidateAuthTokenCaches() { + authTokenCacheInvalidator.invalidate() + } + private companion object { const val PREFERENCES_NAME = "auth_token_preferences" val KEY_AUTH_TOKEN = stringPreferencesKey("auth_token") diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt new file mode 100644 index 00000000..a272220d --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.datastore.auth + +interface AuthTokenCacheInvalidator { + fun invalidate() +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt new file mode 100644 index 00000000..6fd91b42 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.network.auth + +import com.team.prezel.core.datastore.auth.AuthTokenCacheInvalidator +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.clearAuthTokens +import javax.inject.Inject +import javax.inject.Provider + +internal class KtorAuthTokenCacheInvalidator @Inject constructor( + private val httpClientProvider: Provider, +) : AuthTokenCacheInvalidator { + override fun invalidate() { + httpClientProvider.get().clearAuthTokens() + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt index 52b2ebc4..45b2776e 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt @@ -4,8 +4,6 @@ import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse interface AuthRemoteDataSource { - suspend fun reissueToken(refreshToken: String): ApiResponse - suspend fun logout(): ApiResponse suspend fun login(idToken: String): ApiResponse diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index 545e6e33..d8114e22 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -4,7 +4,6 @@ import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginRequest import com.team.prezel.core.network.model.auth.LoginResponse -import com.team.prezel.core.network.model.auth.ReissueTokenRequest import com.team.prezel.core.network.model.auth.WithdrawRequest import com.team.prezel.core.network.service.AuthService import timber.log.Timber @@ -13,12 +12,6 @@ import javax.inject.Inject internal class AuthRemoteDataSourceImpl @Inject constructor( private val authService: AuthService, ) : AuthRemoteDataSource { - override suspend fun reissueToken(refreshToken: String): ApiResponse = - authService - .reissueToken( - request = ReissueTokenRequest(refreshToken = refreshToken), - ).also(::logTokenResponse) - override suspend fun logout(): ApiResponse = authService.logout() override suspend fun login(idToken: String): ApiResponse = diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt new file mode 100644 index 00000000..c9f5571a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.network.di + +import com.team.prezel.core.datastore.auth.AuthTokenCacheInvalidator +import com.team.prezel.core.network.auth.KtorAuthTokenCacheInvalidator +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AuthTokenCacheModule { + @Binds + abstract fun bindAuthTokenCacheInvalidator(impl: KtorAuthTokenCacheInvalidator): AuthTokenCacheInvalidator +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index f6037a75..931f47b1 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -44,7 +44,6 @@ object NetworkModule { Json { ignoreUnknownKeys = true encodeDefaults = true - prettyPrint = false } @Provides From 451417d9c75463049b0cbe0a48deb20d2a324d87 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 14:57:00 +0900 Subject: [PATCH 40/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `AuthLocalDataSource` 내 토큰 저장 및 캐시 무효화 로직 개선** * `saveToken` 메서드에 `invalidateCache` 파라미터를 추가하여 토큰 저장 시 캐시 무효화 여부를 선택할 수 있도록 수정했습니다. * `saveToken` 및 `clear` 성공 시에만 캐시 무효화 로직(`invalidateAuthTokenCaches`)이 실행되도록 변경했습니다. * `runSuspendCatching` 내 예외 처리 순서를 조정하여 `CancellationException`이 올바르게 전파되도록 수정하고, 캐시 무효화 실패 시 Timber 로그를 남기도록 개선했습니다. * **refactor: `AuthTokenRefresher` 토큰 재발급 로직 최적화** * 불필요한 `reissueToken` 메서드를 삭제했습니다. * 토큰 재발급 성공 후 `saveToken` 호출 시 `invalidateCache = false` 옵션을 적용하여, 재발급 과정 중 불필요한 캐시 무효화가 발생하지 않도록 개선했습니다. * **remove: `AuthService` 내 미사용 엔드포인트 제거** * `AuthService` 인터페이스에서 더 이상 사용하지 않는 `reissueToken` API 정의와 관련 DTO(`ReissueTokenRequest`) 참조를 삭제했습니다. * **build: `core:datastore` 모듈 의존성 추가** * 로깅 처리를 위해 `libs.timber` 의존성을 추가했습니다. --- Prezel/core/datastore/build.gradle.kts | 1 + .../datastore/auth/AuthLocalDataSource.kt | 5 ++- .../datastore/auth/AuthLocalDataSourceImpl.kt | 31 ++++++++++++++----- .../core/network/auth/AuthTokenRefresher.kt | 21 +++---------- .../core/network/service/AuthService.kt | 6 ---- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Prezel/core/datastore/build.gradle.kts b/Prezel/core/datastore/build.gradle.kts index 8aadcd21..472d5c86 100644 --- a/Prezel/core/datastore/build.gradle.kts +++ b/Prezel/core/datastore/build.gradle.kts @@ -14,4 +14,5 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) + implementation(libs.timber) } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt index fd475220..8df3930b 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt @@ -9,7 +9,10 @@ import kotlinx.coroutines.flow.Flow interface AuthLocalDataSource { fun getToken(): Flow - suspend fun saveToken(token: AuthToken): Result + suspend fun saveToken( + token: AuthToken, + invalidateCache: Boolean = true, + ): Result suspend fun clear(): Result } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt index 7ae9eea9..f2f34301 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import timber.log.Timber import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -47,28 +48,36 @@ internal class AuthLocalDataSourceImpl @Inject constructor( preferences.toAuthToken() } - override suspend fun saveToken(token: AuthToken): Result = - runSuspendCatching { + override suspend fun saveToken( + token: AuthToken, + invalidateCache: Boolean, + ): Result { + val result = runSuspendCatching { dataStore.edit { preferences -> preferences[KEY_AUTH_TOKEN] = json.encodeToString(token) } - invalidateAuthTokenCaches() } + if (result.isSuccess && invalidateCache) invalidateAuthTokenCaches() + return result + } - override suspend fun clear(): Result = - runSuspendCatching { + override suspend fun clear(): Result { + val result = runSuspendCatching { dataStore.edit { preferences -> preferences.remove(KEY_AUTH_TOKEN) } - invalidateAuthTokenCaches() } + if (result.isSuccess) invalidateAuthTokenCaches() + return result + } private suspend inline fun runSuspendCatching(crossinline block: suspend () -> Unit): Result = try { block() Result.success(Unit) + } catch (t: CancellationException) { + throw t } catch (t: Throwable) { - if (t is CancellationException) throw t Result.failure(t) } @@ -79,7 +88,13 @@ internal class AuthLocalDataSourceImpl @Inject constructor( }?.takeIf { token -> token.accessToken.isNotBlank() && token.refreshToken.isNotBlank() } private fun invalidateAuthTokenCaches() { - authTokenCacheInvalidator.invalidate() + try { + authTokenCacheInvalidator.invalidate() + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + Timber.e(t, "인증 토큰 캐시 무효화에 실패했습니다.") + } } private companion object { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 543d58ef..26a5509a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -9,7 +9,6 @@ import com.team.prezel.core.network.model.auth.ReissueTokenRequest import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.ResponseException -import io.ktor.client.plugins.auth.AuthCircuitBreaker import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.RefreshTokensParams import io.ktor.client.request.HttpRequestBuilder @@ -60,20 +59,6 @@ class AuthTokenRefresher @Inject constructor( } } - suspend fun reissueToken( - client: HttpClient, - refreshToken: String, - ): AuthTokenRefreshResult = - mutex.withLock { - requestTokenReissue( - client = client, - refreshToken = refreshToken, - markAsRefreshTokenRequest = { - attributes.put(AuthCircuitBreaker, Unit) - }, - ) - } - private suspend fun requestTokenReissue( client: HttpClient, refreshToken: String, @@ -91,8 +76,10 @@ class AuthTokenRefresher @Inject constructor( refreshToken = response.refreshToken, ) authLocalDataSource - .saveToken(token) - .onFailure { throwable -> + .saveToken( + token = token, + invalidateCache = false, + ).onFailure { throwable -> Timber.e(throwable, "재발급된 토큰 저장에 실패했습니다.") return AuthTokenRefreshResult.Failure.Retryable(throwable) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt index 3fc040e5..2ace3d51 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -3,18 +3,12 @@ package com.team.prezel.core.network.service import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginRequest import com.team.prezel.core.network.model.auth.LoginResponse -import com.team.prezel.core.network.model.auth.ReissueTokenRequest import com.team.prezel.core.network.model.auth.WithdrawRequest import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE import de.jensklingenberg.ktorfit.http.POST internal interface AuthService { - @POST("auth/reissue") - suspend fun reissueToken( - @Body request: ReissueTokenRequest, - ): ApiResponse - @POST("auth/logout") suspend fun logout(): ApiResponse From af23eb70935548f7fe0d7d50e76e88a4e69bbab4 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 19:08:15 +0900 Subject: [PATCH 41/63] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=A7=8C=EB=A3=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=20=EB=B0=8F=20DataStore=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 인증 세션 만료 알림 시스템 구현** * 인증 만료 이벤트를 정의하는 `AuthSessionEvent` sealed interface 추가 * 이벤트를 발행하고 구독하기 위한 `AuthSessionEventPublisher`, `AuthSessionEventStream` 인터페이스 추가 * `DefaultAuthSessionEventBus`를 통해 앱 전역에서 세션 만료를 감지하고 처리할 수 있는 구조 구현 * `MainActivity` 및 `PrezelApp`에서 세션 만료 시 로그인 화면으로 전환하고 사용자 정보를 정리하도록 연동 * **refactor: `AuthTokenStore` 도입 및 데이터 레이어 추상화** * `core:network` 모듈에 `AuthTokenStore` 인터페이스를 정의하여 `core:datastore`에 대한 직접적인 의존성 제거 * `core:data` 모듈에서 `AuthTokenStore`를 구현(`DataAuthTokenStore`)하여 `AuthLocalDataSource`와 네트워크 레이어 연결 * 불필요해진 `AuthTokenCacheInvalidator` 관련 로직 및 모듈 제거 * **refactor: `AuthTokenRefresher` 및 네트워크 보안 로직 강화** * `AuthTokenRefresher`가 `AuthTokenStore`를 사용하도록 수정 * 토큰 재발급 실패(Unrecoverable) 시 `AuthSessionExpiredNotifier`를 호출하여 세션 만료 이벤트를 전파하도록 개선 * Ktor `HttpClient` 설정에서 `cacheTokens` 옵션을 `false`로 변경하여 토큰 관리 신뢰성 향상 * **refactor: DataStore 설정 및 모듈 분리** * `DataStoreModule`을 통해 `DataStore` 주입 설정을 별도로 분리 * `AuthLocalDataSourceImpl`에서 직접 수행하던 DataStore 빌드 로직을 삭제하고 주입받은 인스턴스를 사용하도록 변경 * **build: 모듈 간 의존성 정렬** * `core:network`에서 `core:datastore` 의존성 제거 * `app` 모듈에 `core:domain` 의존성 추가 등 레이어 설계에 따른 의존성 최적화 --- Prezel/app/build.gradle.kts | 1 + .../main/java/com/team/prezel/MainActivity.kt | 10 +++++ .../main/java/com/team/prezel/ui/PrezelApp.kt | 17 +++++++ Prezel/core/data/build.gradle.kts | 1 - .../auth/DataAuthSessionExpiredNotifier.kt | 13 ++++++ .../core/data/auth/DataAuthTokenStore.kt | 19 ++++++++ .../data/auth/DefaultAuthSessionEventBus.kt | 22 +++++++++ .../team/prezel/core/data/di/DataModule.kt | 23 ++++++++++ Prezel/core/datastore/build.gradle.kts | 1 - .../datastore/auth/AuthLocalDataSource.kt | 5 +-- .../datastore/auth/AuthLocalDataSourceImpl.kt | 45 +++---------------- .../auth/AuthTokenCacheInvalidator.kt | 5 --- .../core/datastore/di/DataStoreModule.kt | 31 +++++++++++++ .../core/domain/session/AuthSessionEvent.kt | 5 +++ .../domain/session/AuthSessionEventBus.kt | 11 +++++ Prezel/core/network/build.gradle.kts | 3 +- .../auth/AuthSessionExpiredNotifier.kt | 5 +++ .../core/network/auth/AuthTokenRefresher.kt | 17 ++++--- .../core/network/auth/AuthTokenStore.kt | 12 +++++ .../auth/KtorAuthTokenCacheInvalidator.kt | 15 ------- .../core/network/di/AuthTokenCacheModule.kt | 15 ------- .../prezel/core/network/di/NetworkModule.kt | 14 +++--- 22 files changed, 191 insertions(+), 99 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt delete mode 100644 Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt create mode 100644 Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 6ac709f8..e134d27c 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.coreAuth) implementation(projects.coreData) implementation(projects.coreDesignsystem) + implementation(projects.coreDomain) implementation(projects.coreNavigation) implementation(projects.coreUi) diff --git a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt index 6188a75d..d09f6842 100644 --- a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt +++ b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt @@ -6,8 +6,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.data.NetworkMonitor import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.domain.session.AuthSessionEventStream import com.team.prezel.ui.PrezelApp import com.team.prezel.ui.rememberPrezelAppState import dagger.hilt.android.AndroidEntryPoint @@ -19,6 +21,12 @@ class MainActivity : ComponentActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var authManager: AuthManager + + @Inject + lateinit var authSessionEventStream: AuthSessionEventStream + @Inject lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit> @@ -35,6 +43,8 @@ class MainActivity : ComponentActivity() { PrezelApp( appState = appState, entryBuilders = entryBuilders.toImmutableSet(), + authSessionEventStream = authSessionEventStream, + onSessionExpired = authManager::clearCurrentProvider, ) } } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index f4330a9b..3305d511 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -17,11 +18,14 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold +import com.team.prezel.core.domain.session.AuthSessionEvent +import com.team.prezel.core.domain.session.AuthSessionEventStream import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope import com.team.prezel.core.navigation.toEntries import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.navigation.MAIN_NAV_ITEMS import kotlinx.collections.immutable.ImmutableSet @@ -29,6 +33,8 @@ import kotlinx.collections.immutable.ImmutableSet fun PrezelApp( appState: PrezelAppState, entryBuilders: ImmutableSet.() -> Unit>, + authSessionEventStream: AuthSessionEventStream, + onSessionExpired: () -> Unit, ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } val snackbarHostState = remember { SnackbarHostState() } @@ -37,6 +43,17 @@ fun PrezelApp( LocalNavigator provides navigator, LocalSnackbarHostState provides snackbarHostState, ) { + LaunchedEffect(authSessionEventStream, navigator) { + authSessionEventStream.events.collect { event -> + when (event) { + AuthSessionEvent.Expired -> { + onSessionExpired() + navigator.replaceRoot(LoginNavKey) + } + } + } + } + DoubleBackToExitHandler(navigationState = appState.navigationState) PrezelAppContent( diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index c2e4a813..7b97b97a 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -15,5 +15,4 @@ dependencies { implementation(projects.coreNetwork) implementation(libs.kotlinx.coroutines.core) - implementation(libs.ktor.client.auth) } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt new file mode 100644 index 00000000..1c389c46 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt @@ -0,0 +1,13 @@ +package com.team.prezel.core.data.auth + +import com.team.prezel.core.domain.session.AuthSessionEventPublisher +import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier +import javax.inject.Inject + +internal class DataAuthSessionExpiredNotifier @Inject constructor( + private val authSessionEventPublisher: AuthSessionEventPublisher, +) : AuthSessionExpiredNotifier { + override fun notifySessionExpired() { + authSessionEventPublisher.notifySessionExpired() + } +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt new file mode 100644 index 00000000..0aec1624 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.data.auth + +import com.team.prezel.core.datastore.auth.AuthLocalDataSource +import com.team.prezel.core.model.auth.AuthToken +import com.team.prezel.core.network.auth.AuthTokenStore +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DataAuthTokenStore @Inject constructor( + private val authLocalDataSource: AuthLocalDataSource, +) : AuthTokenStore { + override fun getToken(): Flow = authLocalDataSource.getToken() + + override suspend fun saveToken(token: AuthToken): Result = authLocalDataSource.saveToken(token) + + override suspend fun clear(): Result = authLocalDataSource.clear() +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt new file mode 100644 index 00000000..ebd66aed --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt @@ -0,0 +1,22 @@ +package com.team.prezel.core.data.auth + +import com.team.prezel.core.domain.session.AuthSessionEvent +import com.team.prezel.core.domain.session.AuthSessionEventPublisher +import com.team.prezel.core.domain.session.AuthSessionEventStream +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class DefaultAuthSessionEventBus @Inject constructor() : + AuthSessionEventPublisher, + AuthSessionEventStream { + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + override val events: SharedFlow = _events.asSharedFlow() + + override fun notifySessionExpired() { + _events.tryEmit(AuthSessionEvent.Expired) + } + } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt index 13c0e917..511ac714 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt @@ -2,6 +2,13 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.ConnectivityManagerNetworkMonitor import com.team.prezel.core.data.NetworkMonitor +import com.team.prezel.core.data.auth.DataAuthSessionExpiredNotifier +import com.team.prezel.core.data.auth.DataAuthTokenStore +import com.team.prezel.core.data.auth.DefaultAuthSessionEventBus +import com.team.prezel.core.domain.session.AuthSessionEventPublisher +import com.team.prezel.core.domain.session.AuthSessionEventStream +import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier +import com.team.prezel.core.network.auth.AuthTokenStore import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,4 +21,20 @@ abstract class DataModule { @Singleton @Binds internal abstract fun bindsNetworkMonitor(networkMonitor: ConnectivityManagerNetworkMonitor): NetworkMonitor + + @Singleton + @Binds + internal abstract fun bindAuthTokenStore(authTokenStore: DataAuthTokenStore): AuthTokenStore + + @Singleton + @Binds + internal abstract fun bindAuthSessionExpiredNotifier(notifier: DataAuthSessionExpiredNotifier): AuthSessionExpiredNotifier + + @Singleton + @Binds + internal abstract fun bindAuthSessionEventPublisher(eventBus: DefaultAuthSessionEventBus): AuthSessionEventPublisher + + @Singleton + @Binds + internal abstract fun bindAuthSessionEventStream(eventBus: DefaultAuthSessionEventBus): AuthSessionEventStream } diff --git a/Prezel/core/datastore/build.gradle.kts b/Prezel/core/datastore/build.gradle.kts index 472d5c86..8aadcd21 100644 --- a/Prezel/core/datastore/build.gradle.kts +++ b/Prezel/core/datastore/build.gradle.kts @@ -14,5 +14,4 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.timber) } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt index 8df3930b..fd475220 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt @@ -9,10 +9,7 @@ import kotlinx.coroutines.flow.Flow interface AuthLocalDataSource { fun getToken(): Flow - suspend fun saveToken( - token: AuthToken, - invalidateCache: Boolean = true, - ): Result + suspend fun saveToken(token: AuthToken): Result suspend fun clear(): Result } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt index f2f34301..bf062218 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -1,23 +1,16 @@ package com.team.prezel.core.datastore.auth -import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStoreFile -import com.team.prezel.core.datastore.di.ApplicationScope import com.team.prezel.core.model.auth.AuthToken -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import timber.log.Timber import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -25,21 +18,13 @@ import kotlin.coroutines.cancellation.CancellationException @Singleton internal class AuthLocalDataSourceImpl @Inject constructor( - @ApplicationContext context: Context, - @param:ApplicationScope private val applicationScope: CoroutineScope, - private val authTokenCacheInvalidator: AuthTokenCacheInvalidator, + private val dataStore: DataStore, ) : AuthLocalDataSource { private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } - private val dataStore: DataStore = - PreferenceDataStoreFactory.create( - scope = applicationScope, - produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, - ) - override fun getToken(): Flow = dataStore.data .catch { exception -> @@ -48,28 +33,19 @@ internal class AuthLocalDataSourceImpl @Inject constructor( preferences.toAuthToken() } - override suspend fun saveToken( - token: AuthToken, - invalidateCache: Boolean, - ): Result { - val result = runSuspendCatching { + override suspend fun saveToken(token: AuthToken): Result = + runSuspendCatching { dataStore.edit { preferences -> preferences[KEY_AUTH_TOKEN] = json.encodeToString(token) } } - if (result.isSuccess && invalidateCache) invalidateAuthTokenCaches() - return result - } - override suspend fun clear(): Result { - val result = runSuspendCatching { + override suspend fun clear(): Result = + runSuspendCatching { dataStore.edit { preferences -> preferences.remove(KEY_AUTH_TOKEN) } } - if (result.isSuccess) invalidateAuthTokenCaches() - return result - } private suspend inline fun runSuspendCatching(crossinline block: suspend () -> Unit): Result = try { @@ -87,18 +63,7 @@ internal class AuthLocalDataSourceImpl @Inject constructor( runCatching { json.decodeFromString(tokenJson) }.getOrNull() }?.takeIf { token -> token.accessToken.isNotBlank() && token.refreshToken.isNotBlank() } - private fun invalidateAuthTokenCaches() { - try { - authTokenCacheInvalidator.invalidate() - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - Timber.e(t, "인증 토큰 캐시 무효화에 실패했습니다.") - } - } - private companion object { - const val PREFERENCES_NAME = "auth_token_preferences" val KEY_AUTH_TOKEN = stringPreferencesKey("auth_token") } } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt deleted file mode 100644 index a272220d..00000000 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthTokenCacheInvalidator.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.datastore.auth - -interface AuthTokenCacheInvalidator { - fun invalidate() -} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt new file mode 100644 index 00000000..a5b89425 --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,31 @@ +package com.team.prezel.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DataStoreModule { + @Provides + @Singleton + fun providePreferencesDataStore( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore = + PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, + ) + + private const val PREFERENCES_NAME = "auth_token_preferences" +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt new file mode 100644 index 00000000..e0f066d7 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.domain.session + +sealed interface AuthSessionEvent { + data object Expired : AuthSessionEvent +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt new file mode 100644 index 00000000..580b0fe8 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.session + +import kotlinx.coroutines.flow.SharedFlow + +interface AuthSessionEventStream { + val events: SharedFlow +} + +interface AuthSessionEventPublisher { + fun notifySessionExpired() +} diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index 3d6dd1a7..0487c135 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -17,8 +17,8 @@ android { } dependencies { - implementation(projects.coreDatastore) implementation(projects.coreModel) + implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.auth) @@ -27,7 +27,6 @@ dependencies { implementation(libs.ktor.client.logging) implementation(libs.kotlinx.serialization.json) implementation(libs.timber) - implementation(libs.ktorfit.lib) ksp(libs.ktorfit.ksp) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt new file mode 100644 index 00000000..59d82f2c --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.network.auth + +interface AuthSessionExpiredNotifier { + fun notifySessionExpired() +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 26a5509a..98241dbb 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -1,6 +1,5 @@ package com.team.prezel.core.network.auth -import com.team.prezel.core.datastore.auth.AuthLocalDataSource import com.team.prezel.core.model.auth.AuthToken import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.model.ApiErrorResponse @@ -27,14 +26,15 @@ import kotlin.coroutines.cancellation.CancellationException @Singleton class AuthTokenRefresher @Inject constructor( private val json: Json, - private val authLocalDataSource: AuthLocalDataSource, + private val authTokenStore: AuthTokenStore, + private val authSessionExpiredNotifier: AuthSessionExpiredNotifier, ) { private val mutex = Mutex() suspend fun refreshBearerTokens(params: RefreshTokensParams): BearerTokens? = mutex.withLock { val refreshToken = params.oldTokens?.refreshToken - ?: authLocalDataSource.getToken().first()?.refreshToken + ?: authTokenStore.getToken().first()?.refreshToken ?: return@withLock null when ( @@ -75,11 +75,9 @@ class AuthTokenRefresher @Inject constructor( accessToken = response.accessToken, refreshToken = response.refreshToken, ) - authLocalDataSource - .saveToken( - token = token, - invalidateCache = false, - ).onFailure { throwable -> + authTokenStore + .saveToken(token) + .onFailure { throwable -> Timber.e(throwable, "재발급된 토큰 저장에 실패했습니다.") return AuthTokenRefreshResult.Failure.Retryable(throwable) } @@ -90,11 +88,12 @@ class AuthTokenRefresher @Inject constructor( } catch (t: Throwable) { t.rethrowIfCancellation() if (t.isSessionRecoveryUnrecoverable()) { - authLocalDataSource + authTokenStore .clear() .onFailure { throwable -> Timber.e(throwable, "인증 토큰 삭제에 실패했습니다.") } + authSessionExpiredNotifier.notifySessionExpired() Timber.e(t, "토큰 재발급에 실패했습니다.") AuthTokenRefreshResult.Failure.Unrecoverable(t) } else { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt new file mode 100644 index 00000000..085338d4 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.auth + +import com.team.prezel.core.model.auth.AuthToken +import kotlinx.coroutines.flow.Flow + +interface AuthTokenStore { + fun getToken(): Flow + + suspend fun saveToken(token: AuthToken): Result + + suspend fun clear(): Result +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt deleted file mode 100644 index 6fd91b42..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/KtorAuthTokenCacheInvalidator.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.team.prezel.core.network.auth - -import com.team.prezel.core.datastore.auth.AuthTokenCacheInvalidator -import io.ktor.client.HttpClient -import io.ktor.client.plugins.auth.clearAuthTokens -import javax.inject.Inject -import javax.inject.Provider - -internal class KtorAuthTokenCacheInvalidator @Inject constructor( - private val httpClientProvider: Provider, -) : AuthTokenCacheInvalidator { - override fun invalidate() { - httpClientProvider.get().clearAuthTokens() - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt deleted file mode 100644 index c9f5571a..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/AuthTokenCacheModule.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.team.prezel.core.network.di - -import com.team.prezel.core.datastore.auth.AuthTokenCacheInvalidator -import com.team.prezel.core.network.auth.KtorAuthTokenCacheInvalidator -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -internal abstract class AuthTokenCacheModule { - @Binds - abstract fun bindAuthTokenCacheInvalidator(impl: KtorAuthTokenCacheInvalidator): AuthTokenCacheInvalidator -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 931f47b1..3f6d099d 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,11 +1,11 @@ package com.team.prezel.core.network.di import android.os.Build -import com.team.prezel.core.datastore.auth.AuthLocalDataSource import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.auth.AuthPathPolicy import com.team.prezel.core.network.auth.AuthTokenRefresher +import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.service.AuthService import com.team.prezel.core.network.service.createAuthService import dagger.Module @@ -50,9 +50,9 @@ object NetworkModule { @Singleton internal fun provideHttpClient( json: Json, - authLocalDataSource: AuthLocalDataSource, + authTokenStore: AuthTokenStore, authTokenRefresher: AuthTokenRefresher, - ): HttpClient = createHttpClient(json) { configureAuthenticatedClient(authLocalDataSource, authTokenRefresher) } + ): HttpClient = createHttpClient(json) { configureAuthenticatedClient(authTokenStore, authTokenRefresher) } @Provides @Singleton @@ -75,14 +75,14 @@ object NetworkModule { } private fun HttpClientConfig<*>.configureAuthenticatedClient( - authLocalDataSource: AuthLocalDataSource, + authTokenStore: AuthTokenStore, authTokenRefresher: AuthTokenRefresher, ) { install(Auth) { bearer { - cacheTokens = true + cacheTokens = false loadTokens { - authLocalDataSource.toBearerTokens() + authTokenStore.toBearerTokens() } refreshTokens { @@ -96,7 +96,7 @@ object NetworkModule { } } - private suspend fun AuthLocalDataSource.toBearerTokens(): BearerTokens? { + private suspend fun AuthTokenStore.toBearerTokens(): BearerTokens? { val token = getToken().first() ?: return null return BearerTokens( From a7bc1707375daea981970cbb04095d504c8f14e4 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 19:17:22 +0900 Subject: [PATCH 42/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `AuthSessionEventBus` 구현 및 이벤트 처리 로직 개선** * `AuthSessionEventStream`의 `events` 타입을 `SharedFlow`에서 일반 `Flow`로 변경하고, 이벤트를 초기화할 수 있는 `clearSessionExpiredEvent()` 메서드를 추가했습니다. * `DefaultAuthSessionEventBus` 구현체를 `MutableSharedFlow`에서 `MutableStateFlow` 기반으로 변경하여 상태 관리 효율성을 높였습니다. * `PrezelApp`에서 세션 만료 이벤트 처리 후 `clearSessionExpiredEvent()`를 호출하여 중복 처리를 방지하도록 수정했습니다. * **feat: `AuthDataModule` 분리 및 의존성 주입 설정** * 기존 `DataModule`에 포함되어 있던 인증 관련 의존성(TokenStore, Notifier, EventBus)을 신규 생성한 `AuthDataModule`로 분리하여 모듈성을 강화했습니다. * **refactor: `AuthTokenRefresher` 예외 처리 로직 강화** * 토큰 재발급 실패 시 토큰 삭제(`clear`) 성공 여부에 따라 세션 만료 알림을 보내도록 흐름을 개선했습니다. * 삭제 실패 시 에러 로그를 출력하고 세션 알림을 건너뛰도록 예외 처리를 구체화했습니다. --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 1 + .../data/auth/DefaultAuthSessionEventBus.kt | 16 +++++---- .../prezel/core/data/di/AuthDataModule.kt | 34 +++++++++++++++++++ .../team/prezel/core/data/di/DataModule.kt | 23 ------------- .../domain/session/AuthSessionEventBus.kt | 6 ++-- .../core/network/auth/AuthTokenRefresher.kt | 12 ++++--- 6 files changed, 56 insertions(+), 36 deletions(-) create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 3305d511..77209dbf 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -49,6 +49,7 @@ fun PrezelApp( AuthSessionEvent.Expired -> { onSessionExpired() navigator.replaceRoot(LoginNavKey) + authSessionEventStream.clearSessionExpiredEvent() } } } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt index ebd66aed..59fa335b 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt @@ -3,9 +3,9 @@ package com.team.prezel.core.data.auth import com.team.prezel.core.domain.session.AuthSessionEvent import com.team.prezel.core.domain.session.AuthSessionEventPublisher import com.team.prezel.core.domain.session.AuthSessionEventStream -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull import javax.inject.Inject import javax.inject.Singleton @@ -13,10 +13,14 @@ import javax.inject.Singleton internal class DefaultAuthSessionEventBus @Inject constructor() : AuthSessionEventPublisher, AuthSessionEventStream { - private val _events = MutableSharedFlow(extraBufferCapacity = 1) - override val events: SharedFlow = _events.asSharedFlow() + private val event = MutableStateFlow(null) + override val events: Flow = event.filterNotNull() override fun notifySessionExpired() { - _events.tryEmit(AuthSessionEvent.Expired) + event.value = AuthSessionEvent.Expired + } + + override fun clearSessionExpiredEvent() { + event.value = null } } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt new file mode 100644 index 00000000..67edbb0c --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt @@ -0,0 +1,34 @@ +package com.team.prezel.core.data.di + +import com.team.prezel.core.data.auth.DataAuthSessionExpiredNotifier +import com.team.prezel.core.data.auth.DataAuthTokenStore +import com.team.prezel.core.data.auth.DefaultAuthSessionEventBus +import com.team.prezel.core.domain.session.AuthSessionEventPublisher +import com.team.prezel.core.domain.session.AuthSessionEventStream +import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier +import com.team.prezel.core.network.auth.AuthTokenStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AuthDataModule { + @Singleton + @Binds + abstract fun bindAuthTokenStore(authTokenStore: DataAuthTokenStore): AuthTokenStore + + @Singleton + @Binds + abstract fun bindAuthSessionExpiredNotifier(notifier: DataAuthSessionExpiredNotifier): AuthSessionExpiredNotifier + + @Singleton + @Binds + abstract fun bindAuthSessionEventPublisher(eventBus: DefaultAuthSessionEventBus): AuthSessionEventPublisher + + @Singleton + @Binds + abstract fun bindAuthSessionEventStream(eventBus: DefaultAuthSessionEventBus): AuthSessionEventStream +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt index 511ac714..13c0e917 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/DataModule.kt @@ -2,13 +2,6 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.ConnectivityManagerNetworkMonitor import com.team.prezel.core.data.NetworkMonitor -import com.team.prezel.core.data.auth.DataAuthSessionExpiredNotifier -import com.team.prezel.core.data.auth.DataAuthTokenStore -import com.team.prezel.core.data.auth.DefaultAuthSessionEventBus -import com.team.prezel.core.domain.session.AuthSessionEventPublisher -import com.team.prezel.core.domain.session.AuthSessionEventStream -import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier -import com.team.prezel.core.network.auth.AuthTokenStore import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,20 +14,4 @@ abstract class DataModule { @Singleton @Binds internal abstract fun bindsNetworkMonitor(networkMonitor: ConnectivityManagerNetworkMonitor): NetworkMonitor - - @Singleton - @Binds - internal abstract fun bindAuthTokenStore(authTokenStore: DataAuthTokenStore): AuthTokenStore - - @Singleton - @Binds - internal abstract fun bindAuthSessionExpiredNotifier(notifier: DataAuthSessionExpiredNotifier): AuthSessionExpiredNotifier - - @Singleton - @Binds - internal abstract fun bindAuthSessionEventPublisher(eventBus: DefaultAuthSessionEventBus): AuthSessionEventPublisher - - @Singleton - @Binds - internal abstract fun bindAuthSessionEventStream(eventBus: DefaultAuthSessionEventBus): AuthSessionEventStream } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt index 580b0fe8..ad1cb184 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt @@ -1,9 +1,11 @@ package com.team.prezel.core.domain.session -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.Flow interface AuthSessionEventStream { - val events: SharedFlow + val events: Flow + + fun clearSessionExpiredEvent() } interface AuthSessionEventPublisher { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 98241dbb..62013cec 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -88,12 +88,14 @@ class AuthTokenRefresher @Inject constructor( } catch (t: Throwable) { t.rethrowIfCancellation() if (t.isSessionRecoveryUnrecoverable()) { - authTokenStore - .clear() - .onFailure { throwable -> + authTokenStore.clear().fold( + onSuccess = { + authSessionExpiredNotifier.notifySessionExpired() + }, + onFailure = { throwable -> Timber.e(throwable, "인증 토큰 삭제에 실패했습니다.") - } - authSessionExpiredNotifier.notifySessionExpired() + }, + ) Timber.e(t, "토큰 재발급에 실패했습니다.") AuthTokenRefreshResult.Failure.Unrecoverable(t) } else { From 9e5f0104fb8dc9719849be8ec7bfd104c2c0cc8a Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 21:22:42 +0900 Subject: [PATCH 43/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20(`AuthSessionEventBus`=20->=20`AuthSessionMonitor`)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 인증 세션 모니터링 인터페이스 및 구현체 변경** * 기존의 `AuthSessionEventStream` 및 `DefaultAuthSessionEventBus`를 삭제하고, 보다 명확한 역할을 수행하는 `AuthSessionMonitor`와 `DefaultAuthSessionMonitor`를 도입했습니다. * `clearSessionExpiredEvent()` 메서드명을 `acknowledgeSessionEvent()`로 변경하여 이벤트 확인 처리에 대한 의미를 강화했습니다. * `AuthSessionEventPublisher` 인터페이스를 별도 파일로 분리했습니다. * **refactor: 앱 수준의 세션 관리 구조 개선** * `PrezelAppState`에서 `AuthSessionMonitor`를 직접 보유하도록 수정하여 상태 관리의 응집도를 높였습니다. * `MainActivity`에서 `AuthSessionMonitor`를 주입받아 `PrezelAppState` 생성 시 전달하도록 변경했습니다. * `PrezelApp` UI 컴포넌트에서 `appState`를 통해 세션 이벤트를 구독하고, 세션 만료 시 로그인 화면 이동 및 이벤트 확인 로직을 수행하도록 리팩터링했습니다. * **build: 의존성 주입(Hilt) 설정 업데이트** * `AuthDataModule`에서 새로운 인터페이스인 `AuthSessionMonitor`와 그 구현체인 `DefaultAuthSessionMonitor`를 바인딩하도록 수정했습니다. --- .../src/main/java/com/team/prezel/MainActivity.kt | 6 +++--- .../src/main/java/com/team/prezel/ui/PrezelApp.kt | 9 ++++----- .../main/java/com/team/prezel/ui/PrezelAppState.kt | 5 +++++ ...sionEventBus.kt => DefaultAuthSessionMonitor.kt} | 12 ++++++------ .../com/team/prezel/core/data/di/AuthDataModule.kt | 8 ++++---- .../core/domain/session/AuthSessionEventBus.kt | 13 ------------- .../domain/session/AuthSessionEventPublisher.kt | 5 +++++ .../core/domain/session/AuthSessionMonitor.kt | 9 +++++++++ 8 files changed, 36 insertions(+), 31 deletions(-) rename Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/{DefaultAuthSessionEventBus.kt => DefaultAuthSessionMonitor.kt} (65%) delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt diff --git a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt index d09f6842..2f9192f8 100644 --- a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt +++ b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt @@ -9,7 +9,7 @@ import androidx.navigation3.runtime.NavKey import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.data.NetworkMonitor import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.domain.session.AuthSessionEventStream +import com.team.prezel.core.domain.session.AuthSessionMonitor import com.team.prezel.ui.PrezelApp import com.team.prezel.ui.rememberPrezelAppState import dagger.hilt.android.AndroidEntryPoint @@ -25,7 +25,7 @@ class MainActivity : ComponentActivity() { lateinit var authManager: AuthManager @Inject - lateinit var authSessionEventStream: AuthSessionEventStream + lateinit var authSessionMonitor: AuthSessionMonitor @Inject lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit> @@ -38,12 +38,12 @@ class MainActivity : ComponentActivity() { PrezelTheme { val appState = rememberPrezelAppState( networkMonitor = networkMonitor, + authSessionMonitor = authSessionMonitor, ) PrezelApp( appState = appState, entryBuilders = entryBuilders.toImmutableSet(), - authSessionEventStream = authSessionEventStream, onSessionExpired = authManager::clearCurrentProvider, ) } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 77209dbf..5658df17 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -19,7 +19,6 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold import com.team.prezel.core.domain.session.AuthSessionEvent -import com.team.prezel.core.domain.session.AuthSessionEventStream import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope @@ -33,23 +32,23 @@ import kotlinx.collections.immutable.ImmutableSet fun PrezelApp( appState: PrezelAppState, entryBuilders: ImmutableSet.() -> Unit>, - authSessionEventStream: AuthSessionEventStream, onSessionExpired: () -> Unit, ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } val snackbarHostState = remember { SnackbarHostState() } + val authSessionMonitor = appState.authSessionMonitor CompositionLocalProvider( LocalNavigator provides navigator, LocalSnackbarHostState provides snackbarHostState, ) { - LaunchedEffect(authSessionEventStream, navigator) { - authSessionEventStream.events.collect { event -> + LaunchedEffect(authSessionMonitor, navigator) { + authSessionMonitor.sessionEvents.collect { event -> when (event) { AuthSessionEvent.Expired -> { onSessionExpired() navigator.replaceRoot(LoginNavKey) - authSessionEventStream.clearSessionExpiredEvent() + authSessionMonitor.acknowledgeSessionEvent() } } } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt index 312ed5f1..4129eca8 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.team.prezel.core.data.NetworkMonitor +import com.team.prezel.core.domain.session.AuthSessionMonitor import com.team.prezel.core.navigation.NavigationState import com.team.prezel.core.navigation.rememberNavigationState import com.team.prezel.feature.splash.api.SplashNavKey @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn @Composable fun rememberPrezelAppState( networkMonitor: NetworkMonitor, + authSessionMonitor: AuthSessionMonitor, coroutineScope: CoroutineScope = rememberCoroutineScope(), ): PrezelAppState { val navigationState = rememberNavigationState( @@ -30,11 +32,13 @@ fun rememberPrezelAppState( navigationState, coroutineScope, networkMonitor, + authSessionMonitor, ) { PrezelAppState( navigationState = navigationState, coroutineScope = coroutineScope, networkMonitor = networkMonitor, + authSessionMonitor = authSessionMonitor, ) } } @@ -42,6 +46,7 @@ fun rememberPrezelAppState( @Stable class PrezelAppState( val navigationState: NavigationState, + val authSessionMonitor: AuthSessionMonitor, coroutineScope: CoroutineScope, networkMonitor: NetworkMonitor, ) { diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionMonitor.kt similarity index 65% rename from Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt rename to Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionMonitor.kt index 59fa335b..76f50a9d 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionEventBus.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionMonitor.kt @@ -2,7 +2,7 @@ package com.team.prezel.core.data.auth import com.team.prezel.core.domain.session.AuthSessionEvent import com.team.prezel.core.domain.session.AuthSessionEventPublisher -import com.team.prezel.core.domain.session.AuthSessionEventStream +import com.team.prezel.core.domain.session.AuthSessionMonitor import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull @@ -10,17 +10,17 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class DefaultAuthSessionEventBus @Inject constructor() : - AuthSessionEventPublisher, - AuthSessionEventStream { +internal class DefaultAuthSessionMonitor @Inject constructor() : + AuthSessionMonitor, + AuthSessionEventPublisher { private val event = MutableStateFlow(null) - override val events: Flow = event.filterNotNull() + override val sessionEvents: Flow = event.filterNotNull() override fun notifySessionExpired() { event.value = AuthSessionEvent.Expired } - override fun clearSessionExpiredEvent() { + override fun acknowledgeSessionEvent() { event.value = null } } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt index 67edbb0c..22dc2a8b 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt @@ -2,9 +2,9 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.auth.DataAuthSessionExpiredNotifier import com.team.prezel.core.data.auth.DataAuthTokenStore -import com.team.prezel.core.data.auth.DefaultAuthSessionEventBus +import com.team.prezel.core.data.auth.DefaultAuthSessionMonitor import com.team.prezel.core.domain.session.AuthSessionEventPublisher -import com.team.prezel.core.domain.session.AuthSessionEventStream +import com.team.prezel.core.domain.session.AuthSessionMonitor import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier import com.team.prezel.core.network.auth.AuthTokenStore import dagger.Binds @@ -26,9 +26,9 @@ internal abstract class AuthDataModule { @Singleton @Binds - abstract fun bindAuthSessionEventPublisher(eventBus: DefaultAuthSessionEventBus): AuthSessionEventPublisher + abstract fun bindAuthSessionEventPublisher(monitor: DefaultAuthSessionMonitor): AuthSessionEventPublisher @Singleton @Binds - abstract fun bindAuthSessionEventStream(eventBus: DefaultAuthSessionEventBus): AuthSessionEventStream + abstract fun bindAuthSessionMonitor(monitor: DefaultAuthSessionMonitor): AuthSessionMonitor } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt deleted file mode 100644 index ad1cb184..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventBus.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.team.prezel.core.domain.session - -import kotlinx.coroutines.flow.Flow - -interface AuthSessionEventStream { - val events: Flow - - fun clearSessionExpiredEvent() -} - -interface AuthSessionEventPublisher { - fun notifySessionExpired() -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt new file mode 100644 index 00000000..2627333f --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.domain.session + +interface AuthSessionEventPublisher { + fun notifySessionExpired() +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt new file mode 100644 index 00000000..8dc47fbf --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.domain.session + +import kotlinx.coroutines.flow.Flow + +interface AuthSessionMonitor { + val sessionEvents: Flow + + fun acknowledgeSessionEvent() +} From 8afcac076961f46d1f473fc28f53c2b1c656d594 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 26 Apr 2026 21:37:46 +0900 Subject: [PATCH 44/63] =?UTF-8?q?test:=20AuthTokenRefresher=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: `AuthTokenRefresher` 클래스에 대한 단위 테스트 추가 Ktor `MockEngine`을 사용하여 토큰 재발급 및 세션 만료 로직의 시나리오별 동작을 검증하는 테스트 코드를 작성했습니다. * **토큰 재발급 성공**: 서버로부터 새 토큰을 받았을 때 `AuthTokenStore`에 저장하고 `BearerTokens`를 반환하는지 확인 * **토큰 재발급 실패(401 Unauthorized)**: Refresh Token 만료 시 로컬 토큰을 삭제하고 `AuthSessionExpiredNotifier`를 통해 세션 만료 알림이 수행되는지 확인 * `FakeAuthTokenStore`, `FakeAuthSessionExpiredNotifier` 등 테스트용 더블 구현 * build: 테스트를 위한 Ktor Mock 엔진 의존성 추가 * `libs.versions.toml`에 `ktor-client-mock` 라이브러리 추가 * `core:network` 모듈의 `testImplementation`에 해당 의존성 적용 --- Prezel/core/network/build.gradle.kts | 1 + .../network/auth/AuthTokenRefresherTest.kt | 174 ++++++++++++++++++ Prezel/gradle/libs.versions.toml | 1 + 3 files changed, 176 insertions(+) create mode 100644 Prezel/core/network/src/test/java/com/team/prezel/core/network/auth/AuthTokenRefresherTest.kt diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index 0487c135..cfe2b4e4 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { ksp(libs.ktorfit.ksp) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.ktor.client.mock) } androidComponents { diff --git a/Prezel/core/network/src/test/java/com/team/prezel/core/network/auth/AuthTokenRefresherTest.kt b/Prezel/core/network/src/test/java/com/team/prezel/core/network/auth/AuthTokenRefresherTest.kt new file mode 100644 index 00000000..04cb68f5 --- /dev/null +++ b/Prezel/core/network/src/test/java/com/team/prezel/core/network/auth/AuthTokenRefresherTest.kt @@ -0,0 +1,174 @@ +package com.team.prezel.core.network.auth + +import com.team.prezel.core.model.auth.AuthToken +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.get +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AuthTokenRefresherTest { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + @Test + fun `refresh token 만료 응답이면 토큰을 삭제하고 세션 만료를 알린다`() = + runTest { + val tokenStore = FakeAuthTokenStore() + val notifier = FakeAuthSessionExpiredNotifier() + val refresher = AuthTokenRefresher( + json = json, + authTokenStore = tokenStore, + authSessionExpiredNotifier = notifier, + ) + val client = createClient( + content = + """ + { + "status": 401, + "code": "T001", + "data": null, + "message": "Token is invalid." + } + """.trimIndent(), + status = HttpStatusCode.Unauthorized, + ) + + val result = refresher.refreshBearerTokens( + params = client.refreshTokensParams(), + ) + + assertNull(result) + assertEquals(1, tokenStore.clearCount) + assertNull(tokenStore.token) + assertEquals(1, notifier.notifyCount) + } + + @Test + fun `refresh 성공이면 새 토큰을 저장하고 BearerTokens를 반환한다`() = + runTest { + val tokenStore = FakeAuthTokenStore() + val notifier = FakeAuthSessionExpiredNotifier() + val refresher = AuthTokenRefresher( + json = json, + authTokenStore = tokenStore, + authSessionExpiredNotifier = notifier, + ) + val client = createClient( + content = + """ + { + "accessToken": "new-access-token", + "refreshToken": "new-refresh-token" + } + """.trimIndent(), + status = HttpStatusCode.OK, + ) + + val result = refresher.refreshBearerTokens( + params = client.refreshTokensParams(), + ) + + assertEquals("new-access-token", result?.accessToken) + assertEquals("new-refresh-token", result?.refreshToken) + assertEquals(AuthToken("new-access-token", "new-refresh-token"), tokenStore.token) + assertEquals(1, tokenStore.saveCount) + assertEquals(0, tokenStore.clearCount) + assertEquals(0, notifier.notifyCount) + } + + private fun createClient( + content: String, + status: HttpStatusCode, + ): HttpClient = + HttpClient( + MockEngine { request -> + if (request.url.encodedPath == "/protected") { + respond(content = "{}", status = HttpStatusCode.OK, headers = jsonHeaders) + } else { + respond(content = content, status = status, headers = jsonHeaders) + } + }, + ) { + expectSuccess = true + + install(ContentNegotiation) { + json(json) + } + + defaultRequest { + contentType(ContentType.Application.Json) + } + } + + private suspend fun HttpClient.refreshTokensParams(): RefreshTokensParams = + RefreshTokensParams( + client = this, + response = get("https://prezel.test/protected"), + oldTokens = BearerTokens( + accessToken = initialToken.accessToken, + refreshToken = initialToken.refreshToken, + ), + ) + + private class FakeAuthTokenStore( + var token: AuthToken? = initialToken, + private val clearResult: Result = Result.success(Unit), + ) : AuthTokenStore { + var saveCount = 0 + private set + var clearCount = 0 + private set + + override fun getToken(): Flow = flowOf(token) + + override suspend fun saveToken(token: AuthToken): Result { + saveCount += 1 + this.token = token + return Result.success(Unit) + } + + override suspend fun clear(): Result { + clearCount += 1 + clearResult.onSuccess { + token = null + } + return clearResult + } + } + + private class FakeAuthSessionExpiredNotifier : AuthSessionExpiredNotifier { + var notifyCount = 0 + private set + + override fun notifySessionExpired() { + notifyCount += 1 + } + } + + private companion object { + val initialToken = AuthToken( + accessToken = "old-access-token", + refreshToken = "old-refresh-token", + ) + val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + } +} diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index dc674654..57bf34d2 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -62,6 +62,7 @@ ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } ktorfit-ksp = { module = "de.jensklingenberg.ktorfit:ktorfit-ksp", version.ref = "ktorfit" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } From a59c8a5904105a2734a445c352d86f22cf7b9b45 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 27 Apr 2026 00:05:53 +0900 Subject: [PATCH 45/63] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: URL 기반에서 속성(Attribute) 기반 인증 정책으로 전환** * 특정 URL 패턴을 하드코딩하여 인증을 제외하던 `AuthPathPolicy`를 삭제했습니다. * `AuthRequestAttributes`를 추가하여 Ktor `AttributeKey`를 통해 요청별로 인증 제외 여부(`SkipAuthKey`)를 설정할 수 있도록 개선했습니다. * `AuthService`의 `login` API에 `@Tag`를 사용하여 인증 제외 속성을 적용했습니다. * **refactor: 로그아웃 및 탈퇴 시 토큰 관리 로직 강화** * `AuthRepositoryImpl`: 로그아웃 요청 시 HTTP 에러나 네트워크 에러가 발생하더라도 로컬 토큰을 강제로 삭제하도록 변경했습니다. * `AuthManager`: 세션 만료 시 각 소셜 로그인 제공자(SDK)의 로그아웃 로직을 일괄 처리하는 `clearAuthSession` 메서드를 추가했습니다. * `MyViewModel`: 로그아웃 및 회원 탈퇴 성공 시 로컬 세션 정리 로직을 `onSuccess` 콜백으로 분리하여 구조를 개선했습니다. * **refactor: 토큰 재발급(Refresh) 및 예외 처리 개선** * `AuthTokenRefresher`: 재발급 요청 시 `SkipAuthKey` 속성을 명시적으로 추가하여 무한 루프를 방지했습니다. * `ResponseException` 분석 시 취소 예외(`CancellationException`)에 대한 전파 로직을 정교화했습니다. * `AuthRemoteDataSourceImpl`: 로그인 실패 로그 메시지를 한글로 변경하고 명확하게 개선했습니다. * **test: 네트워크 테스트 코드 구조 정리** * `AuthTokenRefresherTest` 파일 위치를 `com.team.prezel.core.network` 패키지로 이동하고 불필요한 `.gitkeep` 파일을 삭제했습니다. * **ui: 세션 만료 처리 로직 개선** * `MainActivity` 및 `PrezelApp`: 세션 만료 이벤트 수신 시 `AuthManager.clearAuthSession`을 비동기로 호출하여 외부 SDK 세션까지 안전하게 정리하도록 수정했습니다. --- .../main/java/com/team/prezel/MainActivity.kt | 2 +- .../main/java/com/team/prezel/ui/PrezelApp.kt | 11 ++- .../com/team/prezel/core/auth/AuthManager.kt | 76 +++++++++++-------- .../data/repository/AuthRepositoryImpl.kt | 29 ++++--- .../core/network/auth/AuthPathPolicy.kt | 8 -- .../network/auth/AuthRequestAttributes.kt | 9 +++ .../core/network/auth/AuthTokenRefresher.kt | 17 ++--- .../datasource/AuthRemoteDataSourceImpl.kt | 11 ++- .../prezel/core/network/di/NetworkModule.kt | 7 +- .../core/network/service/AuthService.kt | 3 + .../com/team/prezel/core/network/.gitkeep | 0 .../{auth => }/AuthTokenRefresherTest.kt | 5 +- .../prezel/feature/my/impl/MyViewModel.kt | 20 ++--- 13 files changed, 118 insertions(+), 80 deletions(-) delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthRequestAttributes.kt delete mode 100644 Prezel/core/network/src/test/java/com/team/prezel/core/network/.gitkeep rename Prezel/core/network/src/test/java/com/team/prezel/core/network/{auth => }/AuthTokenRefresherTest.kt (96%) diff --git a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt index 2f9192f8..86d53a65 100644 --- a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt +++ b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt @@ -44,7 +44,7 @@ class MainActivity : ComponentActivity() { PrezelApp( appState = appState, entryBuilders = entryBuilders.toImmutableSet(), - onSessionExpired = authManager::clearCurrentProvider, + onSessionExpired = authManager::clearAuthSession, ) } } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 5658df17..96e027e9 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -27,12 +27,13 @@ import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.navigation.MAIN_NAV_ITEMS import kotlinx.collections.immutable.ImmutableSet +import timber.log.Timber @Composable fun PrezelApp( appState: PrezelAppState, entryBuilders: ImmutableSet.() -> Unit>, - onSessionExpired: () -> Unit, + onSessionExpired: suspend () -> Unit, ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } val snackbarHostState = remember { SnackbarHostState() } @@ -46,9 +47,13 @@ fun PrezelApp( authSessionMonitor.sessionEvents.collect { event -> when (event) { AuthSessionEvent.Expired -> { - onSessionExpired() - navigator.replaceRoot(LoginNavKey) authSessionMonitor.acknowledgeSessionEvent() + runCatching { + onSessionExpired() + }.onFailure { throwable -> + Timber.w(throwable, "세션 만료 후 인증 세션 정리에 실패했습니다.") + } + navigator.replaceRoot(LoginNavKey) } } } diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt index 1d8ef643..cbcc4ddb 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt @@ -7,44 +7,60 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AuthManager - @Inject - constructor( - private val authClients: Map, - ) { - var currentProvider: AuthProvider? = null - private set - - suspend fun login( - context: Context, - provider: AuthProvider, - ): AuthResult { - val authClient = authClients[provider] ?: return AuthResult.Failure.Unknown - val result = authClient.login(context = context) - - if (result is AuthResult.Success) { - currentProvider = provider - } +class AuthManager @Inject constructor( + private val authClients: Map, +) { + var currentProvider: AuthProvider? = null + private set + + suspend fun login( + context: Context, + provider: AuthProvider, + ): AuthResult { + val authClient = authClients[provider] ?: return AuthResult.Failure.Unknown + val result = authClient.login(context = context) - return result + if (result is AuthResult.Success) { + currentProvider = provider } - suspend fun logout(): Result { - val provider = - currentProvider ?: authClients.keys.singleOrNull() ?: return Result.failure( - IllegalStateException("로그인된 AuthProvider가 없습니다."), - ) + return result + } - val authClient = authClients[provider] ?: return Result.failure( - IllegalStateException("해당 AuthProvider에 대한 AuthClient를 찾을 수 없습니다. provider=$provider"), + suspend fun logout(): Result { + val provider = + currentProvider ?: authClients.keys.singleOrNull() ?: return Result.failure( + IllegalStateException("로그인된 AuthProvider가 없습니다."), ) - return authClient.logout().onSuccess { - currentProvider = null - } + val authClient = authClients[provider] ?: return Result.failure( + IllegalStateException("해당 AuthProvider에 대한 AuthClient를 찾을 수 없습니다. provider=$provider"), + ) + + return authClient.logout().onSuccess { + currentProvider = null } + } - fun clearCurrentProvider() { + fun clearCurrentProvider() { + currentProvider = null + } + + suspend fun clearAuthSession(): Result { + val provider = currentProvider ?: return Result.success(Unit) + val authClient = authClients[provider] + + if (authClient == null) { currentProvider = null + return Result.failure( + IllegalStateException("해당 AuthProvider에 대한 AuthClient를 찾을 수 없습니다. provider=$provider"), + ) } + + return authClient + .logout() + .also { + currentProvider = null + } } +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index a4f6855f..1d2556f7 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -22,20 +22,31 @@ internal class AuthRepositoryImpl @Inject constructor( } override suspend fun logout(): AuthActionResult { - if (authLocalDataSource - .getToken() - .first() - ?.accessToken - .isNullOrBlank() - ) { + val accessToken = authLocalDataSource + .getToken() + .first() + ?.accessToken + + if (accessToken.isNullOrBlank()) { return clearTokensAndAuthenticationRequired() } return when (val response = authRemoteDataSource.logout()) { - is ApiResponse.Success -> clearTokens().toAuthActionSuccessResult() + is ApiResponse.Success -> { + clearTokens().toAuthActionSuccessResult() + } - is ApiResponse.Failure.HttpError -> response.toAuthActionResult() - is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) + is ApiResponse.Failure.HttpError -> { + clearTokens() + + response.toAuthActionResult() + } + + is ApiResponse.Failure.NetworkError -> { + clearTokens() + + AuthActionResult.Failure(response.throwable) + } } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt deleted file mode 100644 index fc7901a1..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthPathPolicy.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.team.prezel.core.network.auth - -internal object AuthPathPolicy { - private const val LOGIN_PATH = "/auth/login" - private const val REISSUE_PATH = "/auth/reissue" - - fun requiresAuthorization(encodedPath: String): Boolean = !encodedPath.endsWith(LOGIN_PATH) && !encodedPath.endsWith(REISSUE_PATH) -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthRequestAttributes.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthRequestAttributes.kt new file mode 100644 index 00000000..b6cf8cfa --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthRequestAttributes.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.network.auth + +import io.ktor.util.AttributeKey + +internal object AuthRequestAttributes { + const val SKIP_AUTH = "skipAuth" + + val SkipAuthKey = AttributeKey(SKIP_AUTH) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 62013cec..044fef8b 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -68,6 +68,7 @@ class AuthTokenRefresher @Inject constructor( val response = client .post("${BuildConfig.BASE_URL}auth/reissue") { markAsRefreshTokenRequest() + attributes.put(AuthRequestAttributes.SkipAuthKey, true) setBody(ReissueTokenRequest(refreshToken = refreshToken)) }.body() @@ -107,21 +108,19 @@ class AuthTokenRefresher @Inject constructor( private suspend fun Throwable.isSessionRecoveryUnrecoverable(): Boolean { if (this !is ResponseException) return false - val error = parseErrorResponse() - return error?.code == TOKEN_INVALID_CODE || - error?.code == AUTHENTICATION_REQUIRED_CODE || - error?.code == USER_NOT_FOUND_CODE - } - - private suspend fun ResponseException.parseErrorResponse(): ApiErrorResponse? = - try { + val error = try { json.decodeFromString(response.bodyAsText()) } catch (t: Throwable) { t.rethrowIfCancellation() null } - private fun Throwable.rethrowIfCancellation() { + return error?.code == TOKEN_INVALID_CODE || + error?.code == AUTHENTICATION_REQUIRED_CODE || + error?.code == USER_NOT_FOUND_CODE + } + + fun Throwable.rethrowIfCancellation() { if (this is CancellationException) throw this } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index d8114e22..b30ff386 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -15,10 +15,9 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( override suspend fun logout(): ApiResponse = authService.logout() override suspend fun login(idToken: String): ApiResponse = - LoginRequest(idToken = idToken) - .let { request -> - authService.login(request = request) - }.also(::logTokenResponse) + authService + .login(request = LoginRequest(idToken = idToken)) + .also(::logTokenResponse) override suspend fun withdraw( reasonCategory: String, @@ -38,11 +37,11 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( is ApiResponse.Success -> Timber.tag("AuthToken").d("서버 인증 응답에 성공했습니다.") is ApiResponse.Failure.HttpError -> { - Timber.tag("AuthToken").e(response.throwable, "Server login failed: http error") + Timber.tag("AuthToken").e(response.throwable, "서버 로그인에 실패했습니다: http error") } is ApiResponse.Failure.NetworkError -> { - Timber.tag("AuthToken").e(response.throwable, "Server login failed: network error") + Timber.tag("AuthToken").e(response.throwable, "서버 로그인에 실패했습니다: network error") } } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 3f6d099d..1c7c2f01 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -3,7 +3,7 @@ package com.team.prezel.core.network.di import android.os.Build import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.network.auth.AuthPathPolicy +import com.team.prezel.core.network.auth.AuthRequestAttributes import com.team.prezel.core.network.auth.AuthTokenRefresher import com.team.prezel.core.network.auth.AuthTokenStore import com.team.prezel.core.network.service.AuthService @@ -28,7 +28,6 @@ import io.ktor.client.plugins.logging.Logging import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.contentType -import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json @@ -86,11 +85,11 @@ object NetworkModule { } refreshTokens { - authTokenRefresher.refreshBearerTokens(this) ?: return@refreshTokens null + authTokenRefresher.refreshBearerTokens(this) } sendWithoutRequest { request -> - AuthPathPolicy.requiresAuthorization(request.url.encodedPath) + request.attributes.getOrNull(AuthRequestAttributes.SkipAuthKey) != true } } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt index 2ace3d51..c787633f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -1,5 +1,6 @@ package com.team.prezel.core.network.service +import com.team.prezel.core.network.auth.AuthRequestAttributes import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginRequest import com.team.prezel.core.network.model.auth.LoginResponse @@ -7,6 +8,7 @@ import com.team.prezel.core.network.model.auth.WithdrawRequest import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Tag internal interface AuthService { @POST("auth/logout") @@ -15,6 +17,7 @@ internal interface AuthService { @POST("auth/login") suspend fun login( @Body request: LoginRequest, + @Tag(AuthRequestAttributes.SKIP_AUTH) skipAuth: Boolean = true, ): ApiResponse @DELETE("auth/withdraw") diff --git a/Prezel/core/network/src/test/java/com/team/prezel/core/network/.gitkeep b/Prezel/core/network/src/test/java/com/team/prezel/core/network/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Prezel/core/network/src/test/java/com/team/prezel/core/network/auth/AuthTokenRefresherTest.kt b/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt similarity index 96% rename from Prezel/core/network/src/test/java/com/team/prezel/core/network/auth/AuthTokenRefresherTest.kt rename to Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt index 04cb68f5..7666a69f 100644 --- a/Prezel/core/network/src/test/java/com/team/prezel/core/network/auth/AuthTokenRefresherTest.kt +++ b/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt @@ -1,6 +1,9 @@ -package com.team.prezel.core.network.auth +package com.team.prezel.core.network import com.team.prezel.core.model.auth.AuthToken +import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier +import com.team.prezel.core.network.auth.AuthTokenRefresher +import com.team.prezel.core.network.auth.AuthTokenStore import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt index 72d09491..73b8d3ce 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -41,6 +41,13 @@ internal class MyViewModel @Inject constructor( val result = logoutUseCase() handleAuthActionResult( result = result, + onSuccess = { + authManager + .logout() + .onFailure { throwable -> + Timber.w(throwable, "로컬 인증 세션 정리에 실패했습니다.") + } + }, failureLog = "로그아웃에 실패했습니다.", failureMessage = MyUiMessage.LOGOUT_FAILED, ) @@ -56,12 +63,10 @@ internal class MyViewModel @Inject constructor( viewModelScope.launch { try { _uiState.update { it.copy(isLoading = true) } - val result = - withdrawUseCase( - reason = WithdrawReason.Other("임시 테스트 탈퇴"), - ) + val result = withdrawUseCase(reason = WithdrawReason.Other("임시 테스트 탈퇴")) handleAuthActionResult( result = result, + onSuccess = { authManager.clearCurrentProvider() }, failureLog = "회원탈퇴에 실패했습니다.", failureMessage = MyUiMessage.WITHDRAW_FAILED, ) @@ -73,16 +78,13 @@ internal class MyViewModel @Inject constructor( private suspend fun handleAuthActionResult( result: AuthActionResult, + onSuccess: suspend () -> Unit, failureLog: String, failureMessage: MyUiMessage, ) { when (result) { AuthActionResult.Success -> { - authManager - .logout() - .onFailure { throwable -> - Timber.w(throwable, "로컬 인증 세션 정리에 실패했습니다.") - } + onSuccess() _uiEffect.emit(MyUiEffect.NavigateToLogin) } From 9e577c41d2917f72e48365d75f93fe2e4d62fe91 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 27 Apr 2026 00:18:00 +0900 Subject: [PATCH 46/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AuthLocalDataSource` 내 메모리 캐시 계층 도입** * `DataStore` 접근 횟수를 줄이기 위해 `MutableStateFlow`와 `Mutex`를 이용한 토큰 캐싱 로직을 추가했습니다. * 초기 1회 로드 이후에는 캐시된 데이터를 사용하며, 저장/삭제 시 캐시가 동기화되도록 개선했습니다. * **refactor: `AuthTokenRefresher` 중복 재발급 방지 로직 개선** * 토큰 재발급 요청 전, 현재 저장된 토큰이 이미 갱신되었는지 확인하는 로직을 추가했습니다. * 요청 보낸 토큰과 저장된 토큰이 다를 경우, 추가 API 호출 없이 즉시 최신 토큰을 반환하도록 수정했습니다. * 관련 시나리오에 대한 단위 테스트(`AuthTokenRefresherTest`)를 추가했습니다. * **refactor: `AuthRepositoryImpl` 로그인 실패 처리 강화** * `login` 성공 후 토큰 저장 과정에서 실패(`onFailure`)할 경우, 무결성을 위해 `clearTokens()`를 호출하도록 예외 처리를 추가했습니다. * **style: `AuthRepositoryImpl` 응답 처리 가독성 개선** * `login` 메서드 내 `when` 절의 각 브랜치를 블록 형식으로 변경하여 가독성을 높였습니다. --- .../data/repository/AuthRepositoryImpl.kt | 14 ++++-- .../datastore/auth/AuthLocalDataSourceImpl.kt | 49 ++++++++++++++++--- .../core/network/auth/AuthTokenRefresher.kt | 21 +++++++- .../core/network/AuthTokenRefresherTest.kt | 35 +++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 1d2556f7..6a354657 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -52,9 +52,17 @@ internal class AuthRepositoryImpl @Inject constructor( override suspend fun login(idToken: String): Result = when (val response = authRemoteDataSource.login(idToken = idToken)) { - is ApiResponse.Success -> saveTokens(response.data) - is ApiResponse.Failure.HttpError -> Result.failure(response.throwable) - is ApiResponse.Failure.NetworkError -> Result.failure(response.throwable) + is ApiResponse.Success -> { + saveTokens(response.data).onFailure { clearTokens() } + } + + is ApiResponse.Failure.HttpError -> { + Result.failure(response.throwable) + } + + is ApiResponse.Failure.NetworkError -> { + Result.failure(response.throwable) + } } override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt index bf062218..521268b8 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -7,8 +7,14 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import com.team.prezel.core.model.auth.AuthToken import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.IOException @@ -24,20 +30,25 @@ internal class AuthLocalDataSourceImpl @Inject constructor( ignoreUnknownKeys = true encodeDefaults = true } + private val cacheMutex = Mutex() + private val cachedToken = MutableStateFlow(null) + + @Volatile + private var isCacheInitialized = false override fun getToken(): Flow = - dataStore.data - .catch { exception -> - if (exception is IOException) emit(emptyPreferences()) else throw exception - }.map { preferences -> - preferences.toAuthToken() - } + flow { + ensureCacheInitialized() + emitAll(cachedToken) + } override suspend fun saveToken(token: AuthToken): Result = runSuspendCatching { dataStore.edit { preferences -> preferences[KEY_AUTH_TOKEN] = json.encodeToString(token) } + }.onSuccess { + updateCache(token) } override suspend fun clear(): Result = @@ -45,8 +56,34 @@ internal class AuthLocalDataSourceImpl @Inject constructor( dataStore.edit { preferences -> preferences.remove(KEY_AUTH_TOKEN) } + }.onSuccess { + updateCache(null) } + private suspend fun ensureCacheInitialized() { + if (isCacheInitialized) return + + cacheMutex.withLock { + if (isCacheInitialized) return + + cachedToken.value = readTokenFromDataStore() + isCacheInitialized = true + } + } + + private suspend fun readTokenFromDataStore(): AuthToken? = + dataStore.data + .catch { exception -> + if (exception is IOException) emit(emptyPreferences()) else throw exception + }.map { preferences -> + preferences.toAuthToken() + }.first() + + private fun updateCache(token: AuthToken?) { + cachedToken.value = token + isCacheInitialized = true + } + private suspend inline fun runSuspendCatching(crossinline block: suspend () -> Unit): Result = try { block() diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt index 044fef8b..ccb75d44 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt @@ -33,8 +33,19 @@ class AuthTokenRefresher @Inject constructor( suspend fun refreshBearerTokens(params: RefreshTokensParams): BearerTokens? = mutex.withLock { - val refreshToken = params.oldTokens?.refreshToken - ?: authTokenStore.getToken().first()?.refreshToken + val storedToken = authTokenStore.getToken().first() + val oldRefreshToken = params.oldTokens?.refreshToken + + if ( + oldRefreshToken != null && + storedToken != null && + storedToken.refreshToken != oldRefreshToken + ) { + return@withLock storedToken.toBearerTokens() + } + + val refreshToken = oldRefreshToken + ?: storedToken?.refreshToken ?: return@withLock null when ( @@ -59,6 +70,12 @@ class AuthTokenRefresher @Inject constructor( } } + private fun AuthToken.toBearerTokens(): BearerTokens = + BearerTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + private suspend fun requestTokenReissue( client: HttpClient, refreshToken: String, diff --git a/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt b/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt index 7666a69f..1a821d23 100644 --- a/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt +++ b/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.fail class AuthTokenRefresherTest { private val json = Json { @@ -98,14 +99,48 @@ class AuthTokenRefresherTest { assertEquals(0, notifier.notifyCount) } + @Test + fun `저장소 토큰이 이미 갱신됐으면 재발급 요청 없이 최신 BearerTokens를 반환한다`() = + runTest { + val updatedToken = AuthToken( + accessToken = "updated-access-token", + refreshToken = "updated-refresh-token", + ) + val tokenStore = FakeAuthTokenStore(token = updatedToken) + val notifier = FakeAuthSessionExpiredNotifier() + val refresher = AuthTokenRefresher( + json = json, + authTokenStore = tokenStore, + authSessionExpiredNotifier = notifier, + ) + val client = createClient( + content = "", + status = HttpStatusCode.OK, + failOnReissue = true, + ) + + val result = refresher.refreshBearerTokens( + params = client.refreshTokensParams(), + ) + + assertEquals("updated-access-token", result?.accessToken) + assertEquals("updated-refresh-token", result?.refreshToken) + assertEquals(0, tokenStore.saveCount) + assertEquals(0, tokenStore.clearCount) + assertEquals(0, notifier.notifyCount) + } + private fun createClient( content: String, status: HttpStatusCode, + failOnReissue: Boolean = false, ): HttpClient = HttpClient( MockEngine { request -> if (request.url.encodedPath == "/protected") { respond(content = "{}", status = HttpStatusCode.OK, headers = jsonHeaders) + } else if (failOnReissue) { + fail("재발급 요청이 호출되지 않아야 합니다.") } else { respond(content = content, status = status, headers = jsonHeaders) } From c5cae2745882562bce4416438d32bce6d6e32b2a Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 02:40:11 +0900 Subject: [PATCH 47/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Prezel/app/build.gradle.kts | 1 + .../java/com/team/prezel/GlobalEventModule.kt | 19 ++ Prezel/core/common/build.gradle.kts | 8 + .../common/event/DefaultGlobalEventBus.kt | 22 ++ .../prezel/core/common/event/GlobalEvent.kt | 5 + .../core/common/event/GlobalEventBus.kt | 9 + .../team/prezel/core/data/ApiResponseExt.kt | 10 - .../core/data/auth/DefaultTokenProvider.kt | 24 ++ .../team/prezel/core/data/di/AuthModule.kt | 17 ++ .../prezel/core/data/di/RepositoryModule.kt | 4 +- .../data/repository/AuthRepositoryImpl.kt | 146 ------------ .../data/repository/DefaultAuthRepository.kt | 69 ++++++ .../datastore/auth/AuthLocalDataSource.kt | 11 +- .../datastore/auth/AuthLocalDataSourceImpl.kt | 110 +++------ .../core/datastore/di/DataStoreModule.kt | 5 +- .../domain/repository/auth/AuthRepository.kt | 10 +- .../domain/result/auth/AuthActionResult.kt | 11 - .../domain/result/auth/LoginStatusResult.kt | 11 - .../usecase/auth/CheckLoginStatusUseCase.kt | 11 +- .../core/domain/usecase/auth/LogoutUseCase.kt | 5 +- .../domain/usecase/auth/WithdrawUseCase.kt | 5 +- Prezel/core/model/build.gradle.kts | 2 - .../auth/{AuthToken.kt => AuthTokens.kt} | 5 +- .../prezel/core/model/auth/LoginStatus.kt | 7 + .../prezel/core/model/base/ApiException.kt | 7 + .../prezel/core/model/base/ServerErrorCode.kt | 32 +++ Prezel/core/network/build.gradle.kts | 1 + .../network/ApiResponseConverterFactory.kt | 93 -------- .../auth/AuthSessionExpiredNotifier.kt | 5 - .../network/auth/AuthTokenRefreshResult.kt | 21 -- .../core/network/auth/AuthTokenRefresher.kt | 149 ------------ .../core/network/auth/AuthTokenStore.kt | 12 - .../prezel/core/network/auth/TokenProvider.kt | 12 + .../datasource/AuthRemoteDataSource.kt | 7 +- .../datasource/AuthRemoteDataSourceImpl.kt | 38 +--- .../prezel/core/network/di/NetworkModule.kt | 129 +++++------ .../core/network/model/ApiErrorResponse.kt | 12 - .../prezel/core/network/model/ApiResponse.kt | 18 -- .../prezel/core/network/model/BaseResponse.kt | 30 +++ .../ReissueRequest.kt} | 5 +- .../model/auth/reissue/ReissueResponse.kt | 12 + .../core/network/service/AuthService.kt | 16 +- .../core/network/AuthTokenRefresherTest.kt | 212 ------------------ Prezel/feature/splash/impl/build.gradle.kts | 1 + .../feature/splash/impl/SplashViewModel.kt | 15 +- Prezel/gradle/libs.versions.toml | 1 - Prezel/settings.gradle.kts | 3 +- 47 files changed, 428 insertions(+), 930 deletions(-) create mode 100644 Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt create mode 100644 Prezel/core/common/build.gradle.kts create mode 100644 Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/DefaultGlobalEventBus.kt create mode 100644 Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt create mode 100644 Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultTokenProvider.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt create mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/AuthActionResult.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt rename Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/{AuthToken.kt => AuthTokens.kt} (56%) create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ApiException.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt delete mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt rename Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/{ReissueTokenRequest.kt => reissue/ReissueRequest.kt} (55%) create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueResponse.kt delete mode 100644 Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index e134d27c..a4f876e6 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.coreDomain) implementation(projects.coreNavigation) implementation(projects.coreUi) + implementation(projects.coreCommon) implementation(projects.featureSplashApi) implementation(projects.featureSplashImpl) diff --git a/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt b/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt new file mode 100644 index 00000000..a743484c --- /dev/null +++ b/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt @@ -0,0 +1,19 @@ +package com.team.prezel + +import com.team.prezel.core.common.event.DefaultGlobalEventBus +import com.team.prezel.core.common.event.GlobalEventBus +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class GlobalEventModule { + @Binds + @Singleton + abstract fun bindsGlobalEventBus( + globalEventBus: DefaultGlobalEventBus, + ): GlobalEventBus +} diff --git a/Prezel/core/common/build.gradle.kts b/Prezel/core/common/build.gradle.kts new file mode 100644 index 00000000..6f626e78 --- /dev/null +++ b/Prezel/core/common/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.prezel.jvm.library) +} + +dependencies { + implementation(libs.javax.inject) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/DefaultGlobalEventBus.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/DefaultGlobalEventBus.kt new file mode 100644 index 00000000..791334ad --- /dev/null +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/DefaultGlobalEventBus.kt @@ -0,0 +1,22 @@ +package com.team.prezel.core.common.event + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DefaultGlobalEventBus @Inject constructor() : GlobalEventBus { + private val _events = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val events: Flow = _events.asSharedFlow() + + override suspend fun emit(event: GlobalEvent) { + _events.emit(event) + } +} diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt new file mode 100644 index 00000000..65d4894a --- /dev/null +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.common.event + +sealed interface GlobalEvent { + data object ForceLogout : GlobalEvent +} diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt new file mode 100644 index 00000000..a92e62cd --- /dev/null +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.common.event + +import kotlinx.coroutines.flow.Flow + +interface GlobalEventBus { + val events: Flow + + suspend fun emit(event: GlobalEvent) +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt deleted file mode 100644 index 94f74038..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/ApiResponseExt.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.team.prezel.core.data - -import com.team.prezel.core.network.model.ApiResponse - -internal suspend inline fun ApiResponse.toResult(crossinline transform: suspend (T) -> R): Result = - when (this) { - is ApiResponse.Success -> Result.success(transform(data)) - is ApiResponse.Failure.HttpError -> Result.failure(throwable) - is ApiResponse.Failure.NetworkError -> Result.failure(throwable) - } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultTokenProvider.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultTokenProvider.kt new file mode 100644 index 00000000..4fdf8f5f --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultTokenProvider.kt @@ -0,0 +1,24 @@ +package com.team.prezel.core.data.auth + +import com.team.prezel.core.datastore.auth.AuthLocalDataSource +import com.team.prezel.core.model.auth.AuthTokens +import com.team.prezel.core.network.auth.TokenProvider +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject + +internal class DefaultTokenProvider @Inject constructor( + private val dataSource: AuthLocalDataSource, +) : TokenProvider { + override suspend fun getTokens(): AuthTokens? = dataSource.tokens.firstOrNull() + + override suspend fun updateTokens( + accessToken: String, + refreshToken: String, + ) { + dataSource.saveTokens(accessToken = accessToken, refreshToken = refreshToken) + } + + override suspend fun clearTokens() { + dataSource.clearTokens() + } +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt new file mode 100644 index 00000000..73a01005 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.data.di + +import com.team.prezel.core.data.auth.DefaultTokenProvider +import com.team.prezel.core.network.auth.TokenProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AuthModule { + @Binds + @Singleton + abstract fun bindsTokenProvider(tokenProvider: DefaultTokenProvider): TokenProvider +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index b027abf2..9baee783 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,6 +1,6 @@ package com.team.prezel.core.data.di -import com.team.prezel.core.data.repository.AuthRepositoryImpl +import com.team.prezel.core.data.repository.DefaultAuthRepository import com.team.prezel.core.data.repository.UserRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.repository.profile.UserRepository @@ -15,7 +15,7 @@ import javax.inject.Singleton internal abstract class RepositoryModule { @Binds @Singleton - abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + abstract fun bindAuthRepository(impl: DefaultAuthRepository): AuthRepository @Binds @Singleton diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt deleted file mode 100644 index 6a354657..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.team.prezel.core.data.repository - -import com.team.prezel.core.datastore.auth.AuthLocalDataSource -import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.domain.result.auth.AuthActionResult -import com.team.prezel.core.domain.result.auth.LoginStatusResult -import com.team.prezel.core.model.auth.AuthToken -import com.team.prezel.core.model.auth.WithdrawReason -import com.team.prezel.core.network.datasource.AuthRemoteDataSource -import com.team.prezel.core.network.model.ApiResponse -import com.team.prezel.core.network.model.auth.LoginResponse -import kotlinx.coroutines.flow.first -import javax.inject.Inject - -internal class AuthRepositoryImpl @Inject constructor( - private val authRemoteDataSource: AuthRemoteDataSource, - private val authLocalDataSource: AuthLocalDataSource, -) : AuthRepository { - override suspend fun checkLoginStatus(): LoginStatusResult { - val token = authLocalDataSource.getToken().first() - return if (token == null) LoginStatusResult.Unauthenticated else LoginStatusResult.Authenticated - } - - override suspend fun logout(): AuthActionResult { - val accessToken = authLocalDataSource - .getToken() - .first() - ?.accessToken - - if (accessToken.isNullOrBlank()) { - return clearTokensAndAuthenticationRequired() - } - - return when (val response = authRemoteDataSource.logout()) { - is ApiResponse.Success -> { - clearTokens().toAuthActionSuccessResult() - } - - is ApiResponse.Failure.HttpError -> { - clearTokens() - - response.toAuthActionResult() - } - - is ApiResponse.Failure.NetworkError -> { - clearTokens() - - AuthActionResult.Failure(response.throwable) - } - } - } - - override suspend fun login(idToken: String): Result = - when (val response = authRemoteDataSource.login(idToken = idToken)) { - is ApiResponse.Success -> { - saveTokens(response.data).onFailure { clearTokens() } - } - - is ApiResponse.Failure.HttpError -> { - Result.failure(response.throwable) - } - - is ApiResponse.Failure.NetworkError -> { - Result.failure(response.throwable) - } - } - - override suspend fun withdraw(reason: WithdrawReason): AuthActionResult { - if (authLocalDataSource - .getToken() - .first() - ?.accessToken - .isNullOrBlank() - ) { - return clearTokensAndAuthenticationRequired() - } - - return when ( - val response = - authRemoteDataSource.withdraw( - reasonCategory = reason.toCategory(), - reasonText = reason.toReasonText(), - ) - ) { - is ApiResponse.Success -> clearTokens().toAuthActionSuccessResult() - - is ApiResponse.Failure.HttpError -> response.toAuthActionResult() - is ApiResponse.Failure.NetworkError -> AuthActionResult.Failure(response.throwable) - } - } - - private suspend fun saveTokens(response: LoginResponse): Result { - val token = response.toAuthToken() - return authLocalDataSource.saveToken(token) - } - - private fun LoginResponse.toAuthToken(): AuthToken = - AuthToken( - accessToken = accessToken, - refreshToken = refreshToken, - ) - - private suspend fun ApiResponse.Failure.HttpError.toAuthActionResult(): AuthActionResult = - if (error?.code == AUTHENTICATION_REQUIRED_CODE) { - clearTokens().fold( - onSuccess = { AuthActionResult.AuthenticationRequired }, - onFailure = { throwable -> AuthActionResult.Failure(throwable) }, - ) - } else { - AuthActionResult.Failure(throwable) - } - - private suspend fun clearTokensAndAuthenticationRequired(): AuthActionResult = - clearTokens().fold( - onSuccess = { AuthActionResult.AuthenticationRequired }, - onFailure = { throwable -> AuthActionResult.Failure(throwable) }, - ) - - private suspend fun clearTokens(): Result = authLocalDataSource.clear() - - private fun Result.toAuthActionSuccessResult(): AuthActionResult = - fold( - onSuccess = { AuthActionResult.Success }, - onFailure = { throwable -> AuthActionResult.Failure(throwable) }, - ) - - private fun WithdrawReason.toCategory(): String = - when (this) { - WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" - WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" - WithdrawReason.TooDifficultOrComplex -> "TOO_COMPLEX" - WithdrawReason.AnalysisResultInaccurate -> "INACCURATE_ANALYSIS" - WithdrawReason.TooManyErrors -> "MANY_ERRORS" - is WithdrawReason.Other -> "ETC" - } - - private fun WithdrawReason.toReasonText(): String = - when (this) { - is WithdrawReason.Other -> text - else -> "" - } - - private companion object { - const val AUTHENTICATION_REQUIRED_CODE = "U001" - } -} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt new file mode 100644 index 00000000..6c3e93ad --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt @@ -0,0 +1,69 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.datastore.auth.AuthLocalDataSource +import com.team.prezel.core.datastore.di.ApplicationScope +import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.model.auth.LoginStatus +import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +internal class DefaultAuthRepository @Inject constructor( + private val authRemoteDataSource: AuthRemoteDataSource, + private val authLocalDataSource: AuthLocalDataSource, + @param:ApplicationScope private val externalScope: CoroutineScope, +) : AuthRepository { + override val loginStatus: StateFlow = + authLocalDataSource.tokens + .map { tokens -> if (tokens == null) LoginStatus.UNAUTHENTICATED else LoginStatus.AUTHENTICATED } + .stateIn( + scope = externalScope, + started = SharingStarted.Eagerly, + initialValue = LoginStatus.LOADING, + ) + + override suspend fun logout(): Result = + runCatching { + authRemoteDataSource.logout() + authLocalDataSource.clearTokens() + } + + override suspend fun login(idToken: String): Result = + runCatching { + val response = authRemoteDataSource.login(idToken = idToken) + authLocalDataSource.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + } + + override suspend fun withdraw(reason: WithdrawReason): Result = + runCatching { + authRemoteDataSource.withdraw( + reasonCategory = reason.toCategory(), + reasonText = reason.toReasonText(), + ) + authLocalDataSource.clearTokens() + } + + private fun WithdrawReason.toCategory(): String = + when (this) { + WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" + WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" + WithdrawReason.TooDifficultOrComplex -> "TOO_COMPLEX" + WithdrawReason.AnalysisResultInaccurate -> "INACCURATE_ANALYSIS" + WithdrawReason.TooManyErrors -> "MANY_ERRORS" + is WithdrawReason.Other -> "ETC" + } + + private fun WithdrawReason.toReasonText(): String = + when (this) { + is WithdrawReason.Other -> text + else -> "" + } +} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt index fd475220..73908469 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt @@ -1,15 +1,12 @@ package com.team.prezel.core.datastore.auth -import com.team.prezel.core.model.auth.AuthToken +import com.team.prezel.core.model.auth.AuthTokens import kotlinx.coroutines.flow.Flow -/** - * 인증 토큰을 로컬 저장소에서 읽고 쓰는 데이터 소스 계약입니다. - */ interface AuthLocalDataSource { - fun getToken(): Flow + val tokens: Flow - suspend fun saveToken(token: AuthToken): Result + suspend fun saveTokens(accessToken: String, refreshToken: String) - suspend fun clear(): Result + suspend fun clearTokens() } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt index 521268b8..d890fabb 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -5,102 +5,56 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey -import com.team.prezel.core.model.auth.AuthToken +import com.team.prezel.core.model.auth.AuthTokens import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.io.IOException import javax.inject.Inject import javax.inject.Singleton -import kotlin.coroutines.cancellation.CancellationException @Singleton internal class AuthLocalDataSourceImpl @Inject constructor( private val dataStore: DataStore, ) : AuthLocalDataSource { - private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - } - private val cacheMutex = Mutex() - private val cachedToken = MutableStateFlow(null) - - @Volatile - private var isCacheInitialized = false - - override fun getToken(): Flow = - flow { - ensureCacheInitialized() - emitAll(cachedToken) - } - - override suspend fun saveToken(token: AuthToken): Result = - runSuspendCatching { - dataStore.edit { preferences -> - preferences[KEY_AUTH_TOKEN] = json.encodeToString(token) - } - }.onSuccess { - updateCache(token) - } - - override suspend fun clear(): Result = - runSuspendCatching { - dataStore.edit { preferences -> - preferences.remove(KEY_AUTH_TOKEN) + override val tokens: Flow + get() = dataStore.data + .catch { throwable -> + if (throwable is IOException) { + emit(emptyPreferences()) + } else { + throw throwable + } + }.map { preferences -> + val accessToken = preferences[ACCESS_TOKEN_KEY] + val refreshToken = preferences[REFRESH_TOKEN_KEY] + + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + null + } else { + AuthTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + } } - }.onSuccess { - updateCache(null) - } - - private suspend fun ensureCacheInitialized() { - if (isCacheInitialized) return - - cacheMutex.withLock { - if (isCacheInitialized) return - cachedToken.value = readTokenFromDataStore() - isCacheInitialized = true + override suspend fun saveTokens(accessToken: String, refreshToken: String) { + dataStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = accessToken + preferences[REFRESH_TOKEN_KEY] = refreshToken } } - private suspend fun readTokenFromDataStore(): AuthToken? = - dataStore.data - .catch { exception -> - if (exception is IOException) emit(emptyPreferences()) else throw exception - }.map { preferences -> - preferences.toAuthToken() - }.first() - - private fun updateCache(token: AuthToken?) { - cachedToken.value = token - isCacheInitialized = true - } - - private suspend inline fun runSuspendCatching(crossinline block: suspend () -> Unit): Result = - try { - block() - Result.success(Unit) - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - Result.failure(t) + override suspend fun clearTokens() { + dataStore.edit { preferences -> + preferences.remove(ACCESS_TOKEN_KEY) + preferences.remove(REFRESH_TOKEN_KEY) } - - private fun Preferences.toAuthToken(): AuthToken? = - this[KEY_AUTH_TOKEN] - ?.let { tokenJson -> - runCatching { json.decodeFromString(tokenJson) }.getOrNull() - }?.takeIf { token -> token.accessToken.isNotBlank() && token.refreshToken.isNotBlank() } + } private companion object { - val KEY_AUTH_TOKEN = stringPreferencesKey("auth_token") + val ACCESS_TOKEN_KEY = stringPreferencesKey("auth_access_token") + val REFRESH_TOKEN_KEY = stringPreferencesKey("auth_refresh_token") } } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt index a5b89425..bdb76823 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,6 +17,8 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal object DataStoreModule { + private const val PREFERENCES_NAME = "auth_token_preferences" + @Provides @Singleton fun providePreferencesDataStore( @@ -26,6 +29,4 @@ internal object DataStoreModule { scope = scope, produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, ) - - private const val PREFERENCES_NAME = "auth_token_preferences" } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt index 6350cecb..64b8efdd 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -1,15 +1,15 @@ package com.team.prezel.core.domain.repository.auth -import com.team.prezel.core.domain.result.auth.AuthActionResult -import com.team.prezel.core.domain.result.auth.LoginStatusResult +import com.team.prezel.core.model.auth.LoginStatus import com.team.prezel.core.model.auth.WithdrawReason +import kotlinx.coroutines.flow.Flow interface AuthRepository { - suspend fun checkLoginStatus(): LoginStatusResult + val loginStatus: Flow - suspend fun logout(): AuthActionResult + suspend fun logout(): Result suspend fun login(idToken: String): Result - suspend fun withdraw(reason: WithdrawReason): AuthActionResult + suspend fun withdraw(reason: WithdrawReason): Result } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/AuthActionResult.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/AuthActionResult.kt deleted file mode 100644 index bf2fdb25..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/AuthActionResult.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.team.prezel.core.domain.result.auth - -sealed interface AuthActionResult { - data object Success : AuthActionResult - - data object AuthenticationRequired : AuthActionResult - - data class Failure( - val throwable: Throwable, - ) : AuthActionResult -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt deleted file mode 100644 index a06d2e01..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/result/auth/LoginStatusResult.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.team.prezel.core.domain.result.auth - -sealed interface LoginStatusResult { - data object Authenticated : LoginStatusResult - - data object Unauthenticated : LoginStatusResult - - data class RetryableFailure( - val throwable: Throwable, - ) : LoginStatusResult -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt index adb6a4fe..c68009a6 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -1,20 +1,21 @@ package com.team.prezel.core.domain.usecase.auth import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.domain.result.auth.LoginStatusResult +import com.team.prezel.core.model.auth.LoginStatus +import kotlinx.coroutines.flow.Flow import javax.inject.Inject /** * 저장된 인증 토큰을 기반으로 현재 로그인 상태를 확인하는 UseCase. * * ### 동작 흐름 - * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.checkLoginStatus]를 호출합니다. - * 2. 저장된 토큰 및 재발급 여부를 포함한 로그인 상태 판별은 repository 내부에서 처리합니다. - * 3. 판별 결과로 [LoginStatusResult]를 반환합니다. + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.loginStatus]를 구독합니다. + * 2. 저장된 토큰을 기반으로 로그인 상태 판별은 repository 내부에서 처리합니다. + * 3. 판별 결과를 [Flow] 형태로 반환합니다. * */ class CheckLoginStatusUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(): LoginStatusResult = authRepository.checkLoginStatus() + operator fun invoke(): Flow = authRepository.loginStatus } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt index c807283d..7c1545d6 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt @@ -1,7 +1,6 @@ package com.team.prezel.core.domain.usecase.auth import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.domain.result.auth.AuthActionResult import javax.inject.Inject /** @@ -10,10 +9,10 @@ import javax.inject.Inject * ### 동작 흐름 * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출합니다. * 2. repository가 저장된 토큰 조회와 서버 로그아웃 요청을 처리합니다. - * 3. 결과를 [AuthActionResult]로 반환합니다. + * 3. 결과를 [Result]로 반환합니다. */ class LogoutUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(): AuthActionResult = authRepository.logout() + suspend operator fun invoke(): Result = authRepository.logout() } diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt index 5d0855f8..b21e139b 100644 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt @@ -1,7 +1,6 @@ package com.team.prezel.core.domain.usecase.auth import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.model.auth.WithdrawReason import javax.inject.Inject @@ -11,10 +10,10 @@ import javax.inject.Inject * ### 동작 흐름 * 1. 호출부로부터 전달받은 [WithdrawReason]으로 [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출합니다. * 2. repository가 저장된 토큰 조회와 서버 회원 탈퇴 요청을 처리합니다. - * 3. 결과를 [AuthActionResult]로 반환합니다. + * 3. 결과를 [Result]로 반환합니다. */ class WithdrawUseCase @Inject constructor( private val authRepository: AuthRepository, ) { - suspend operator fun invoke(reason: WithdrawReason): AuthActionResult = authRepository.withdraw(reason = reason) + suspend operator fun invoke(reason: WithdrawReason): Result = authRepository.withdraw(reason = reason) } diff --git a/Prezel/core/model/build.gradle.kts b/Prezel/core/model/build.gradle.kts index 3d759f04..ec4f50d9 100644 --- a/Prezel/core/model/build.gradle.kts +++ b/Prezel/core/model/build.gradle.kts @@ -1,9 +1,7 @@ plugins { alias(libs.plugins.prezel.jvm.library) - alias(libs.plugins.kotlinx.serialization) } dependencies { implementation(libs.kotlinx.datetime) - implementation(libs.kotlinx.serialization.json) } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthTokens.kt similarity index 56% rename from Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt rename to Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthTokens.kt index 93f21a87..72d69b97 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthToken.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthTokens.kt @@ -1,9 +1,6 @@ package com.team.prezel.core.model.auth -import kotlinx.serialization.Serializable - -@Serializable -data class AuthToken( +data class AuthTokens( val accessToken: String, val refreshToken: String, ) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt new file mode 100644 index 00000000..82e63826 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt @@ -0,0 +1,7 @@ +package com.team.prezel.core.model.auth + +enum class LoginStatus { + LOADING, + AUTHENTICATED, + UNAUTHENTICATED +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ApiException.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ApiException.kt new file mode 100644 index 00000000..620f20e5 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ApiException.kt @@ -0,0 +1,7 @@ +package com.team.prezel.core.model.base + +class ApiException( + val status: Int, + val errorCode: ServerErrorCode, + override val message: String, +) : Exception(message) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt new file mode 100644 index 00000000..0f1a5800 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt @@ -0,0 +1,32 @@ +package com.team.prezel.core.model.base + +enum class ServerErrorCode( + val code: String, +) { + INVALID_REQUEST("C001"), + SERVER_ERROR("C002"), + + UNAUTHORIZED("U001"), + FORBIDDEN("U002"), + USER_NOT_FOUND("U003"), + DUPLICATE_NICKNAME("U004"), + + INVALID_TOKEN("T001"), + TOKEN_STOLEN("T002"), + INVALID_ID_TOKEN("T003"), + + TERMS_NOT_FOUND("TR001"), + REQUIRED_TERMS_DISAGREED("TR002"), + + FILE_IS_EMPTY("F001"), + FILE_UPLOAD_FAILED("F002"), + + UNKNOWN("UNKNOWN"), + ; + + companion object { + fun from(code: String?): ServerErrorCode { + return entries.firstOrNull { it.code == code } ?: UNKNOWN + } + } +} diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index cfe2b4e4..bb9ec2b4 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -17,6 +17,7 @@ android { } dependencies { + implementation(projects.coreCommon) implementation(projects.coreModel) implementation(libs.ktor.client.core) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt deleted file mode 100644 index 1fab62f5..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.team.prezel.core.network - -import com.team.prezel.core.network.model.ApiErrorResponse -import com.team.prezel.core.network.model.ApiResponse -import de.jensklingenberg.ktorfit.Ktorfit -import de.jensklingenberg.ktorfit.converter.Converter -import de.jensklingenberg.ktorfit.converter.KtorfitResult -import de.jensklingenberg.ktorfit.converter.TypeData -import io.ktor.client.call.body -import io.ktor.client.plugins.ResponseException -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.bodyAsText -import io.ktor.util.reflect.TypeInfo -import kotlinx.serialization.json.Json -import timber.log.Timber -import java.io.IOException -import kotlin.coroutines.cancellation.CancellationException - -class ApiResponseConverterFactory : Converter.Factory { - private val json = - Json { - ignoreUnknownKeys = true - encodeDefaults = true - prettyPrint = false - } - - override fun suspendResponseConverter( - typeData: TypeData, - ktorfit: Ktorfit, - ): Converter.SuspendResponseConverter? { - if (typeData.typeInfo.type != ApiResponse::class) return null - val bodyTypeInfo = typeData.typeArgs.firstOrNull()?.typeInfo ?: return null - - return object : Converter.SuspendResponseConverter> { - override suspend fun convert(result: KtorfitResult): ApiResponse = - when (result) { - is KtorfitResult.Success -> parseSuccess(result.response, bodyTypeInfo) - is KtorfitResult.Failure -> mapFailure(result.throwable) - } - } - } - - private suspend fun parseSuccess( - response: HttpResponse, - bodyTypeInfo: TypeInfo, - ): ApiResponse = - try { - val body = response.body(bodyTypeInfo) - ApiResponse.Success( - data = body, - ) - } catch (t: Throwable) { - t.rethrowIfCancellation() - Timber.e(t, "Response parsing failed") - ApiResponse.Failure.NetworkError(t) - } - - private suspend fun mapFailure(t: Throwable): ApiResponse { - t.rethrowIfCancellation() - - return when (t) { - is IOException -> { - Timber.e(t, "Network error") - ApiResponse.Failure.NetworkError(t) - } - - is ResponseException -> { - Timber.e(t, "HTTP error ${t.response.status.value}") - ApiResponse.Failure.HttpError( - error = parseErrorResponse(t), - throwable = t, - ) - } - - else -> { - Timber.e(t, "Unknown error") - ApiResponse.Failure.NetworkError(t) - } - } - } - - private suspend fun parseErrorResponse(exception: ResponseException): ApiErrorResponse? = - try { - json.decodeFromString(exception.response.bodyAsText()) - } catch (t: Throwable) { - t.rethrowIfCancellation() - null - } - - private fun Throwable.rethrowIfCancellation() { - if (this is CancellationException) throw this - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt deleted file mode 100644 index 59d82f2c..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthSessionExpiredNotifier.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.network.auth - -interface AuthSessionExpiredNotifier { - fun notifySessionExpired() -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt deleted file mode 100644 index 3017cc4a..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefreshResult.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.team.prezel.core.network.auth - -import com.team.prezel.core.model.auth.AuthToken - -sealed interface AuthTokenRefreshResult { - data class Success( - val token: AuthToken, - ) : AuthTokenRefreshResult - - sealed interface Failure : AuthTokenRefreshResult { - val throwable: Throwable - - data class Retryable( - override val throwable: Throwable, - ) : Failure - - data class Unrecoverable( - override val throwable: Throwable, - ) : Failure - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt deleted file mode 100644 index ccb75d44..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenRefresher.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.team.prezel.core.network.auth - -import com.team.prezel.core.model.auth.AuthToken -import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.network.model.ApiErrorResponse -import com.team.prezel.core.network.model.auth.LoginResponse -import com.team.prezel.core.network.model.auth.ReissueTokenRequest -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.plugins.ResponseException -import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.RefreshTokensParams -import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.cancellation.CancellationException - -@Singleton -class AuthTokenRefresher @Inject constructor( - private val json: Json, - private val authTokenStore: AuthTokenStore, - private val authSessionExpiredNotifier: AuthSessionExpiredNotifier, -) { - private val mutex = Mutex() - - suspend fun refreshBearerTokens(params: RefreshTokensParams): BearerTokens? = - mutex.withLock { - val storedToken = authTokenStore.getToken().first() - val oldRefreshToken = params.oldTokens?.refreshToken - - if ( - oldRefreshToken != null && - storedToken != null && - storedToken.refreshToken != oldRefreshToken - ) { - return@withLock storedToken.toBearerTokens() - } - - val refreshToken = oldRefreshToken - ?: storedToken?.refreshToken - ?: return@withLock null - - when ( - val result = - requestTokenReissue( - client = params.client, - refreshToken = refreshToken, - markAsRefreshTokenRequest = { - with(params) { - markAsRefreshTokenRequest() - } - }, - ) - ) { - is AuthTokenRefreshResult.Success -> - BearerTokens( - accessToken = result.token.accessToken, - refreshToken = result.token.refreshToken, - ) - - is AuthTokenRefreshResult.Failure -> null - } - } - - private fun AuthToken.toBearerTokens(): BearerTokens = - BearerTokens( - accessToken = accessToken, - refreshToken = refreshToken, - ) - - private suspend fun requestTokenReissue( - client: HttpClient, - refreshToken: String, - markAsRefreshTokenRequest: HttpRequestBuilder.() -> Unit, - ): AuthTokenRefreshResult = - try { - val response = client - .post("${BuildConfig.BASE_URL}auth/reissue") { - markAsRefreshTokenRequest() - attributes.put(AuthRequestAttributes.SkipAuthKey, true) - setBody(ReissueTokenRequest(refreshToken = refreshToken)) - }.body() - - val token = AuthToken( - accessToken = response.accessToken, - refreshToken = response.refreshToken, - ) - authTokenStore - .saveToken(token) - .onFailure { throwable -> - Timber.e(throwable, "재발급된 토큰 저장에 실패했습니다.") - return AuthTokenRefreshResult.Failure.Retryable(throwable) - } - if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d("토큰 재발급에 성공했습니다.") - } - AuthTokenRefreshResult.Success(token) - } catch (t: Throwable) { - t.rethrowIfCancellation() - if (t.isSessionRecoveryUnrecoverable()) { - authTokenStore.clear().fold( - onSuccess = { - authSessionExpiredNotifier.notifySessionExpired() - }, - onFailure = { throwable -> - Timber.e(throwable, "인증 토큰 삭제에 실패했습니다.") - }, - ) - Timber.e(t, "토큰 재발급에 실패했습니다.") - AuthTokenRefreshResult.Failure.Unrecoverable(t) - } else { - Timber.e(t, "토큰 재발급에 실패했습니다.") - AuthTokenRefreshResult.Failure.Retryable(t) - } - } - - private suspend fun Throwable.isSessionRecoveryUnrecoverable(): Boolean { - if (this !is ResponseException) return false - - val error = try { - json.decodeFromString(response.bodyAsText()) - } catch (t: Throwable) { - t.rethrowIfCancellation() - null - } - - return error?.code == TOKEN_INVALID_CODE || - error?.code == AUTHENTICATION_REQUIRED_CODE || - error?.code == USER_NOT_FOUND_CODE - } - - fun Throwable.rethrowIfCancellation() { - if (this is CancellationException) throw this - } - - private companion object { - const val TOKEN_INVALID_CODE = "T001" - const val AUTHENTICATION_REQUIRED_CODE = "U001" - const val USER_NOT_FOUND_CODE = "U003" - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt deleted file mode 100644 index 085338d4..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthTokenStore.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.team.prezel.core.network.auth - -import com.team.prezel.core.model.auth.AuthToken -import kotlinx.coroutines.flow.Flow - -interface AuthTokenStore { - fun getToken(): Flow - - suspend fun saveToken(token: AuthToken): Result - - suspend fun clear(): Result -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt new file mode 100644 index 00000000..f4ab472c --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.auth + +import com.team.prezel.core.model.auth.AuthTokens + +interface TokenProvider { + suspend fun getTokens(): AuthTokens? + suspend fun updateTokens( + accessToken: String, + refreshToken: String, + ) + suspend fun clearTokens() +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt index 45b2776e..a6495118 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt @@ -1,15 +1,14 @@ package com.team.prezel.core.network.datasource -import com.team.prezel.core.network.model.ApiResponse import com.team.prezel.core.network.model.auth.LoginResponse interface AuthRemoteDataSource { - suspend fun logout(): ApiResponse + suspend fun logout() - suspend fun login(idToken: String): ApiResponse + suspend fun login(idToken: String): LoginResponse suspend fun withdraw( reasonCategory: String, reasonText: String, - ): ApiResponse + ) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index b30ff386..e4c01fa7 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -1,48 +1,28 @@ package com.team.prezel.core.network.datasource -import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.model.auth.LoginRequest import com.team.prezel.core.network.model.auth.LoginResponse import com.team.prezel.core.network.model.auth.WithdrawRequest import com.team.prezel.core.network.service.AuthService -import timber.log.Timber import javax.inject.Inject internal class AuthRemoteDataSourceImpl @Inject constructor( private val authService: AuthService, ) : AuthRemoteDataSource { - override suspend fun logout(): ApiResponse = authService.logout() + override suspend fun logout() { + authService.logout().requireData() + } - override suspend fun login(idToken: String): ApiResponse = - authService - .login(request = LoginRequest(idToken = idToken)) - .also(::logTokenResponse) + override suspend fun login(idToken: String): LoginResponse = + authService.login(request = LoginRequest(idToken = idToken)).requireData() override suspend fun withdraw( reasonCategory: String, reasonText: String, - ): ApiResponse = + ) { authService.withdraw( - request = WithdrawRequest( - reasonCategory = reasonCategory, - reasonText = reasonText, - ), - ) - - private fun logTokenResponse(response: ApiResponse) { - if (!BuildConfig.DEBUG) return - - when (response) { - is ApiResponse.Success -> Timber.tag("AuthToken").d("서버 인증 응답에 성공했습니다.") - - is ApiResponse.Failure.HttpError -> { - Timber.tag("AuthToken").e(response.throwable, "서버 로그인에 실패했습니다: http error") - } - - is ApiResponse.Failure.NetworkError -> { - Timber.tag("AuthToken").e(response.throwable, "서버 로그인에 실패했습니다: network error") - } - } + request = WithdrawRequest(reasonCategory = reasonCategory, reasonText = reasonText), + ).requireData() } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 1c7c2f01..65e25512 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,11 +1,13 @@ package com.team.prezel.core.network.di import android.os.Build -import com.team.prezel.core.network.ApiResponseConverterFactory +import com.team.prezel.core.common.event.GlobalEvent +import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.network.BuildConfig import com.team.prezel.core.network.auth.AuthRequestAttributes -import com.team.prezel.core.network.auth.AuthTokenRefresher -import com.team.prezel.core.network.auth.AuthTokenStore +import com.team.prezel.core.network.auth.TokenProvider +import com.team.prezel.core.network.model.auth.reissue.ReissueRequest +import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.service.AuthService import com.team.prezel.core.network.service.createAuthService import dagger.Module @@ -14,10 +16,10 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.clearAuthTokens import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -25,67 +27,81 @@ import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.observer.ResponseObserver import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.Json import timber.log.Timber +import javax.inject.Provider import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object NetworkModule { - @Provides - @Singleton - fun provideJson(): Json = + private val networkJson: Json = Json { ignoreUnknownKeys = true encodeDefaults = true + prettyPrint = true } @Provides @Singleton internal fun provideHttpClient( - json: Json, - authTokenStore: AuthTokenStore, - authTokenRefresher: AuthTokenRefresher, - ): HttpClient = createHttpClient(json) { configureAuthenticatedClient(authTokenStore, authTokenRefresher) } - - @Provides - @Singleton - fun provideKtorfit(httpClient: HttpClient): Ktorfit = createKtorfit(httpClient) + tokenProvider: TokenProvider, + authServiceProvider: Provider, + globalEventBus: GlobalEventBus, + ): HttpClient = HttpClient(OkHttp) { + defaultRequest { + contentType(ContentType.Application.Json) + } + install(ContentNegotiation) { json(networkJson) } - @Provides - @Singleton - internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() + install(UserAgent) { agent = buildUserAgent() } - private fun createHttpClient( - json: Json, - configure: HttpClientConfig<*>.() -> Unit = {}, - ): HttpClient = - HttpClient(OkHttp) { - configureBaseClient(json) - configure() - defaultRequest { - contentType(ContentType.Application.Json) + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Timber.tag("KTOR-LOG").d(message) + } } + sanitizeHeader { header -> header == HttpHeaders.Authorization } + level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE } - private fun HttpClientConfig<*>.configureAuthenticatedClient( - authTokenStore: AuthTokenStore, - authTokenRefresher: AuthTokenRefresher, - ) { install(Auth) { bearer { - cacheTokens = false + cacheTokens = true loadTokens { - authTokenStore.toBearerTokens() + tokenProvider.getTokens()?.let { tokens -> + BearerTokens( + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + ) + } } refreshTokens { - authTokenRefresher.refreshBearerTokens(this) + val oldRefreshToken = oldTokens?.refreshToken ?: return@refreshTokens null + return@refreshTokens try { + val response = authServiceProvider.get() + .reissue(request = ReissueRequest(oldRefreshToken)) + .requireData() + with(response) { + tokenProvider.updateTokens(accessToken = accessToken, refreshToken = refreshToken) + BearerTokens(accessToken = accessToken, refreshToken = refreshToken).also { client.clearAuthTokens() } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + tokenProvider.clearTokens() + client.clearAuthTokens() + globalEventBus.emit(GlobalEvent.ForceLogout) + null + } } sendWithoutRequest { request -> @@ -95,44 +111,19 @@ object NetworkModule { } } - private suspend fun AuthTokenStore.toBearerTokens(): BearerTokens? { - val token = getToken().first() ?: return null - - return BearerTokens( - accessToken = token.accessToken, - refreshToken = token.refreshToken, - ) - } - - private fun createKtorfit(httpClient: HttpClient): Ktorfit = - Ktorfit - .Builder() + @Provides + @Singleton + fun provideKtorfit( + httpClient: HttpClient, + ): Ktorfit = + Ktorfit.Builder() .baseUrl(BuildConfig.BASE_URL) .httpClient(httpClient) - .converterFactories(ApiResponseConverterFactory()) .build() - private fun HttpClientConfig<*>.configureBaseClient(json: Json) { - expectSuccess = true - - install(ContentNegotiation) { - json(json) - } - - install(UserAgent) { - agent = buildUserAgent() - } - - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - Timber.tag("KtorClient").d(message) - } - } - sanitizeHeader { header -> header == HttpHeaders.Authorization } - level = if (BuildConfig.DEBUG) LogLevel.HEADERS else LogLevel.NONE - } - } + @Provides + @Singleton + internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() private fun buildUserAgent(): String = buildString { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt deleted file mode 100644 index 54ddaa59..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiErrorResponse.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.team.prezel.core.network.model - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -@Serializable -data class ApiErrorResponse( - val status: Int, - val code: String, - val data: JsonElement? = null, - val message: String, -) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt deleted file mode 100644 index eee2a1a5..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.team.prezel.core.network.model - -sealed interface ApiResponse { - data class Success( - val data: T, - ) : ApiResponse - - sealed interface Failure : ApiResponse { - data class HttpError( - val error: ApiErrorResponse?, - val throwable: Throwable, - ) : Failure - - data class NetworkError( - val throwable: Throwable, - ) : Failure - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt new file mode 100644 index 00000000..0d9e6467 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt @@ -0,0 +1,30 @@ +package com.team.prezel.core.network.model + +import com.team.prezel.core.model.base.ApiException +import com.team.prezel.core.model.base.ServerErrorCode +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseResponse( + @SerialName("status") + val status: Int, + @SerialName("code") + val code: String, + @SerialName("data") + val data: T, + @SerialName("message") + val message: String, +) + +internal fun BaseResponse.requireData(): T { + if (status !in 200..299) { + throw ApiException( + status = status, + errorCode = ServerErrorCode.from(code), + message = message, + ) + } + + return data +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/ReissueTokenRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt similarity index 55% rename from Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/ReissueTokenRequest.kt rename to Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt index a5d45f79..ea4f63ab 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/ReissueTokenRequest.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt @@ -1,10 +1,11 @@ -package com.team.prezel.core.network.model.auth +package com.team.prezel.core.network.model.auth.reissue +import com.team.prezel.core.model.auth.AuthTokens import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class ReissueTokenRequest( +data class ReissueRequest( @SerialName("refresh-token") val refreshToken: String, ) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueResponse.kt new file mode 100644 index 00000000..dda88411 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueResponse.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model.auth.reissue + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReissueResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt index c787633f..b5008e0f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -1,10 +1,12 @@ package com.team.prezel.core.network.service import com.team.prezel.core.network.auth.AuthRequestAttributes -import com.team.prezel.core.network.model.ApiResponse +import com.team.prezel.core.network.model.BaseResponse import com.team.prezel.core.network.model.auth.LoginRequest import com.team.prezel.core.network.model.auth.LoginResponse import com.team.prezel.core.network.model.auth.WithdrawRequest +import com.team.prezel.core.network.model.auth.reissue.ReissueRequest +import com.team.prezel.core.network.model.auth.reissue.ReissueResponse import de.jensklingenberg.ktorfit.http.Body import de.jensklingenberg.ktorfit.http.DELETE import de.jensklingenberg.ktorfit.http.POST @@ -12,16 +14,22 @@ import de.jensklingenberg.ktorfit.http.Tag internal interface AuthService { @POST("auth/logout") - suspend fun logout(): ApiResponse + suspend fun logout(): BaseResponse @POST("auth/login") suspend fun login( @Body request: LoginRequest, @Tag(AuthRequestAttributes.SKIP_AUTH) skipAuth: Boolean = true, - ): ApiResponse + ): BaseResponse @DELETE("auth/withdraw") suspend fun withdraw( @Body request: WithdrawRequest, - ): ApiResponse + ): BaseResponse + + @POST("auth/reissue") + suspend fun reissue( + @Body request: ReissueRequest, + @Tag(AuthRequestAttributes.SKIP_AUTH) skipAuth: Boolean = true, + ): BaseResponse } diff --git a/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt b/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt deleted file mode 100644 index 1a821d23..00000000 --- a/Prezel/core/network/src/test/java/com/team/prezel/core/network/AuthTokenRefresherTest.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.team.prezel.core.network - -import com.team.prezel.core.model.auth.AuthToken -import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier -import com.team.prezel.core.network.auth.AuthTokenRefresher -import com.team.prezel.core.network.auth.AuthTokenStore -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.RefreshTokensParams -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.request.get -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.http.headersOf -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.fail - -class AuthTokenRefresherTest { - private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - } - - @Test - fun `refresh token 만료 응답이면 토큰을 삭제하고 세션 만료를 알린다`() = - runTest { - val tokenStore = FakeAuthTokenStore() - val notifier = FakeAuthSessionExpiredNotifier() - val refresher = AuthTokenRefresher( - json = json, - authTokenStore = tokenStore, - authSessionExpiredNotifier = notifier, - ) - val client = createClient( - content = - """ - { - "status": 401, - "code": "T001", - "data": null, - "message": "Token is invalid." - } - """.trimIndent(), - status = HttpStatusCode.Unauthorized, - ) - - val result = refresher.refreshBearerTokens( - params = client.refreshTokensParams(), - ) - - assertNull(result) - assertEquals(1, tokenStore.clearCount) - assertNull(tokenStore.token) - assertEquals(1, notifier.notifyCount) - } - - @Test - fun `refresh 성공이면 새 토큰을 저장하고 BearerTokens를 반환한다`() = - runTest { - val tokenStore = FakeAuthTokenStore() - val notifier = FakeAuthSessionExpiredNotifier() - val refresher = AuthTokenRefresher( - json = json, - authTokenStore = tokenStore, - authSessionExpiredNotifier = notifier, - ) - val client = createClient( - content = - """ - { - "accessToken": "new-access-token", - "refreshToken": "new-refresh-token" - } - """.trimIndent(), - status = HttpStatusCode.OK, - ) - - val result = refresher.refreshBearerTokens( - params = client.refreshTokensParams(), - ) - - assertEquals("new-access-token", result?.accessToken) - assertEquals("new-refresh-token", result?.refreshToken) - assertEquals(AuthToken("new-access-token", "new-refresh-token"), tokenStore.token) - assertEquals(1, tokenStore.saveCount) - assertEquals(0, tokenStore.clearCount) - assertEquals(0, notifier.notifyCount) - } - - @Test - fun `저장소 토큰이 이미 갱신됐으면 재발급 요청 없이 최신 BearerTokens를 반환한다`() = - runTest { - val updatedToken = AuthToken( - accessToken = "updated-access-token", - refreshToken = "updated-refresh-token", - ) - val tokenStore = FakeAuthTokenStore(token = updatedToken) - val notifier = FakeAuthSessionExpiredNotifier() - val refresher = AuthTokenRefresher( - json = json, - authTokenStore = tokenStore, - authSessionExpiredNotifier = notifier, - ) - val client = createClient( - content = "", - status = HttpStatusCode.OK, - failOnReissue = true, - ) - - val result = refresher.refreshBearerTokens( - params = client.refreshTokensParams(), - ) - - assertEquals("updated-access-token", result?.accessToken) - assertEquals("updated-refresh-token", result?.refreshToken) - assertEquals(0, tokenStore.saveCount) - assertEquals(0, tokenStore.clearCount) - assertEquals(0, notifier.notifyCount) - } - - private fun createClient( - content: String, - status: HttpStatusCode, - failOnReissue: Boolean = false, - ): HttpClient = - HttpClient( - MockEngine { request -> - if (request.url.encodedPath == "/protected") { - respond(content = "{}", status = HttpStatusCode.OK, headers = jsonHeaders) - } else if (failOnReissue) { - fail("재발급 요청이 호출되지 않아야 합니다.") - } else { - respond(content = content, status = status, headers = jsonHeaders) - } - }, - ) { - expectSuccess = true - - install(ContentNegotiation) { - json(json) - } - - defaultRequest { - contentType(ContentType.Application.Json) - } - } - - private suspend fun HttpClient.refreshTokensParams(): RefreshTokensParams = - RefreshTokensParams( - client = this, - response = get("https://prezel.test/protected"), - oldTokens = BearerTokens( - accessToken = initialToken.accessToken, - refreshToken = initialToken.refreshToken, - ), - ) - - private class FakeAuthTokenStore( - var token: AuthToken? = initialToken, - private val clearResult: Result = Result.success(Unit), - ) : AuthTokenStore { - var saveCount = 0 - private set - var clearCount = 0 - private set - - override fun getToken(): Flow = flowOf(token) - - override suspend fun saveToken(token: AuthToken): Result { - saveCount += 1 - this.token = token - return Result.success(Unit) - } - - override suspend fun clear(): Result { - clearCount += 1 - clearResult.onSuccess { - token = null - } - return clearResult - } - } - - private class FakeAuthSessionExpiredNotifier : AuthSessionExpiredNotifier { - var notifyCount = 0 - private set - - override fun notifySessionExpired() { - notifyCount += 1 - } - } - - private companion object { - val initialToken = AuthToken( - accessToken = "old-access-token", - refreshToken = "old-refresh-token", - ) - val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - } -} diff --git a/Prezel/feature/splash/impl/build.gradle.kts b/Prezel/feature/splash/impl/build.gradle.kts index 988400bf..fe2f1b2c 100644 --- a/Prezel/feature/splash/impl/build.gradle.kts +++ b/Prezel/feature/splash/impl/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(projects.coreDomain) + implementation(projects.coreModel) implementation(projects.featureSplashApi) implementation(projects.featureLoginApi) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index cc89c7d4..a1295b37 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -1,15 +1,15 @@ package com.team.prezel.feature.splash.impl import androidx.lifecycle.viewModelScope -import com.team.prezel.core.domain.result.auth.LoginStatusResult import com.team.prezel.core.domain.usecase.auth.CheckLoginStatusUseCase +import com.team.prezel.core.model.auth.LoginStatus import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent import com.team.prezel.feature.splash.impl.contract.SplashUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -29,13 +29,10 @@ internal class SplashViewModel @Inject constructor( viewModelScope .launch { - when (val result = checkLoginStatusUseCase()) { - LoginStatusResult.Authenticated -> sendEffect(SplashUiEffect.NavigateToHome) - LoginStatusResult.Unauthenticated -> sendEffect(SplashUiEffect.NavigateToLogin) - is LoginStatusResult.RetryableFailure -> { - Timber.w(result.throwable, "로그인 상태 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.") - sendEffect(SplashUiEffect.ShowRetryableFailureMessage) - } + when (checkLoginStatusUseCase().first { it != LoginStatus.LOADING }) { + LoginStatus.AUTHENTICATED -> sendEffect(SplashUiEffect.NavigateToHome) + LoginStatus.UNAUTHENTICATED -> sendEffect(SplashUiEffect.NavigateToLogin) + LoginStatus.LOADING -> Unit } }.invokeOnCompletion { updateState { copy(isLoading = false) } } } diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 57bf34d2..e38ba928 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -70,7 +70,6 @@ coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = " kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 7a48c509..22be6806 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -39,7 +39,8 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") includeAuto( ":app", - "core:auth", + ":core:auth", + ":core:common", ":core:data", ":core:datastore", ":core:designsystem", From a1dc1e95da5616051c6bb388bbb9378c4f4e7699 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 02:40:59 +0900 Subject: [PATCH 48/63] =?UTF-8?q?refactor:=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/team/prezel/GlobalEventModule.kt | 4 +- .../main/java/com/team/prezel/MainActivity.kt | 15 +-- .../main/java/com/team/prezel/ui/PrezelApp.kt | 33 +++---- .../java/com/team/prezel/ui/PrezelAppState.kt | 5 - .../auth/DataAuthSessionExpiredNotifier.kt | 13 --- .../core/data/auth/DataAuthTokenStore.kt | 19 ---- .../data/auth/DefaultAuthSessionMonitor.kt | 26 ----- .../prezel/core/data/di/AuthDataModule.kt | 34 ------- .../datastore/auth/AuthLocalDataSource.kt | 5 +- .../datastore/auth/AuthLocalDataSourceImpl.kt | 5 +- .../core/datastore/di/DataStoreModule.kt | 1 - .../core/domain/session/AuthSessionEvent.kt | 5 - .../session/AuthSessionEventPublisher.kt | 5 - .../core/domain/session/AuthSessionMonitor.kt | 9 -- .../prezel/core/model/auth/LoginStatus.kt | 2 +- .../prezel/core/model/base/ServerErrorCode.kt | 4 +- .../prezel/core/network/auth/TokenProvider.kt | 2 + .../datasource/AuthRemoteDataSourceImpl.kt | 12 +-- .../prezel/core/network/di/NetworkModule.kt | 96 +++++++++---------- .../model/auth/reissue/ReissueRequest.kt | 1 - 20 files changed, 84 insertions(+), 212 deletions(-) delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionMonitor.kt delete mode 100644 Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt diff --git a/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt b/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt index a743484c..bff168ef 100644 --- a/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt +++ b/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt @@ -13,7 +13,5 @@ import javax.inject.Singleton internal abstract class GlobalEventModule { @Binds @Singleton - abstract fun bindsGlobalEventBus( - globalEventBus: DefaultGlobalEventBus, - ): GlobalEventBus + abstract fun bindsGlobalEventBus(globalEventBus: DefaultGlobalEventBus): GlobalEventBus } diff --git a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt index 86d53a65..d3903118 100644 --- a/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt +++ b/Prezel/app/src/main/java/com/team/prezel/MainActivity.kt @@ -6,10 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import com.team.prezel.core.auth.AuthManager +import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.data.NetworkMonitor import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.domain.session.AuthSessionMonitor import com.team.prezel.ui.PrezelApp import com.team.prezel.ui.rememberPrezelAppState import dagger.hilt.android.AndroidEntryPoint @@ -22,10 +21,7 @@ class MainActivity : ComponentActivity() { lateinit var networkMonitor: NetworkMonitor @Inject - lateinit var authManager: AuthManager - - @Inject - lateinit var authSessionMonitor: AuthSessionMonitor + lateinit var globalEventBus: GlobalEventBus @Inject lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit> @@ -36,15 +32,12 @@ class MainActivity : ComponentActivity() { setContent { PrezelTheme { - val appState = rememberPrezelAppState( - networkMonitor = networkMonitor, - authSessionMonitor = authSessionMonitor, - ) + val appState = rememberPrezelAppState(networkMonitor = networkMonitor) PrezelApp( appState = appState, + globalEventBus = globalEventBus, entryBuilders = entryBuilders.toImmutableSet(), - onSessionExpired = authManager::clearAuthSession, ) } } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 96e027e9..c0a586b7 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -17,8 +17,9 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay +import com.team.prezel.core.common.event.GlobalEvent +import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold -import com.team.prezel.core.domain.session.AuthSessionEvent import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope @@ -27,42 +28,25 @@ import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.login.api.LoginNavKey import com.team.prezel.navigation.MAIN_NAV_ITEMS import kotlinx.collections.immutable.ImmutableSet -import timber.log.Timber @Composable fun PrezelApp( appState: PrezelAppState, + globalEventBus: GlobalEventBus, entryBuilders: ImmutableSet.() -> Unit>, - onSessionExpired: suspend () -> Unit, ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } val snackbarHostState = remember { SnackbarHostState() } - val authSessionMonitor = appState.authSessionMonitor CompositionLocalProvider( LocalNavigator provides navigator, LocalSnackbarHostState provides snackbarHostState, ) { - LaunchedEffect(authSessionMonitor, navigator) { - authSessionMonitor.sessionEvents.collect { event -> - when (event) { - AuthSessionEvent.Expired -> { - authSessionMonitor.acknowledgeSessionEvent() - runCatching { - onSessionExpired() - }.onFailure { throwable -> - Timber.w(throwable, "세션 만료 후 인증 세션 정리에 실패했습니다.") - } - navigator.replaceRoot(LoginNavKey) - } - } - } - } - DoubleBackToExitHandler(navigationState = appState.navigationState) PrezelAppContent( appState = appState, + globalEventBus = globalEventBus, entryBuilders = entryBuilders, ) } @@ -71,12 +55,21 @@ fun PrezelApp( @Composable private fun PrezelAppContent( appState: PrezelAppState, + globalEventBus: GlobalEventBus, entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current val showNavigationBar = appState.shouldShowNavigationBar + LaunchedEffect(globalEventBus, navigator) { + globalEventBus.events.collect { event -> + when (event) { + GlobalEvent.ForceLogout -> navigator.replaceRoot(LoginNavKey) + } + } + } + SharedTransitionLayout { ProvideSharedTransitionScope(this@SharedTransitionLayout) { val provider = remember(entryBuilders, navigator) { diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt index 4129eca8..312ed5f1 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.team.prezel.core.data.NetworkMonitor -import com.team.prezel.core.domain.session.AuthSessionMonitor import com.team.prezel.core.navigation.NavigationState import com.team.prezel.core.navigation.rememberNavigationState import com.team.prezel.feature.splash.api.SplashNavKey @@ -20,7 +19,6 @@ import kotlinx.coroutines.flow.stateIn @Composable fun rememberPrezelAppState( networkMonitor: NetworkMonitor, - authSessionMonitor: AuthSessionMonitor, coroutineScope: CoroutineScope = rememberCoroutineScope(), ): PrezelAppState { val navigationState = rememberNavigationState( @@ -32,13 +30,11 @@ fun rememberPrezelAppState( navigationState, coroutineScope, networkMonitor, - authSessionMonitor, ) { PrezelAppState( navigationState = navigationState, coroutineScope = coroutineScope, networkMonitor = networkMonitor, - authSessionMonitor = authSessionMonitor, ) } } @@ -46,7 +42,6 @@ fun rememberPrezelAppState( @Stable class PrezelAppState( val navigationState: NavigationState, - val authSessionMonitor: AuthSessionMonitor, coroutineScope: CoroutineScope, networkMonitor: NetworkMonitor, ) { diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt deleted file mode 100644 index 1c389c46..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthSessionExpiredNotifier.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.team.prezel.core.data.auth - -import com.team.prezel.core.domain.session.AuthSessionEventPublisher -import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier -import javax.inject.Inject - -internal class DataAuthSessionExpiredNotifier @Inject constructor( - private val authSessionEventPublisher: AuthSessionEventPublisher, -) : AuthSessionExpiredNotifier { - override fun notifySessionExpired() { - authSessionEventPublisher.notifySessionExpired() - } -} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt deleted file mode 100644 index 0aec1624..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DataAuthTokenStore.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.team.prezel.core.data.auth - -import com.team.prezel.core.datastore.auth.AuthLocalDataSource -import com.team.prezel.core.model.auth.AuthToken -import com.team.prezel.core.network.auth.AuthTokenStore -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -internal class DataAuthTokenStore @Inject constructor( - private val authLocalDataSource: AuthLocalDataSource, -) : AuthTokenStore { - override fun getToken(): Flow = authLocalDataSource.getToken() - - override suspend fun saveToken(token: AuthToken): Result = authLocalDataSource.saveToken(token) - - override suspend fun clear(): Result = authLocalDataSource.clear() -} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionMonitor.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionMonitor.kt deleted file mode 100644 index 76f50a9d..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultAuthSessionMonitor.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.team.prezel.core.data.auth - -import com.team.prezel.core.domain.session.AuthSessionEvent -import com.team.prezel.core.domain.session.AuthSessionEventPublisher -import com.team.prezel.core.domain.session.AuthSessionMonitor -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -internal class DefaultAuthSessionMonitor @Inject constructor() : - AuthSessionMonitor, - AuthSessionEventPublisher { - private val event = MutableStateFlow(null) - override val sessionEvents: Flow = event.filterNotNull() - - override fun notifySessionExpired() { - event.value = AuthSessionEvent.Expired - } - - override fun acknowledgeSessionEvent() { - event.value = null - } - } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt deleted file mode 100644 index 22dc2a8b..00000000 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthDataModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.team.prezel.core.data.di - -import com.team.prezel.core.data.auth.DataAuthSessionExpiredNotifier -import com.team.prezel.core.data.auth.DataAuthTokenStore -import com.team.prezel.core.data.auth.DefaultAuthSessionMonitor -import com.team.prezel.core.domain.session.AuthSessionEventPublisher -import com.team.prezel.core.domain.session.AuthSessionMonitor -import com.team.prezel.core.network.auth.AuthSessionExpiredNotifier -import com.team.prezel.core.network.auth.AuthTokenStore -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal abstract class AuthDataModule { - @Singleton - @Binds - abstract fun bindAuthTokenStore(authTokenStore: DataAuthTokenStore): AuthTokenStore - - @Singleton - @Binds - abstract fun bindAuthSessionExpiredNotifier(notifier: DataAuthSessionExpiredNotifier): AuthSessionExpiredNotifier - - @Singleton - @Binds - abstract fun bindAuthSessionEventPublisher(monitor: DefaultAuthSessionMonitor): AuthSessionEventPublisher - - @Singleton - @Binds - abstract fun bindAuthSessionMonitor(monitor: DefaultAuthSessionMonitor): AuthSessionMonitor -} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt index 73908469..48dd2cc7 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt @@ -6,7 +6,10 @@ import kotlinx.coroutines.flow.Flow interface AuthLocalDataSource { val tokens: Flow - suspend fun saveTokens(accessToken: String, refreshToken: String) + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) suspend fun clearTokens() } diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt index d890fabb..9f0e80b0 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -39,7 +39,10 @@ internal class AuthLocalDataSourceImpl @Inject constructor( } } - override suspend fun saveTokens(accessToken: String, refreshToken: String) { + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { dataStore.edit { preferences -> preferences[ACCESS_TOKEN_KEY] = accessToken preferences[REFRESH_TOKEN_KEY] = refreshToken diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt index bdb76823..f9f6703d 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt @@ -5,7 +5,6 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile -import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt deleted file mode 100644 index e0f066d7..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEvent.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.domain.session - -sealed interface AuthSessionEvent { - data object Expired : AuthSessionEvent -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt deleted file mode 100644 index 2627333f..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionEventPublisher.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.domain.session - -interface AuthSessionEventPublisher { - fun notifySessionExpired() -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt deleted file mode 100644 index 8dc47fbf..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/session/AuthSessionMonitor.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.team.prezel.core.domain.session - -import kotlinx.coroutines.flow.Flow - -interface AuthSessionMonitor { - val sessionEvents: Flow - - fun acknowledgeSessionEvent() -} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt index 82e63826..4a7d0db5 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt @@ -3,5 +3,5 @@ package com.team.prezel.core.model.auth enum class LoginStatus { LOADING, AUTHENTICATED, - UNAUTHENTICATED + UNAUTHENTICATED, } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt index 0f1a5800..49cb56b8 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt @@ -25,8 +25,6 @@ enum class ServerErrorCode( ; companion object { - fun from(code: String?): ServerErrorCode { - return entries.firstOrNull { it.code == code } ?: UNKNOWN - } + fun from(code: String?): ServerErrorCode = entries.firstOrNull { it.code == code } ?: UNKNOWN } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt index f4ab472c..cfff3460 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt @@ -4,9 +4,11 @@ import com.team.prezel.core.model.auth.AuthTokens interface TokenProvider { suspend fun getTokens(): AuthTokens? + suspend fun updateTokens( accessToken: String, refreshToken: String, ) + suspend fun clearTokens() } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index e4c01fa7..eaedd5ab 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -1,9 +1,9 @@ package com.team.prezel.core.network.datasource -import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.model.auth.LoginRequest import com.team.prezel.core.network.model.auth.LoginResponse import com.team.prezel.core.network.model.auth.WithdrawRequest +import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.service.AuthService import javax.inject.Inject @@ -14,15 +14,15 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( authService.logout().requireData() } - override suspend fun login(idToken: String): LoginResponse = - authService.login(request = LoginRequest(idToken = idToken)).requireData() + override suspend fun login(idToken: String): LoginResponse = authService.login(request = LoginRequest(idToken = idToken)).requireData() override suspend fun withdraw( reasonCategory: String, reasonText: String, ) { - authService.withdraw( - request = WithdrawRequest(reasonCategory = reasonCategory, reasonText = reasonText), - ).requireData() + authService + .withdraw( + request = WithdrawRequest(reasonCategory = reasonCategory, reasonText = reasonText), + ).requireData() } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 65e25512..3832ab7d 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -27,7 +27,6 @@ import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.observer.ResponseObserver import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.contentType @@ -54,69 +53,70 @@ object NetworkModule { tokenProvider: TokenProvider, authServiceProvider: Provider, globalEventBus: GlobalEventBus, - ): HttpClient = HttpClient(OkHttp) { - defaultRequest { - contentType(ContentType.Application.Json) - } - install(ContentNegotiation) { json(networkJson) } + ): HttpClient = + HttpClient(OkHttp) { + defaultRequest { + contentType(ContentType.Application.Json) + } + install(ContentNegotiation) { json(networkJson) } - install(UserAgent) { agent = buildUserAgent() } + install(UserAgent) { agent = buildUserAgent() } - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - Timber.tag("KTOR-LOG").d(message) + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + Timber.tag("KTOR-LOG").d(message) + } } + sanitizeHeader { header -> header == HttpHeaders.Authorization } + level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE } - sanitizeHeader { header -> header == HttpHeaders.Authorization } - level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE - } - install(Auth) { - bearer { - cacheTokens = true - loadTokens { - tokenProvider.getTokens()?.let { tokens -> - BearerTokens( - accessToken = tokens.accessToken, - refreshToken = tokens.refreshToken, - ) + install(Auth) { + bearer { + cacheTokens = true + loadTokens { + tokenProvider.getTokens()?.let { tokens -> + BearerTokens( + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + ) + } } - } - refreshTokens { - val oldRefreshToken = oldTokens?.refreshToken ?: return@refreshTokens null - return@refreshTokens try { - val response = authServiceProvider.get() - .reissue(request = ReissueRequest(oldRefreshToken)) - .requireData() - with(response) { - tokenProvider.updateTokens(accessToken = accessToken, refreshToken = refreshToken) - BearerTokens(accessToken = accessToken, refreshToken = refreshToken).also { client.clearAuthTokens() } + refreshTokens { + val oldRefreshToken = oldTokens?.refreshToken ?: return@refreshTokens null + return@refreshTokens try { + val response = authServiceProvider + .get() + .reissue(request = ReissueRequest(oldRefreshToken)) + .requireData() + with(response) { + tokenProvider.updateTokens(accessToken = accessToken, refreshToken = refreshToken) + BearerTokens(accessToken = accessToken, refreshToken = refreshToken).also { client.clearAuthTokens() } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + tokenProvider.clearTokens() + client.clearAuthTokens() + globalEventBus.emit(GlobalEvent.ForceLogout) + null } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - tokenProvider.clearTokens() - client.clearAuthTokens() - globalEventBus.emit(GlobalEvent.ForceLogout) - null } - } - sendWithoutRequest { request -> - request.attributes.getOrNull(AuthRequestAttributes.SkipAuthKey) != true + sendWithoutRequest { request -> + request.attributes.getOrNull(AuthRequestAttributes.SkipAuthKey) != true + } } } } - } @Provides @Singleton - fun provideKtorfit( - httpClient: HttpClient, - ): Ktorfit = - Ktorfit.Builder() + fun provideKtorfit(httpClient: HttpClient): Ktorfit = + Ktorfit + .Builder() .baseUrl(BuildConfig.BASE_URL) .httpClient(httpClient) .build() diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt index ea4f63ab..06d29cbe 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt @@ -1,6 +1,5 @@ package com.team.prezel.core.network.model.auth.reissue -import com.team.prezel.core.model.auth.AuthTokens import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable From c023b0a0d946e89e616fdfedd6e07f66899d9775 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 02:41:36 +0900 Subject: [PATCH 49/63] =?UTF-8?q?refactor:=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9D=90=EB=A6=84=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20UI=20=EC=83=81=ED=83=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/team/prezel/core/auth/AuthManager.kt | 45 ++--- .../team/prezel/core/auth/KakaoAuthClient.kt | 157 +++++++++--------- .../team/prezel/core/auth/di/AuthModule.kt | 5 - .../prezel/core/auth/model/AuthProvider.kt | 5 - .../prezel/core/auth/model/AuthProviderKey.kt | 8 - .../team/prezel/core/auth/model/AuthResult.kt | 8 +- .../feature/login/impl/landing/LoginScreen.kt | 20 +-- .../login/impl/landing/LoginViewModel.kt | 53 ++---- .../impl/landing/contract/LoginUiEffect.kt | 5 +- .../impl/landing/contract/LoginUiIntent.kt | 6 +- .../impl/landing/contract/LoginUiState.kt | 2 - .../impl/landing/model/LoginUiMessage.kt | 1 - .../impl/src/main/res/values/strings.xml | 3 +- .../prezel/feature/my/impl/MyViewModel.kt | 32 ++-- 14 files changed, 129 insertions(+), 221 deletions(-) delete mode 100644 Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProvider.kt delete mode 100644 Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProviderKey.kt diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt index cbcc4ddb..e49db547 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt @@ -1,66 +1,47 @@ package com.team.prezel.core.auth import android.content.Context -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult import javax.inject.Inject import javax.inject.Singleton @Singleton class AuthManager @Inject constructor( - private val authClients: Map, + private val authClient: AuthClient, ) { - var currentProvider: AuthProvider? = null - private set + private var isLoggedInToOAuthProvider: Boolean = false - suspend fun login( - context: Context, - provider: AuthProvider, - ): AuthResult { - val authClient = authClients[provider] ?: return AuthResult.Failure.Unknown + suspend fun login(context: Context): AuthResult { val result = authClient.login(context = context) if (result is AuthResult.Success) { - currentProvider = provider + isLoggedInToOAuthProvider = true } return result } suspend fun logout(): Result { - val provider = - currentProvider ?: authClients.keys.singleOrNull() ?: return Result.failure( - IllegalStateException("로그인된 AuthProvider가 없습니다."), - ) - - val authClient = authClients[provider] ?: return Result.failure( - IllegalStateException("해당 AuthProvider에 대한 AuthClient를 찾을 수 없습니다. provider=$provider"), - ) + if (!isLoggedInToOAuthProvider) { + return Result.failure(IllegalStateException("로그인된 OAuth 세션이 없습니다.")) + } return authClient.logout().onSuccess { - currentProvider = null + isLoggedInToOAuthProvider = false } } - fun clearCurrentProvider() { - currentProvider = null + fun clearLoginState() { + isLoggedInToOAuthProvider = false } suspend fun clearAuthSession(): Result { - val provider = currentProvider ?: return Result.success(Unit) - val authClient = authClients[provider] - - if (authClient == null) { - currentProvider = null - return Result.failure( - IllegalStateException("해당 AuthProvider에 대한 AuthClient를 찾을 수 없습니다. provider=$provider"), - ) + if (!isLoggedInToOAuthProvider) { + return Result.success(Unit) } return authClient .logout() - .also { - currentProvider = null - } + .also { isLoggedInToOAuthProvider = false } } } diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt index d6da4eb7..0f5a4a58 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt @@ -14,100 +14,101 @@ import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.resume -class KakaoAuthClient - @Inject - constructor() : AuthClient { - override suspend fun login(context: Context): AuthResult = - suspendCancellableCoroutine { continuation -> - if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk(context = context, continuation = continuation) - return@suspendCancellableCoroutine - } - - loginWithKakaoAccount(context = context, continuation = continuation) +class KakaoAuthClient @Inject constructor() : AuthClient { + override suspend fun login(context: Context): AuthResult = + suspendCancellableCoroutine { continuation -> + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk(context = context, continuation = continuation) + return@suspendCancellableCoroutine } - override suspend fun logout(): Result = - suspendCancellableCoroutine { continuation -> - Timber.d("카카오 로그아웃 시도") + loginWithKakaoAccount(context = context, continuation = continuation) + } - UserApiClient.instance.logout { error -> - if (error != null) { - Timber.e(error, "카카오 로그아웃에 실패했습니다.") - continuation.resume(Result.failure(error)) - return@logout - } + override suspend fun logout(): Result = + suspendCancellableCoroutine { continuation -> + Timber.d("카카오 로그아웃 시도") - Timber.d("카카오 로그아웃에 성공했습니다.") - continuation.resume(Result.success(Unit)) + UserApiClient.instance.logout { error -> + if (error != null) { + Timber.e(error, "카카오 로그아웃에 실패했습니다.") + continuation.resume(Result.failure(error)) + return@logout } - } - private fun loginWithKakaoTalk( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오톡으로 로그인 시도") - UserApiClient.instance.loginWithKakaoTalk( - context = context, - callback = continuation.loginCallback(loginType = "카카오톡"), - ) + Timber.d("카카오 로그아웃에 성공했습니다.") + continuation.resume(Result.success(Unit)) + } } - private fun loginWithKakaoAccount( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오 계정으로 로그인 시도") - UserApiClient.instance.loginWithKakaoAccount( - context = context, - callback = continuation.loginCallback(loginType = "카카오 계정"), - ) - } + private fun loginWithKakaoTalk( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오톡으로 로그인 시도") + UserApiClient.instance.loginWithKakaoTalk( + context = context, + callback = continuation.loginCallback(loginType = "카카오톡"), + ) + } - private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = - { token, error -> - when { - error != null -> { - val authResult = error.toAuthResult() - Timber.e(error, "$loginType 로그인에 실패했습니다. ($authResult)") - resume(authResult) - } + private fun loginWithKakaoAccount( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오 계정으로 로그인 시도") + UserApiClient.instance.loginWithKakaoAccount( + context = context, + callback = continuation.loginCallback(loginType = "카카오 계정"), + ) + } - token != null -> { - val idToken = token.idToken - if (idToken.isNullOrBlank()) { - Timber.e("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") - resume(AuthResult.Failure.Unknown) - } else { - if (BuildConfig.DEBUG) { - Timber.tag("AuthToken").d("$loginType 로그인에 성공했습니다.") - } - resume(AuthResult.Success(idToken = idToken)) - } + private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = + { token, error -> + when { + error != null -> { + val authResult = error.toAuthResult() + when (authResult) { + AuthResult.Cancelled -> Timber.i(error, "$loginType 로그인이 취소되었습니다.") + is AuthResult.Failure -> Timber.w(error, "$loginType 로그인에 실패했습니다.") + is AuthResult.Success -> Unit } + resume(authResult) + } - else -> { - Timber.e("$loginType 로그인 결과가 비어있습니다.") - resume(AuthResult.Failure.Unknown) + token != null -> { + val idToken = token.idToken + if (idToken.isNullOrBlank()) { + val throwable = IllegalStateException("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") + Timber.w(throwable) + resume(AuthResult.Failure(throwable)) + } else { + Timber.d("$loginType 로그인에 성공했습니다.") + resume(AuthResult.Success(idToken = idToken)) } } - } - private fun Throwable.toAuthResult(): AuthResult { - if (this is AuthError) return toAuthErrorResult() - if (this is ClientError) return toClientErrorResult() - return AuthResult.Failure.Unknown + else -> { + val throwable = IllegalStateException("$loginType 로그인 결과가 비어있습니다.") + Timber.w(throwable) + resume(AuthResult.Failure(throwable)) + } + } } - private fun AuthError.toAuthErrorResult(): AuthResult { - if (statusCode == 429) return AuthResult.Failure.RateLimited - if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled - return AuthResult.Failure.Unknown - } + private fun Throwable.toAuthResult(): AuthResult { + if (this is AuthError) return toAuthErrorResult() + if (this is ClientError) return toClientErrorResult() + return AuthResult.Failure(this) + } - private fun ClientError.toClientErrorResult(): AuthResult { - if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled - return AuthResult.Failure.Unknown - } + private fun AuthError.toAuthErrorResult(): AuthResult { + if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled + return AuthResult.Failure(this) + } + + private fun ClientError.toClientErrorResult(): AuthResult { + if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled + return AuthResult.Failure(this) } +} diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt index b25e5c44..a98fb108 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt @@ -2,19 +2,14 @@ package com.team.prezel.core.auth.di import com.team.prezel.core.auth.AuthClient import com.team.prezel.core.auth.KakaoAuthClient -import com.team.prezel.core.auth.model.AuthProvider -import com.team.prezel.core.auth.model.AuthProviderKey import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoMap @Module @InstallIn(SingletonComponent::class) abstract class AuthModule { @Binds - @IntoMap - @AuthProviderKey(AuthProvider.KAKAO) abstract fun bindKakaoAuthClient(kakaoAuthClient: KakaoAuthClient): AuthClient } diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProvider.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProvider.kt deleted file mode 100644 index e553c24d..00000000 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.auth.model - -enum class AuthProvider { - KAKAO, -} diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProviderKey.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProviderKey.kt deleted file mode 100644 index 3076cfa0..00000000 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProviderKey.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.team.prezel.core.auth.model - -import dagger.MapKey - -@MapKey -internal annotation class AuthProviderKey( - val value: AuthProvider, -) diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt index 4ae9583c..108167c3 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt @@ -7,9 +7,7 @@ sealed interface AuthResult { data object Cancelled : AuthResult - sealed interface Failure : AuthResult { - data object Unknown : Failure - - data object RateLimited : Failure - } + data class Failure( + val throwable: Throwable, + ) : AuthResult } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index 1939c481..0c7fbec3 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -30,7 +30,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.team.prezel.core.auth.AuthManager -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize @@ -68,28 +67,19 @@ internal fun SharedTransitionScope.LoginScreen( LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { - is LoginUiEffect.LaunchLogin -> { - authManager.login(context = context, provider = effect.provider).also { result -> - viewModel.onIntent( - LoginUiIntent.OnLoginResult( - provider = effect.provider, - result = result, - ), - ) - } + LoginUiEffect.LaunchLogin -> { + val result = authManager.login(context = context) + viewModel.onIntent(LoginUiIntent.OnLoginResult(result = result)) } LoginUiEffect.NavigateToHome -> navigateToHome() LoginUiEffect.NavigateToTerms -> navigateToTerms() - LoginUiEffect.NavigateToHome -> navigateToHome() - is LoginUiEffect.ShowMessage -> { val resId = when (effect.message) { LoginUiMessage.LOGIN_CANCELLED -> R.string.feature_login_impl_kakao_cancelled - LoginUiMessage.LOGIN_FAILED_RATE_LIMITED -> R.string.feature_login_impl_kakao_rate_limited - LoginUiMessage.LOGIN_FAILED_UNKNOWN -> R.string.feature_login_impl_kakao_failure + LoginUiMessage.LOGIN_FAILED_UNKNOWN -> R.string.feature_login_impl_login_failed } snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) } @@ -100,7 +90,7 @@ internal fun SharedTransitionScope.LoginScreen( LoginScreen( uiState = uiState, animatedVisibilityScope = LocalNavAnimatedContentScope.current, - onLogin = { viewModel.onIntent(LoginUiIntent.OnClickLogin(provider = AuthProvider.KAKAO)) }, + onLogin = { viewModel.onIntent(LoginUiIntent.OnClickLogin) }, modifier = modifier, ) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 9702e613..4f71c871 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -1,7 +1,6 @@ package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.viewModelScope -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult import com.team.prezel.core.domain.usecase.auth.LoginUseCase import com.team.prezel.core.ui.base.BaseViewModel @@ -19,58 +18,34 @@ internal class LoginViewModel @Inject constructor( ) : BaseViewModel(LoginUiState()) { override fun onIntent(intent: LoginUiIntent) { when (intent) { - is LoginUiIntent.OnClickLogin -> handleClickLogin(provider = intent.provider) + LoginUiIntent.OnClickLogin -> handleClickLogin() is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) } } - private fun handleClickLogin(provider: AuthProvider) { + private fun handleClickLogin() { if (currentState.isLoading) return viewModelScope.launch { - updateState { - copy( - isLoading = true, - pendingProvider = provider, - ) - } - sendEffect(LoginUiEffect.LaunchLogin(provider = provider)) + updateState { copy(isLoading = true) } + sendEffect(LoginUiEffect.LaunchLogin) } } private fun handleLoginResult(result: AuthResult) { - viewModelScope.launch { - when (result) { - is AuthResult.Success -> handleServerLogin(idToken = result.idToken) - AuthResult.Cancelled -> { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_CANCELLED)) - } - - is AuthResult.Failure -> { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) + viewModelScope + .launch { + when (result) { + is AuthResult.Success -> handleServerLogin(idToken = result.idToken) + is AuthResult.Failure -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) + AuthResult.Cancelled -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_CANCELLED)) } - } - } + }.invokeOnCompletion { updateState { copy(isLoading = false) } } } private suspend fun handleServerLogin(idToken: String) { - loginUseCase(idToken = idToken).fold( - onSuccess = { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.NavigateToTerms) - }, - onFailure = { - updateState { copy(isLoading = false, pendingProvider = null) } - sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) - }, - ) + loginUseCase(idToken = idToken) + .onSuccess { sendEffect(LoginUiEffect.NavigateToTerms) } + .onFailure { sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) } } - - private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = - when (this) { - AuthResult.Failure.RateLimited -> LoginUiMessage.LOGIN_FAILED_RATE_LIMITED - AuthResult.Failure.Unknown -> LoginUiMessage.LOGIN_FAILED_UNKNOWN - } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index ac3de2c6..31053d7b 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -1,13 +1,10 @@ package com.team.prezel.feature.login.impl.landing.contract -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.ui.base.UiEffect import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage internal sealed interface LoginUiEffect : UiEffect { - data class LaunchLogin( - val provider: AuthProvider, - ) : LoginUiEffect + data object LaunchLogin : LoginUiEffect data object NavigateToHome : LoginUiEffect diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt index b1bed517..e4a7033d 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt @@ -1,16 +1,12 @@ package com.team.prezel.feature.login.impl.landing.contract -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult import com.team.prezel.core.ui.base.UiIntent internal sealed interface LoginUiIntent : UiIntent { - data class OnClickLogin( - val provider: AuthProvider, - ) : LoginUiIntent + data object OnClickLogin : LoginUiIntent data class OnLoginResult( - val provider: AuthProvider, val result: AuthResult, ) : LoginUiIntent } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt index 6adbca2f..543b16b0 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt @@ -1,11 +1,9 @@ package com.team.prezel.feature.login.impl.landing.contract import androidx.compose.runtime.Immutable -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.ui.base.UiState @Immutable internal data class LoginUiState( val isLoading: Boolean = false, - val pendingProvider: AuthProvider? = null, ) : UiState diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt index 8e95aaf3..eeb6fb09 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt @@ -2,6 +2,5 @@ package com.team.prezel.feature.login.impl.landing.model internal enum class LoginUiMessage { LOGIN_CANCELLED, - LOGIN_FAILED_RATE_LIMITED, LOGIN_FAILED_UNKNOWN, } diff --git a/Prezel/feature/login/impl/src/main/res/values/strings.xml b/Prezel/feature/login/impl/src/main/res/values/strings.xml index cab90d27..4bd1d55a 100644 --- a/Prezel/feature/login/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/login/impl/src/main/res/values/strings.xml @@ -1,8 +1,7 @@ - 카카오 로그인에 문제가 발생했어요.\n잠시 후 다시 시도해 주세요. - 로그인 시도가 너무 많아요.\n잠시 후 다시 시도해 주세요. + 로그인에 문제가 발생했어요.\n잠시 후 다시 시도해 주세요. 로그인이 취소되었어요. 카카오로 시작하기 뒤로가기 diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt index 73b8d3ce..17bc9c05 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -3,7 +3,6 @@ package com.team.prezel.feature.my.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.AuthManager -import com.team.prezel.core.domain.result.auth.AuthActionResult import com.team.prezel.core.domain.usecase.auth.LogoutUseCase import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase import com.team.prezel.core.model.auth.WithdrawReason @@ -39,7 +38,7 @@ internal class MyViewModel @Inject constructor( try { _uiState.update { it.copy(isLoading = true) } val result = logoutUseCase() - handleAuthActionResult( + handleResult( result = result, onSuccess = { authManager @@ -64,9 +63,9 @@ internal class MyViewModel @Inject constructor( try { _uiState.update { it.copy(isLoading = true) } val result = withdrawUseCase(reason = WithdrawReason.Other("임시 테스트 탈퇴")) - handleAuthActionResult( + handleResult( result = result, - onSuccess = { authManager.clearCurrentProvider() }, + onSuccess = { authManager.clearLoginState() }, failureLog = "회원탈퇴에 실패했습니다.", failureMessage = MyUiMessage.WITHDRAW_FAILED, ) @@ -76,28 +75,21 @@ internal class MyViewModel @Inject constructor( } } - private suspend fun handleAuthActionResult( - result: AuthActionResult, + private suspend fun handleResult( + result: Result, onSuccess: suspend () -> Unit, failureLog: String, failureMessage: MyUiMessage, ) { - when (result) { - AuthActionResult.Success -> { + result.fold( + onSuccess = { onSuccess() _uiEffect.emit(MyUiEffect.NavigateToLogin) - } - - AuthActionResult.AuthenticationRequired -> { - authManager.clearCurrentProvider() - _uiEffect.emit(MyUiEffect.ShowMessage(MyUiMessage.AUTHENTICATION_EXPIRED)) - _uiEffect.emit(MyUiEffect.NavigateToLogin) - } - - is AuthActionResult.Failure -> { - Timber.e(result.throwable, failureLog) + }, + onFailure = { throwable -> + Timber.e(throwable, failureLog) _uiEffect.emit(MyUiEffect.ShowMessage(failureMessage)) - } - } + }, + ) } } From bbc93ed04458fb0f9b27d40443daa27a5ac4314a Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 02:59:18 +0900 Subject: [PATCH 50/63] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=9A=A9=20Coroutin?= =?UTF-8?q?eScope=20=EB=AA=A8=EB=93=88=20=EC=9C=84=EC=B9=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Prezel/core/common/build.gradle.kts | 1 + .../core/common}/di/CoroutineScopesModule.kt | 2 +- Prezel/core/data/build.gradle.kts | 1 + .../data/repository/DefaultAuthRepository.kt | 2 +- Prezel/core/datastore/build.gradle.kts | 1 + .../core/datastore/di/DataStoreModule.kt | 1 + .../main/res/raw/core_ui_asset_loading.json | 7736 +++++++++++++++++ 7 files changed, 7742 insertions(+), 2 deletions(-) rename Prezel/core/{datastore/src/main/java/com/team/prezel/core/datastore => common/src/main/kotlin/com/team/prezel/core/common}/di/CoroutineScopesModule.kt (93%) create mode 100644 Prezel/core/ui/src/main/res/raw/core_ui_asset_loading.json diff --git a/Prezel/core/common/build.gradle.kts b/Prezel/core/common/build.gradle.kts index 6f626e78..b45fa124 100644 --- a/Prezel/core/common/build.gradle.kts +++ b/Prezel/core/common/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.prezel.jvm.library) + alias(libs.plugins.prezel.hilt) } dependencies { diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/CoroutineScopesModule.kt similarity index 93% rename from Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt rename to Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/CoroutineScopesModule.kt index 08cc9884..ea553131 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/CoroutineScopesModule.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/CoroutineScopesModule.kt @@ -1,4 +1,4 @@ -package com.team.prezel.core.datastore.di +package com.team.prezel.core.common.di import dagger.Module import dagger.Provides diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index 7b97b97a..210035fd 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -9,6 +9,7 @@ android { } dependencies { + implementation(projects.coreCommon) implementation(projects.coreDatastore) implementation(projects.coreDomain) implementation(projects.coreModel) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt index 6c3e93ad..78bcc855 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt @@ -1,7 +1,7 @@ package com.team.prezel.core.data.repository +import com.team.prezel.core.common.di.ApplicationScope import com.team.prezel.core.datastore.auth.AuthLocalDataSource -import com.team.prezel.core.datastore.di.ApplicationScope import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.model.auth.LoginStatus import com.team.prezel.core.model.auth.WithdrawReason diff --git a/Prezel/core/datastore/build.gradle.kts b/Prezel/core/datastore/build.gradle.kts index 8aadcd21..e9e38d8c 100644 --- a/Prezel/core/datastore/build.gradle.kts +++ b/Prezel/core/datastore/build.gradle.kts @@ -9,6 +9,7 @@ android { } dependencies { + implementation(projects.coreCommon) implementation(projects.coreModel) implementation(libs.androidx.datastore.preferences) diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt index f9f6703d..934d226e 100644 --- a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile +import com.team.prezel.core.common.di.ApplicationScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/Prezel/core/ui/src/main/res/raw/core_ui_asset_loading.json b/Prezel/core/ui/src/main/res/raw/core_ui_asset_loading.json new file mode 100644 index 00000000..08a64e99 --- /dev/null +++ b/Prezel/core/ui/src/main/res/raw/core_ui_asset_loading.json @@ -0,0 +1,7736 @@ +{ + "nm": "Main Scene", + "ddd": 0, + "h": 512, + "w": 512, + "meta": { + "g": "@lottiefiles/creator 1.74.0" + }, + "layers": [ + { + "ty": 4, + "nm": "4", + "sr": 1, + "st": 0, + "op": 900, + "ip": 0, + "hd": false, + "ln": "105", + "ddd": 0, + "bm": 0, + "hasMask": true, + "ao": 0, + "ks": { + "a": { + "a": 0, + "k": [ + 0, + 0 + ] + }, + "s": { + "a": 0, + "k": [ + 90, + 90 + ] + }, + "sk": { + "a": 0, + "k": 0 + }, + "p": { + "a": 0, + "k": [ + 228.88927430025083, + 252.32495060924165 + ] + }, + "r": { + "a": 0, + "k": 0 + }, + "sa": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + } + }, + "masksProperties": [ + { + "nm": "마스크 1", + "inv": false, + "mode": "a", + "x": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + }, + "pt": { + "a": 1, + "k": [ + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 52.547, + 20.325 + ], + [ + 67.429, + 164.044 + ], + [ + 264.399, + 389.639 + ] + ] + } + ], + "t": 34 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 330.612, + 13.539 + ], + [ + 41.642, + 31.944 + ], + [ + -17.476, + 149.663 + ], + [ + 267.494, + 387.258 + ] + ] + } + ], + "t": 35 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 52.547, + 12.325 + ], + [ + -116.571, + 42.044 + ], + [ + 264.399, + 389.639 + ] + ] + } + ], + "t": 36 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 104.547, + -5.675 + ], + [ + -145.707, + -61.779 + ], + [ + -99.601, + 261.639 + ] + ] + } + ], + "t": 37 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 104.547, + -5.675 + ], + [ + -93.884, + -217.827 + ], + [ + -99.601, + 261.639 + ] + ] + } + ], + "t": 38 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 162.547, + -63.675 + ], + [ + -60.571, + -221.956 + ], + [ + -99.601, + 261.639 + ] + ] + } + ], + "t": 39 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 162.547, + -63.675 + ], + [ + -8.571, + -277.956 + ], + [ + -205.601, + 209.639 + ] + ] + } + ], + "t": 40 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 162.547, + -63.675 + ], + [ + 117.429, + -355.956 + ], + [ + -267.601, + 13.639 + ] + ] + } + ], + "t": 41 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 196.547, + -63.675 + ], + [ + 199.429, + -365.956 + ], + [ + -267.601, + 13.639 + ] + ] + } + ], + "t": 42 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 226.547, + -63.675 + ], + [ + 277.429, + -365.956 + ], + [ + -267.601, + 13.639 + ] + ] + } + ], + "t": 43 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 226.547, + -63.675 + ], + [ + 277.429, + -365.956 + ], + [ + -267.601, + 13.639 + ] + ] + } + ], + "t": 55 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 196.547, + -63.675 + ], + [ + 199.429, + -365.956 + ], + [ + -267.601, + 13.639 + ] + ] + } + ], + "t": 56 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 162.547, + -63.675 + ], + [ + 117.429, + -355.956 + ], + [ + -267.601, + 13.639 + ] + ] + } + ], + "t": 57 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 325.517, + 223.92 + ], + [ + 162.547, + -63.675 + ], + [ + -8.571, + -277.956 + ], + [ + -205.601, + 209.639 + ] + ] + } + ], + "t": 58 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 162.547, + -63.675 + ], + [ + -60.571, + -221.956 + ], + [ + -99.601, + 261.639 + ] + ] + } + ], + "t": 59 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 104.547, + -5.675 + ], + [ + -93.884, + -217.827 + ], + [ + -99.601, + 261.639 + ] + ] + } + ], + "t": 60 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 104.547, + -5.675 + ], + [ + -145.707, + -61.779 + ], + [ + -99.601, + 261.639 + ] + ] + } + ], + "t": 61 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 52.547, + 12.325 + ], + [ + -116.571, + 42.044 + ], + [ + 264.399, + 389.639 + ] + ] + } + ], + "t": 62 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 330.612, + 13.539 + ], + [ + 41.642, + 31.944 + ], + [ + -17.476, + 149.663 + ], + [ + 267.494, + 387.258 + ] + ] + } + ], + "t": 62.999 + }, + { + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 327.517, + 15.92 + ], + [ + 52.547, + 20.325 + ], + [ + 67.429, + 164.044 + ], + [ + 264.399, + 389.639 + ] + ] + } + ], + "t": 63.999 + } + ] + } + } + ], + "shapes": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "패스 1", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "i": [ + [ + 84.484, + -2.099 + ], + [ + -179.956, + -5.24 + ] + ], + "o": [ + [ + -96.548, + 2.399 + ], + [ + 42.7, + 1.243 + ] + ], + "v": [ + [ + 38.121, + 66.162 + ], + [ + 212.498, + -182.025 + ] + ] + } + } + }, + { + "ty": "gs", + "bm": 0, + "hd": false, + "nm": "그라디언트 선 1", + "e": { + "a": 0, + "k": [ + 199, + 0 + ] + }, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.3803921568627451, + 0.596078431372549, + 1, + 0.5, + 0.27058823529411763, + 0.5254901960784314, + 1, + 1, + 0.1607843137254902, + 0.4549019607843137, + 1 + ] + } + }, + "t": 1, + "a": { + "a": 0, + "k": 0 + }, + "h": { + "a": 0, + "k": 0 + }, + "s": { + "a": 0, + "k": [ + 31, + 41 + ] + }, + "lc": 1, + "lj": 2, + "ml": 4, + "o": { + "a": 0, + "k": 100 + }, + "w": { + "a": 0, + "k": 72 + } + } + ], + "ind": 1 + }, + { + "ty": 4, + "nm": "1", + "sr": 1, + "st": 0, + "op": 900, + "ip": 0, + "hd": false, + "ln": "106", + "ddd": 0, + "bm": 0, + "hasMask": true, + "ao": 0, + "ks": { + "a": { + "a": 0, + "k": [ + 0, + 0 + ] + }, + "s": { + "a": 0, + "k": [ + 90, + 90 + ] + }, + "sk": { + "a": 0, + "k": 0 + }, + "p": { + "a": 0, + "k": [ + 228.88927430025083, + 252.32495060924165 + ] + }, + "r": { + "a": 0, + "k": 0 + }, + "sa": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + } + }, + "masksProperties": [ + { + "nm": "마스크 1", + "inv": false, + "mode": "a", + "x": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + }, + "pt": { + "a": 1, + "k": [ + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -414.016, + 117.503 + ], + [ + -483.194, + 284.263 + ], + [ + -107.086, + 440.285 + ], + [ + -37.908, + 273.525 + ] + ] + } + ], + "t": 0 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -375.893, + 47.367 + ], + [ + -456.617, + 207.775 + ], + [ + -94.834, + 389.838 + ], + [ + -14.111, + 229.43 + ] + ] + } + ], + "t": 1 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -347.794, + 0.143 + ], + [ + -440.063, + 154.2 + ], + [ + -92.606, + 362.302 + ], + [ + -0.337, + 208.246 + ] + ] + } + ], + "t": 2 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -319.695, + -47.081 + ], + [ + -423.51, + 100.624 + ], + [ + -90.378, + 334.767 + ], + [ + 13.437, + 187.063 + ] + ] + } + ], + "t": 3 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -230.436, + -119.867 + ], + [ + -394.991, + -5.526 + ], + [ + -130.743, + 366.679 + ], + [ + 33.812, + 252.339 + ] + ] + } + ], + "t": 4 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -141.178, + -192.653 + ], + [ + -366.473, + -111.676 + ], + [ + -171.107, + 398.591 + ], + [ + 54.188, + 317.616 + ] + ] + } + ], + "t": 5 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -51.919, + -265.439 + ], + [ + -337.954, + -217.825 + ], + [ + -211.471, + 430.504 + ], + [ + 74.563, + 382.892 + ] + ] + } + ], + "t": 6 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 46.081, + -268.439 + ], + [ + -387.954, + -274.825 + ], + [ + -261.471, + 373.504 + ], + [ + 74.563, + 382.892 + ] + ] + } + ], + "t": 7 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 144.081, + -271.439 + ], + [ + -437.954, + -331.825 + ], + [ + -311.471, + 316.504 + ], + [ + 74.563, + 382.892 + ] + ] + } + ], + "t": 8 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 209.081, + -182.439 + ], + [ + -437.954, + -331.825 + ], + [ + -311.471, + 316.504 + ], + [ + 43.563, + 320.892 + ] + ] + } + ], + "t": 9 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 274.081, + -93.439 + ], + [ + -437.954, + -331.825 + ], + [ + -311.471, + 316.504 + ], + [ + 12.563, + 258.892 + ] + ] + } + ], + "t": 10 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 318.747, + 28.561 + ], + [ + -364.621, + -328.492 + ], + [ + -311.471, + 316.504 + ], + [ + 51.23, + 110.103 + ] + ] + } + ], + "t": 11 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 390.081, + 122.561 + ], + [ + -217.954, + -321.825 + ], + [ + -311.471, + 316.504 + ], + [ + 62.563, + 116.892 + ] + ] + } + ], + "t": 12 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 390.081, + 122.561 + ], + [ + -217.954, + -321.825 + ], + [ + -311.471, + 316.504 + ], + [ + 62.563, + 116.892 + ] + ] + } + ], + "t": 85.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 318.747, + 28.561 + ], + [ + -364.621, + -328.492 + ], + [ + -311.471, + 316.504 + ], + [ + 51.23, + 110.103 + ] + ] + } + ], + "t": 86.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 274.081, + -93.439 + ], + [ + -437.954, + -331.825 + ], + [ + -311.471, + 316.504 + ], + [ + 12.563, + 258.892 + ] + ] + } + ], + "t": 87.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 209.081, + -182.439 + ], + [ + -437.954, + -331.825 + ], + [ + -311.471, + 316.504 + ], + [ + 43.563, + 320.892 + ] + ] + } + ], + "t": 88.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 144.081, + -271.439 + ], + [ + -437.954, + -331.825 + ], + [ + -311.471, + 316.504 + ], + [ + 74.563, + 382.892 + ] + ] + } + ], + "t": 89.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 46.081, + -268.439 + ], + [ + -387.954, + -274.825 + ], + [ + -261.471, + 373.504 + ], + [ + 74.563, + 382.892 + ] + ] + } + ], + "t": 90.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -51.919, + -265.439 + ], + [ + -337.954, + -217.825 + ], + [ + -211.471, + 430.504 + ], + [ + 74.563, + 382.892 + ] + ] + } + ], + "t": 91.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -141.178, + -192.653 + ], + [ + -366.473, + -111.676 + ], + [ + -171.107, + 398.591 + ], + [ + 54.188, + 317.616 + ] + ] + } + ], + "t": 92.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -230.436, + -119.867 + ], + [ + -394.991, + -5.526 + ], + [ + -130.743, + 366.679 + ], + [ + 33.812, + 252.339 + ] + ] + } + ], + "t": 93.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -319.695, + -47.081 + ], + [ + -423.51, + 100.624 + ], + [ + -90.378, + 334.767 + ], + [ + 13.437, + 187.063 + ] + ] + } + ], + "t": 94.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -347.794, + 0.143 + ], + [ + -440.063, + 154.2 + ], + [ + -92.606, + 362.302 + ], + [ + -0.337, + 208.246 + ] + ] + } + ], + "t": 95.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -375.893, + 47.367 + ], + [ + -456.617, + 207.775 + ], + [ + -94.834, + 389.838 + ], + [ + -14.111, + 229.43 + ] + ] + } + ], + "t": 96.999 + }, + { + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -414.016, + 117.503 + ], + [ + -483.194, + 284.263 + ], + [ + -107.086, + 440.285 + ], + [ + -37.908, + 273.525 + ] + ] + } + ], + "t": 97.999 + } + ] + } + } + ], + "shapes": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "패스 1", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "i": [ + [ + 0, + 0 + ], + [ + -177.567, + 1.261 + ], + [ + 1.76, + -43.361 + ] + ], + "o": [ + [ + 18.785, + -42.102 + ], + [ + 110.867, + -0.788 + ], + [ + -1.696, + 41.808 + ] + ], + "v": [ + [ + -207.626, + 178.043 + ], + [ + 37.668, + -2.485 + ], + [ + 199.517, + 83.773 + ] + ] + } + } + }, + { + "ty": "gs", + "bm": 0, + "hd": false, + "nm": "그라디언트 선 1", + "e": { + "a": 0, + "k": [ + 100, + 0 + ] + }, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.9450980392156862, + 0.9647058823529412, + 1, + 0.487, + 0.8235294117647058, + 0.8823529411764706, + 1, + 1, + 0.6980392156862745, + 0.803921568627451, + 1 + ] + } + }, + "t": 1, + "a": { + "a": 0, + "k": 0 + }, + "h": { + "a": 0, + "k": 0 + }, + "s": { + "a": 0, + "k": [ + 0, + 0 + ] + }, + "lc": 1, + "lj": 2, + "ml": 4, + "o": { + "a": 0, + "k": 100 + }, + "w": { + "a": 0, + "k": 72 + } + } + ], + "ind": 2 + }, + { + "ty": 4, + "nm": "2", + "sr": 1, + "st": 0, + "op": 900, + "ip": 0, + "hd": false, + "ln": "107", + "ddd": 0, + "bm": 0, + "hasMask": true, + "ao": 0, + "ks": { + "a": { + "a": 0, + "k": [ + 0, + 0 + ] + }, + "s": { + "a": 0, + "k": [ + 90, + 90 + ] + }, + "sk": { + "a": 0, + "k": 0 + }, + "p": { + "a": 0, + "k": [ + 228.88927430025083, + 252.32495060924165 + ] + }, + "r": { + "a": 0, + "k": 0 + }, + "sa": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + } + }, + "masksProperties": [ + { + "nm": "마스크 1", + "inv": false, + "mode": "a", + "x": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + }, + "pt": { + "a": 1, + "k": [ + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + -10.906, + 64.325 + ], + [ + 395.976, + 80.044 + ], + [ + 401.649, + -92.361 + ] + ] + } + ], + "t": 11 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -96.08 + ], + [ + -11.453, + 84.325 + ], + [ + 395.429, + 130.115 + ], + [ + 402.399, + -80.361 + ] + ] + } + ], + "t": 12 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 354.517, + 205.547 + ], + [ + 402.399, + -92.361 + ] + ] + } + ], + "t": 13 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 277.429, + 274.044 + ], + [ + 402.399, + -92.361 + ] + ] + } + ], + "t": 14 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 193.004, + 321.677 + ], + [ + 402.399, + -92.361 + ] + ] + } + ], + "t": 15 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 77.217, + 373.86 + ], + [ + 519.399, + -40.361 + ] + ] + } + ], + "t": 16 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + -38.571, + 426.044 + ], + [ + 636.399, + 11.639 + ] + ] + } + ], + "t": 17 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 169.517, + -183.08 + ], + [ + 40.547, + 90.325 + ], + [ + -177.571, + 422.044 + ], + [ + 648.399, + 82.639 + ] + ] + } + ], + "t": 18 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 40.547, + 90.325 + ], + [ + -316.571, + 418.044 + ], + [ + 660.399, + 153.639 + ] + ] + } + ], + "t": 19 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 40.547, + 90.325 + ], + [ + -362.571, + 342.044 + ], + [ + 533.399, + 299.639 + ] + ] + } + ], + "t": 20 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 40.547, + 90.325 + ], + [ + -408.571, + 266.044 + ], + [ + 406.399, + 445.639 + ] + ] + } + ], + "t": 21 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 50.547, + 24.325 + ], + [ + -502.571, + 180.044 + ], + [ + 406.399, + 445.639 + ] + ] + } + ], + "t": 22 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 41.547, + -23.675 + ], + [ + -520.071, + 105.544 + ], + [ + 354.399, + 464.139 + ] + ] + } + ], + "t": 23 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 32.547, + -71.675 + ], + [ + -537.571, + 31.044 + ], + [ + 302.399, + 482.639 + ] + ] + } + ], + "t": 24 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 50.547, + -47.675 + ], + [ + -572.571, + -117.956 + ], + [ + 198.399, + 519.639 + ] + ] + } + ], + "t": 25 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 50.547, + -47.675 + ], + [ + -572.571, + -117.956 + ], + [ + 198.399, + 519.639 + ] + ] + } + ], + "t": 72.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 32.547, + -71.675 + ], + [ + -537.571, + 31.044 + ], + [ + 302.399, + 482.639 + ] + ] + } + ], + "t": 73.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 41.547, + -23.675 + ], + [ + -520.071, + 105.544 + ], + [ + 354.399, + 464.139 + ] + ] + } + ], + "t": 74.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 50.547, + 24.325 + ], + [ + -502.571, + 180.044 + ], + [ + 406.399, + 445.639 + ] + ] + } + ], + "t": 75.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 40.547, + 90.325 + ], + [ + -408.571, + 266.044 + ], + [ + 406.399, + 445.639 + ] + ] + } + ], + "t": 76.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 40.547, + 90.325 + ], + [ + -362.571, + 342.044 + ], + [ + 533.399, + 299.639 + ] + ] + } + ], + "t": 77.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 343.517, + -258.08 + ], + [ + 40.547, + 90.325 + ], + [ + -316.571, + 418.044 + ], + [ + 660.399, + 153.639 + ] + ] + } + ], + "t": 78.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 169.517, + -183.08 + ], + [ + 40.547, + 90.325 + ], + [ + -177.571, + 422.044 + ], + [ + 648.399, + 82.639 + ] + ] + } + ], + "t": 79.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + -38.571, + 426.044 + ], + [ + 636.399, + 11.639 + ] + ] + } + ], + "t": 80.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 77.217, + 373.86 + ], + [ + 519.399, + -40.361 + ] + ] + } + ], + "t": 81.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 193.004, + 321.677 + ], + [ + 402.399, + -92.361 + ] + ] + } + ], + "t": 82.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 277.429, + 274.044 + ], + [ + 402.399, + -92.361 + ] + ] + } + ], + "t": 83.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + 40.547, + 90.325 + ], + [ + 354.517, + 205.547 + ], + [ + 402.399, + -92.361 + ] + ] + } + ], + "t": 84.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -96.08 + ], + [ + -11.453, + 84.325 + ], + [ + 395.429, + 130.115 + ], + [ + 402.399, + -80.361 + ] + ] + } + ], + "t": 85.999 + }, + { + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -4.483, + -108.08 + ], + [ + -10.906, + 64.325 + ], + [ + 395.976, + 80.044 + ], + [ + 401.649, + -92.361 + ] + ] + } + ], + "t": 86.999 + } + ] + } + } + ], + "shapes": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "패스 1", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "i": [ + [ + 1.759, + -43.361 + ], + [ + 114.183, + -0.407 + ], + [ + -0.292, + 147.305 + ] + ], + "o": [ + [ + -1.697, + 41.808 + ], + [ + -86.495, + 0.309 + ], + [ + 0.258, + -130.016 + ] + ], + "v": [ + [ + 199.441, + 83.097 + ], + [ + 33.67, + 190.857 + ], + [ + -178.61, + -49.564 + ] + ] + } + } + }, + { + "ty": "gs", + "bm": 0, + "hd": false, + "nm": "그라디언트 선 1", + "e": { + "a": 0, + "k": [ + 100, + 0 + ] + }, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.5607843137254902, + 0.7058823529411765, + 0.984313725490196, + 0.5, + 0.6313725490196078, + 0.7568627450980392, + 0.9921568627450981, + 1, + 0.7058823529411765, + 0.807843137254902, + 1 + ] + } + }, + "t": 1, + "a": { + "a": 0, + "k": 0 + }, + "h": { + "a": 0, + "k": 0 + }, + "s": { + "a": 0, + "k": [ + 0, + 0 + ] + }, + "lc": 1, + "lj": 2, + "ml": 4, + "o": { + "a": 0, + "k": 100 + }, + "w": { + "a": 0, + "k": 72 + } + } + ], + "ind": 3 + }, + { + "ty": 4, + "nm": "3", + "sr": 1, + "st": 0, + "op": 900, + "ip": 0, + "hd": false, + "ln": "108", + "ddd": 0, + "bm": 0, + "hasMask": true, + "ao": 0, + "ks": { + "a": { + "a": 0, + "k": [ + 0, + 0 + ] + }, + "s": { + "a": 0, + "k": [ + 90, + 90 + ] + }, + "sk": { + "a": 0, + "k": 0 + }, + "p": { + "a": 0, + "k": [ + 228.88927430025083, + 252.32495060924165 + ] + }, + "r": { + "a": 0, + "k": 0 + }, + "sa": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + } + }, + "masksProperties": [ + { + "nm": "마스크 1", + "inv": false, + "mode": "a", + "x": { + "a": 0, + "k": 0 + }, + "o": { + "a": 0, + "k": 100 + }, + "pt": { + "a": 1, + "k": [ + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -10.483, + 141.92 + ], + [ + -9.453, + 2.325 + ], + [ + -566.571, + -61.956 + ], + [ + 198.399, + 519.639 + ] + ] + } + ], + "t": 24 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -10.483, + 141.92 + ], + [ + -11.453, + -39.675 + ], + [ + -564.275, + -169.827 + ], + [ + 198.399, + 519.639 + ] + ] + } + ], + "t": 25 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -10.483, + 141.92 + ], + [ + -15.453, + -53.675 + ], + [ + -470.571, + -259.956 + ], + [ + -485.601, + -14.361 + ] + ] + } + ], + "t": 26 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -38.483, + 163.92 + ], + [ + -43.453, + -31.675 + ], + [ + -288.571, + -397.956 + ], + [ + -513.601, + 7.639 + ] + ] + } + ], + "t": 27 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -89.453, + -51.675 + ], + [ + 43.429, + -399.956 + ], + [ + -533.601, + -240.361 + ] + ] + } + ], + "t": 28 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -89.453, + -51.675 + ], + [ + 187.429, + -255.956 + ], + [ + -291.601, + -422.361 + ] + ] + } + ], + "t": 29 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -89.453, + -51.675 + ], + [ + 201.429, + -133.956 + ], + [ + -133.601, + -436.361 + ] + ] + } + ], + "t": 30 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -79.453, + -41.675 + ], + [ + 207.429, + -65.956 + ], + [ + -133.601, + -436.361 + ] + ] + } + ], + "t": 31 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -41.453, + 8.325 + ], + [ + 213.429, + 10.044 + ], + [ + -133.601, + -436.361 + ] + ] + } + ], + "t": 32 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -500.483, + -72.08 + ], + [ + -41.453, + 8.325 + ], + [ + 195.429, + 88.044 + ], + [ + 148.399, + -442.361 + ] + ] + } + ], + "t": 33 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -694.483, + -18.08 + ], + [ + 30.547, + -25.675 + ], + [ + 67.429, + 164.044 + ], + [ + 318.399, + -408.361 + ] + ] + } + ], + "t": 34 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -722.483, + 9.92 + ], + [ + 30.547, + -25.675 + ], + [ + -24.571, + 266.044 + ], + [ + 550.399, + -510.361 + ] + ] + } + ], + "t": 35 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -722.483, + 9.92 + ], + [ + 30.547, + -25.675 + ], + [ + -24.571, + 266.044 + ], + [ + 550.399, + -510.361 + ] + ] + } + ], + "t": 62.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -694.483, + -18.08 + ], + [ + 30.547, + -25.675 + ], + [ + 67.429, + 164.044 + ], + [ + 318.399, + -408.361 + ] + ] + } + ], + "t": 63.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -500.483, + -72.08 + ], + [ + -41.453, + 8.325 + ], + [ + 195.429, + 88.044 + ], + [ + 148.399, + -442.361 + ] + ] + } + ], + "t": 64.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -41.453, + 8.325 + ], + [ + 213.429, + 10.044 + ], + [ + -133.601, + -436.361 + ] + ] + } + ], + "t": 65.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -79.453, + -41.675 + ], + [ + 207.429, + -65.956 + ], + [ + -133.601, + -436.361 + ] + ] + } + ], + "t": 66.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -89.453, + -51.675 + ], + [ + 201.429, + -133.956 + ], + [ + -133.601, + -436.361 + ] + ] + } + ], + "t": 67.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -89.453, + -51.675 + ], + [ + 187.429, + -255.956 + ], + [ + -291.601, + -422.361 + ] + ] + } + ], + "t": 68.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -378.483, + 93.92 + ], + [ + -89.453, + -51.675 + ], + [ + 43.429, + -399.956 + ], + [ + -533.601, + -240.361 + ] + ] + } + ], + "t": 69.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -38.483, + 163.92 + ], + [ + -43.453, + -31.675 + ], + [ + -288.571, + -397.956 + ], + [ + -513.601, + 7.639 + ] + ] + } + ], + "t": 70.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -10.483, + 141.92 + ], + [ + -15.453, + -53.675 + ], + [ + -470.571, + -259.956 + ], + [ + -485.601, + -14.361 + ] + ] + } + ], + "t": 71.999 + }, + { + "o": { + "x": 0.167, + "y": 0.167 + }, + "i": { + "x": 0.833, + "y": 0.833 + }, + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -10.483, + 141.92 + ], + [ + -11.453, + -39.675 + ], + [ + -564.275, + -169.827 + ], + [ + 198.399, + 519.639 + ] + ] + } + ], + "t": 72.999 + }, + { + "s": [ + { + "c": true, + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + -10.483, + 141.92 + ], + [ + -9.453, + 2.325 + ], + [ + -566.571, + -61.956 + ], + [ + 198.399, + 519.639 + ] + ] + } + ], + "t": 73.999 + } + ] + } + } + ], + "shapes": [ + { + "ty": "sh", + "bm": 0, + "hd": false, + "nm": "패스 1", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "i": [ + [ + -0.292, + 147.305 + ], + [ + -1.047, + 0.003 + ], + [ + 84.495, + -1.63 + ] + ], + "o": [ + [ + 0.258, + -130.016 + ], + [ + 108.101, + -0.344 + ], + [ + -83.334, + 1.607 + ] + ], + "v": [ + [ + -178.61, + -48.814 + ], + [ + -77.448, + -180.221 + ], + [ + 37.118, + 66.162 + ] + ] + } + } + }, + { + "ty": "gs", + "bm": 0, + "hd": false, + "nm": "그라디언트 선 1", + "e": { + "a": 0, + "k": [ + 38, + 31 + ] + }, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.6588235294117647, + 0.7764705882352941, + 1, + 0.5, + 0.5176470588235295, + 0.6862745098039216, + 1, + 1, + 0.3803921568627451, + 0.596078431372549, + 1 + ] + } + }, + "t": 1, + "a": { + "a": 0, + "k": 0 + }, + "h": { + "a": 0, + "k": 0 + }, + "s": { + "a": 0, + "k": [ + -99, + -212 + ] + }, + "lc": 1, + "lj": 2, + "ml": 4, + "o": { + "a": 0, + "k": 100 + }, + "w": { + "a": 0, + "k": 72 + } + } + ], + "ind": 4 + } + ], + "v": "5.7.0", + "fr": 30, + "op": 120, + "ip": 0, + "assets": [] +} From f62403fa50da59e4c7c680e42bcad9bc43ce3e36 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 02:59:47 +0900 Subject: [PATCH 51/63] =?UTF-8?q?refactor:=20ForceLogout=20=ED=9B=84=20Spl?= =?UTF-8?q?ash=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=B0=8F=20=EC=95=B1=20?= =?UTF-8?q?=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index c0a586b7..8a419d76 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -20,12 +20,13 @@ import androidx.navigation3.ui.NavDisplay import com.team.prezel.core.common.event.GlobalEvent import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold +import com.team.prezel.core.designsystem.component.PrezelNavigationScope import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope import com.team.prezel.core.navigation.toEntries import com.team.prezel.core.ui.state.LocalSnackbarHostState -import com.team.prezel.feature.login.api.LoginNavKey +import com.team.prezel.feature.splash.api.SplashNavKey import com.team.prezel.navigation.MAIN_NAV_ITEMS import kotlinx.collections.immutable.ImmutableSet @@ -59,16 +60,11 @@ private fun PrezelAppContent( entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = LocalNavigator.current - val snackbarHostState = LocalSnackbarHostState.current - val showNavigationBar = appState.shouldShowNavigationBar - LaunchedEffect(globalEventBus, navigator) { - globalEventBus.events.collect { event -> - when (event) { - GlobalEvent.ForceLogout -> navigator.replaceRoot(LoginNavKey) - } - } - } + ObserveGlobalEvents( + globalEventBus = globalEventBus, + navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, + ) SharedTransitionLayout { ProvideSharedTransitionScope(this@SharedTransitionLayout) { @@ -79,18 +75,9 @@ private fun PrezelAppContent( } PrezelNavigationScaffold( - showNavigationBar = showNavigationBar, - snackbarHostState = snackbarHostState, - navigationItems = { - MAIN_NAV_ITEMS.forEach { (key, item) -> - Item( - selected = key == appState.navigationState.currentTopLevelKey, - onClick = { navigator.navigate(key) }, - label = stringResource(item.titleTextId), - iconResId = item.iconRes, - ) - } - }, + showNavigationBar = appState.shouldShowNavigationBar, + snackbarHostState = LocalSnackbarHostState.current, + navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, ) { padding -> NavDisplay( entries = appState.navigationState.toEntries(provider), @@ -113,3 +100,32 @@ private fun PrezelAppContent( } } } + +@Composable +private fun ObserveGlobalEvents( + globalEventBus: GlobalEventBus, + navigateToSplash: () -> Unit, +) { + LaunchedEffect(globalEventBus) { + globalEventBus.events.collect { event -> + when (event) { + GlobalEvent.ForceLogout -> navigateToSplash() + } + } + } +} + +@Composable +private fun PrezelNavigationScope.AppNavigationItems( + appState: PrezelAppState, + navigateToKey: (NavKey) -> Unit, +) { + MAIN_NAV_ITEMS.forEach { (key, item) -> + Item( + selected = key == appState.navigationState.currentTopLevelKey, + onClick = { navigateToKey(key) }, + label = stringResource(item.titleTextId), + iconResId = item.iconRes, + ) + } +} From de12d44834919aff63bf13e0c65fdbbc99945d1b Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 03:00:01 +0900 Subject: [PATCH 52/63] =?UTF-8?q?refactor:=20core-ui=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EC=95=A0=EC=85=8B=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20prefix=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prezel/core/ui/component/PrezelLottie.kt | 2 +- .../ui/src/main/res/raw/asset_loading.json | 7736 ----------------- 2 files changed, 1 insertion(+), 7737 deletions(-) delete mode 100644 Prezel/core/ui/src/main/res/raw/asset_loading.json diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt index c4f3d6a8..71708487 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt @@ -54,7 +54,7 @@ fun PrezelLottie( private fun PrezelLottiePreview() { PrezelTheme { PrezelLottie( - resId = R.raw.asset_loading, + resId = R.raw.core_ui_asset_loading, iterations = LottieConstants.IterateForever, ) } diff --git a/Prezel/core/ui/src/main/res/raw/asset_loading.json b/Prezel/core/ui/src/main/res/raw/asset_loading.json deleted file mode 100644 index 08a64e99..00000000 --- a/Prezel/core/ui/src/main/res/raw/asset_loading.json +++ /dev/null @@ -1,7736 +0,0 @@ -{ - "nm": "Main Scene", - "ddd": 0, - "h": 512, - "w": 512, - "meta": { - "g": "@lottiefiles/creator 1.74.0" - }, - "layers": [ - { - "ty": 4, - "nm": "4", - "sr": 1, - "st": 0, - "op": 900, - "ip": 0, - "hd": false, - "ln": "105", - "ddd": 0, - "bm": 0, - "hasMask": true, - "ao": 0, - "ks": { - "a": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 90, - 90 - ] - }, - "sk": { - "a": 0, - "k": 0 - }, - "p": { - "a": 0, - "k": [ - 228.88927430025083, - 252.32495060924165 - ] - }, - "r": { - "a": 0, - "k": 0 - }, - "sa": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - } - }, - "masksProperties": [ - { - "nm": "마스크 1", - "inv": false, - "mode": "a", - "x": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - }, - "pt": { - "a": 1, - "k": [ - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 52.547, - 20.325 - ], - [ - 67.429, - 164.044 - ], - [ - 264.399, - 389.639 - ] - ] - } - ], - "t": 34 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 330.612, - 13.539 - ], - [ - 41.642, - 31.944 - ], - [ - -17.476, - 149.663 - ], - [ - 267.494, - 387.258 - ] - ] - } - ], - "t": 35 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 52.547, - 12.325 - ], - [ - -116.571, - 42.044 - ], - [ - 264.399, - 389.639 - ] - ] - } - ], - "t": 36 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 104.547, - -5.675 - ], - [ - -145.707, - -61.779 - ], - [ - -99.601, - 261.639 - ] - ] - } - ], - "t": 37 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 104.547, - -5.675 - ], - [ - -93.884, - -217.827 - ], - [ - -99.601, - 261.639 - ] - ] - } - ], - "t": 38 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 162.547, - -63.675 - ], - [ - -60.571, - -221.956 - ], - [ - -99.601, - 261.639 - ] - ] - } - ], - "t": 39 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 162.547, - -63.675 - ], - [ - -8.571, - -277.956 - ], - [ - -205.601, - 209.639 - ] - ] - } - ], - "t": 40 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 162.547, - -63.675 - ], - [ - 117.429, - -355.956 - ], - [ - -267.601, - 13.639 - ] - ] - } - ], - "t": 41 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 196.547, - -63.675 - ], - [ - 199.429, - -365.956 - ], - [ - -267.601, - 13.639 - ] - ] - } - ], - "t": 42 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 226.547, - -63.675 - ], - [ - 277.429, - -365.956 - ], - [ - -267.601, - 13.639 - ] - ] - } - ], - "t": 43 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 226.547, - -63.675 - ], - [ - 277.429, - -365.956 - ], - [ - -267.601, - 13.639 - ] - ] - } - ], - "t": 55 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 196.547, - -63.675 - ], - [ - 199.429, - -365.956 - ], - [ - -267.601, - 13.639 - ] - ] - } - ], - "t": 56 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 162.547, - -63.675 - ], - [ - 117.429, - -355.956 - ], - [ - -267.601, - 13.639 - ] - ] - } - ], - "t": 57 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 325.517, - 223.92 - ], - [ - 162.547, - -63.675 - ], - [ - -8.571, - -277.956 - ], - [ - -205.601, - 209.639 - ] - ] - } - ], - "t": 58 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 162.547, - -63.675 - ], - [ - -60.571, - -221.956 - ], - [ - -99.601, - 261.639 - ] - ] - } - ], - "t": 59 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 104.547, - -5.675 - ], - [ - -93.884, - -217.827 - ], - [ - -99.601, - 261.639 - ] - ] - } - ], - "t": 60 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 104.547, - -5.675 - ], - [ - -145.707, - -61.779 - ], - [ - -99.601, - 261.639 - ] - ] - } - ], - "t": 61 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 52.547, - 12.325 - ], - [ - -116.571, - 42.044 - ], - [ - 264.399, - 389.639 - ] - ] - } - ], - "t": 62 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 330.612, - 13.539 - ], - [ - 41.642, - 31.944 - ], - [ - -17.476, - 149.663 - ], - [ - 267.494, - 387.258 - ] - ] - } - ], - "t": 62.999 - }, - { - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 327.517, - 15.92 - ], - [ - 52.547, - 20.325 - ], - [ - 67.429, - 164.044 - ], - [ - 264.399, - 389.639 - ] - ] - } - ], - "t": 63.999 - } - ] - } - } - ], - "shapes": [ - { - "ty": "sh", - "bm": 0, - "hd": false, - "nm": "패스 1", - "d": 1, - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [ - 84.484, - -2.099 - ], - [ - -179.956, - -5.24 - ] - ], - "o": [ - [ - -96.548, - 2.399 - ], - [ - 42.7, - 1.243 - ] - ], - "v": [ - [ - 38.121, - 66.162 - ], - [ - 212.498, - -182.025 - ] - ] - } - } - }, - { - "ty": "gs", - "bm": 0, - "hd": false, - "nm": "그라디언트 선 1", - "e": { - "a": 0, - "k": [ - 199, - 0 - ] - }, - "g": { - "p": 3, - "k": { - "a": 0, - "k": [ - 0, - 0.3803921568627451, - 0.596078431372549, - 1, - 0.5, - 0.27058823529411763, - 0.5254901960784314, - 1, - 1, - 0.1607843137254902, - 0.4549019607843137, - 1 - ] - } - }, - "t": 1, - "a": { - "a": 0, - "k": 0 - }, - "h": { - "a": 0, - "k": 0 - }, - "s": { - "a": 0, - "k": [ - 31, - 41 - ] - }, - "lc": 1, - "lj": 2, - "ml": 4, - "o": { - "a": 0, - "k": 100 - }, - "w": { - "a": 0, - "k": 72 - } - } - ], - "ind": 1 - }, - { - "ty": 4, - "nm": "1", - "sr": 1, - "st": 0, - "op": 900, - "ip": 0, - "hd": false, - "ln": "106", - "ddd": 0, - "bm": 0, - "hasMask": true, - "ao": 0, - "ks": { - "a": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 90, - 90 - ] - }, - "sk": { - "a": 0, - "k": 0 - }, - "p": { - "a": 0, - "k": [ - 228.88927430025083, - 252.32495060924165 - ] - }, - "r": { - "a": 0, - "k": 0 - }, - "sa": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - } - }, - "masksProperties": [ - { - "nm": "마스크 1", - "inv": false, - "mode": "a", - "x": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - }, - "pt": { - "a": 1, - "k": [ - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -414.016, - 117.503 - ], - [ - -483.194, - 284.263 - ], - [ - -107.086, - 440.285 - ], - [ - -37.908, - 273.525 - ] - ] - } - ], - "t": 0 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -375.893, - 47.367 - ], - [ - -456.617, - 207.775 - ], - [ - -94.834, - 389.838 - ], - [ - -14.111, - 229.43 - ] - ] - } - ], - "t": 1 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -347.794, - 0.143 - ], - [ - -440.063, - 154.2 - ], - [ - -92.606, - 362.302 - ], - [ - -0.337, - 208.246 - ] - ] - } - ], - "t": 2 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -319.695, - -47.081 - ], - [ - -423.51, - 100.624 - ], - [ - -90.378, - 334.767 - ], - [ - 13.437, - 187.063 - ] - ] - } - ], - "t": 3 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -230.436, - -119.867 - ], - [ - -394.991, - -5.526 - ], - [ - -130.743, - 366.679 - ], - [ - 33.812, - 252.339 - ] - ] - } - ], - "t": 4 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -141.178, - -192.653 - ], - [ - -366.473, - -111.676 - ], - [ - -171.107, - 398.591 - ], - [ - 54.188, - 317.616 - ] - ] - } - ], - "t": 5 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -51.919, - -265.439 - ], - [ - -337.954, - -217.825 - ], - [ - -211.471, - 430.504 - ], - [ - 74.563, - 382.892 - ] - ] - } - ], - "t": 6 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 46.081, - -268.439 - ], - [ - -387.954, - -274.825 - ], - [ - -261.471, - 373.504 - ], - [ - 74.563, - 382.892 - ] - ] - } - ], - "t": 7 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 144.081, - -271.439 - ], - [ - -437.954, - -331.825 - ], - [ - -311.471, - 316.504 - ], - [ - 74.563, - 382.892 - ] - ] - } - ], - "t": 8 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 209.081, - -182.439 - ], - [ - -437.954, - -331.825 - ], - [ - -311.471, - 316.504 - ], - [ - 43.563, - 320.892 - ] - ] - } - ], - "t": 9 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 274.081, - -93.439 - ], - [ - -437.954, - -331.825 - ], - [ - -311.471, - 316.504 - ], - [ - 12.563, - 258.892 - ] - ] - } - ], - "t": 10 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 318.747, - 28.561 - ], - [ - -364.621, - -328.492 - ], - [ - -311.471, - 316.504 - ], - [ - 51.23, - 110.103 - ] - ] - } - ], - "t": 11 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 390.081, - 122.561 - ], - [ - -217.954, - -321.825 - ], - [ - -311.471, - 316.504 - ], - [ - 62.563, - 116.892 - ] - ] - } - ], - "t": 12 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 390.081, - 122.561 - ], - [ - -217.954, - -321.825 - ], - [ - -311.471, - 316.504 - ], - [ - 62.563, - 116.892 - ] - ] - } - ], - "t": 85.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 318.747, - 28.561 - ], - [ - -364.621, - -328.492 - ], - [ - -311.471, - 316.504 - ], - [ - 51.23, - 110.103 - ] - ] - } - ], - "t": 86.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 274.081, - -93.439 - ], - [ - -437.954, - -331.825 - ], - [ - -311.471, - 316.504 - ], - [ - 12.563, - 258.892 - ] - ] - } - ], - "t": 87.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 209.081, - -182.439 - ], - [ - -437.954, - -331.825 - ], - [ - -311.471, - 316.504 - ], - [ - 43.563, - 320.892 - ] - ] - } - ], - "t": 88.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 144.081, - -271.439 - ], - [ - -437.954, - -331.825 - ], - [ - -311.471, - 316.504 - ], - [ - 74.563, - 382.892 - ] - ] - } - ], - "t": 89.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 46.081, - -268.439 - ], - [ - -387.954, - -274.825 - ], - [ - -261.471, - 373.504 - ], - [ - 74.563, - 382.892 - ] - ] - } - ], - "t": 90.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -51.919, - -265.439 - ], - [ - -337.954, - -217.825 - ], - [ - -211.471, - 430.504 - ], - [ - 74.563, - 382.892 - ] - ] - } - ], - "t": 91.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -141.178, - -192.653 - ], - [ - -366.473, - -111.676 - ], - [ - -171.107, - 398.591 - ], - [ - 54.188, - 317.616 - ] - ] - } - ], - "t": 92.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -230.436, - -119.867 - ], - [ - -394.991, - -5.526 - ], - [ - -130.743, - 366.679 - ], - [ - 33.812, - 252.339 - ] - ] - } - ], - "t": 93.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -319.695, - -47.081 - ], - [ - -423.51, - 100.624 - ], - [ - -90.378, - 334.767 - ], - [ - 13.437, - 187.063 - ] - ] - } - ], - "t": 94.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -347.794, - 0.143 - ], - [ - -440.063, - 154.2 - ], - [ - -92.606, - 362.302 - ], - [ - -0.337, - 208.246 - ] - ] - } - ], - "t": 95.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -375.893, - 47.367 - ], - [ - -456.617, - 207.775 - ], - [ - -94.834, - 389.838 - ], - [ - -14.111, - 229.43 - ] - ] - } - ], - "t": 96.999 - }, - { - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -414.016, - 117.503 - ], - [ - -483.194, - 284.263 - ], - [ - -107.086, - 440.285 - ], - [ - -37.908, - 273.525 - ] - ] - } - ], - "t": 97.999 - } - ] - } - } - ], - "shapes": [ - { - "ty": "sh", - "bm": 0, - "hd": false, - "nm": "패스 1", - "d": 1, - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [ - 0, - 0 - ], - [ - -177.567, - 1.261 - ], - [ - 1.76, - -43.361 - ] - ], - "o": [ - [ - 18.785, - -42.102 - ], - [ - 110.867, - -0.788 - ], - [ - -1.696, - 41.808 - ] - ], - "v": [ - [ - -207.626, - 178.043 - ], - [ - 37.668, - -2.485 - ], - [ - 199.517, - 83.773 - ] - ] - } - } - }, - { - "ty": "gs", - "bm": 0, - "hd": false, - "nm": "그라디언트 선 1", - "e": { - "a": 0, - "k": [ - 100, - 0 - ] - }, - "g": { - "p": 3, - "k": { - "a": 0, - "k": [ - 0, - 0.9450980392156862, - 0.9647058823529412, - 1, - 0.487, - 0.8235294117647058, - 0.8823529411764706, - 1, - 1, - 0.6980392156862745, - 0.803921568627451, - 1 - ] - } - }, - "t": 1, - "a": { - "a": 0, - "k": 0 - }, - "h": { - "a": 0, - "k": 0 - }, - "s": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "lc": 1, - "lj": 2, - "ml": 4, - "o": { - "a": 0, - "k": 100 - }, - "w": { - "a": 0, - "k": 72 - } - } - ], - "ind": 2 - }, - { - "ty": 4, - "nm": "2", - "sr": 1, - "st": 0, - "op": 900, - "ip": 0, - "hd": false, - "ln": "107", - "ddd": 0, - "bm": 0, - "hasMask": true, - "ao": 0, - "ks": { - "a": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 90, - 90 - ] - }, - "sk": { - "a": 0, - "k": 0 - }, - "p": { - "a": 0, - "k": [ - 228.88927430025083, - 252.32495060924165 - ] - }, - "r": { - "a": 0, - "k": 0 - }, - "sa": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - } - }, - "masksProperties": [ - { - "nm": "마스크 1", - "inv": false, - "mode": "a", - "x": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - }, - "pt": { - "a": 1, - "k": [ - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - -10.906, - 64.325 - ], - [ - 395.976, - 80.044 - ], - [ - 401.649, - -92.361 - ] - ] - } - ], - "t": 11 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -96.08 - ], - [ - -11.453, - 84.325 - ], - [ - 395.429, - 130.115 - ], - [ - 402.399, - -80.361 - ] - ] - } - ], - "t": 12 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 354.517, - 205.547 - ], - [ - 402.399, - -92.361 - ] - ] - } - ], - "t": 13 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 277.429, - 274.044 - ], - [ - 402.399, - -92.361 - ] - ] - } - ], - "t": 14 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 193.004, - 321.677 - ], - [ - 402.399, - -92.361 - ] - ] - } - ], - "t": 15 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 77.217, - 373.86 - ], - [ - 519.399, - -40.361 - ] - ] - } - ], - "t": 16 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - -38.571, - 426.044 - ], - [ - 636.399, - 11.639 - ] - ] - } - ], - "t": 17 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 169.517, - -183.08 - ], - [ - 40.547, - 90.325 - ], - [ - -177.571, - 422.044 - ], - [ - 648.399, - 82.639 - ] - ] - } - ], - "t": 18 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 40.547, - 90.325 - ], - [ - -316.571, - 418.044 - ], - [ - 660.399, - 153.639 - ] - ] - } - ], - "t": 19 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 40.547, - 90.325 - ], - [ - -362.571, - 342.044 - ], - [ - 533.399, - 299.639 - ] - ] - } - ], - "t": 20 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 40.547, - 90.325 - ], - [ - -408.571, - 266.044 - ], - [ - 406.399, - 445.639 - ] - ] - } - ], - "t": 21 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 50.547, - 24.325 - ], - [ - -502.571, - 180.044 - ], - [ - 406.399, - 445.639 - ] - ] - } - ], - "t": 22 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 41.547, - -23.675 - ], - [ - -520.071, - 105.544 - ], - [ - 354.399, - 464.139 - ] - ] - } - ], - "t": 23 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 32.547, - -71.675 - ], - [ - -537.571, - 31.044 - ], - [ - 302.399, - 482.639 - ] - ] - } - ], - "t": 24 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 50.547, - -47.675 - ], - [ - -572.571, - -117.956 - ], - [ - 198.399, - 519.639 - ] - ] - } - ], - "t": 25 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 50.547, - -47.675 - ], - [ - -572.571, - -117.956 - ], - [ - 198.399, - 519.639 - ] - ] - } - ], - "t": 72.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 32.547, - -71.675 - ], - [ - -537.571, - 31.044 - ], - [ - 302.399, - 482.639 - ] - ] - } - ], - "t": 73.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 41.547, - -23.675 - ], - [ - -520.071, - 105.544 - ], - [ - 354.399, - 464.139 - ] - ] - } - ], - "t": 74.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 50.547, - 24.325 - ], - [ - -502.571, - 180.044 - ], - [ - 406.399, - 445.639 - ] - ] - } - ], - "t": 75.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 40.547, - 90.325 - ], - [ - -408.571, - 266.044 - ], - [ - 406.399, - 445.639 - ] - ] - } - ], - "t": 76.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 40.547, - 90.325 - ], - [ - -362.571, - 342.044 - ], - [ - 533.399, - 299.639 - ] - ] - } - ], - "t": 77.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 343.517, - -258.08 - ], - [ - 40.547, - 90.325 - ], - [ - -316.571, - 418.044 - ], - [ - 660.399, - 153.639 - ] - ] - } - ], - "t": 78.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - 169.517, - -183.08 - ], - [ - 40.547, - 90.325 - ], - [ - -177.571, - 422.044 - ], - [ - 648.399, - 82.639 - ] - ] - } - ], - "t": 79.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - -38.571, - 426.044 - ], - [ - 636.399, - 11.639 - ] - ] - } - ], - "t": 80.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 77.217, - 373.86 - ], - [ - 519.399, - -40.361 - ] - ] - } - ], - "t": 81.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 193.004, - 321.677 - ], - [ - 402.399, - -92.361 - ] - ] - } - ], - "t": 82.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 277.429, - 274.044 - ], - [ - 402.399, - -92.361 - ] - ] - } - ], - "t": 83.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - 40.547, - 90.325 - ], - [ - 354.517, - 205.547 - ], - [ - 402.399, - -92.361 - ] - ] - } - ], - "t": 84.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -96.08 - ], - [ - -11.453, - 84.325 - ], - [ - 395.429, - 130.115 - ], - [ - 402.399, - -80.361 - ] - ] - } - ], - "t": 85.999 - }, - { - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -4.483, - -108.08 - ], - [ - -10.906, - 64.325 - ], - [ - 395.976, - 80.044 - ], - [ - 401.649, - -92.361 - ] - ] - } - ], - "t": 86.999 - } - ] - } - } - ], - "shapes": [ - { - "ty": "sh", - "bm": 0, - "hd": false, - "nm": "패스 1", - "d": 1, - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [ - 1.759, - -43.361 - ], - [ - 114.183, - -0.407 - ], - [ - -0.292, - 147.305 - ] - ], - "o": [ - [ - -1.697, - 41.808 - ], - [ - -86.495, - 0.309 - ], - [ - 0.258, - -130.016 - ] - ], - "v": [ - [ - 199.441, - 83.097 - ], - [ - 33.67, - 190.857 - ], - [ - -178.61, - -49.564 - ] - ] - } - } - }, - { - "ty": "gs", - "bm": 0, - "hd": false, - "nm": "그라디언트 선 1", - "e": { - "a": 0, - "k": [ - 100, - 0 - ] - }, - "g": { - "p": 3, - "k": { - "a": 0, - "k": [ - 0, - 0.5607843137254902, - 0.7058823529411765, - 0.984313725490196, - 0.5, - 0.6313725490196078, - 0.7568627450980392, - 0.9921568627450981, - 1, - 0.7058823529411765, - 0.807843137254902, - 1 - ] - } - }, - "t": 1, - "a": { - "a": 0, - "k": 0 - }, - "h": { - "a": 0, - "k": 0 - }, - "s": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "lc": 1, - "lj": 2, - "ml": 4, - "o": { - "a": 0, - "k": 100 - }, - "w": { - "a": 0, - "k": 72 - } - } - ], - "ind": 3 - }, - { - "ty": 4, - "nm": "3", - "sr": 1, - "st": 0, - "op": 900, - "ip": 0, - "hd": false, - "ln": "108", - "ddd": 0, - "bm": 0, - "hasMask": true, - "ao": 0, - "ks": { - "a": { - "a": 0, - "k": [ - 0, - 0 - ] - }, - "s": { - "a": 0, - "k": [ - 90, - 90 - ] - }, - "sk": { - "a": 0, - "k": 0 - }, - "p": { - "a": 0, - "k": [ - 228.88927430025083, - 252.32495060924165 - ] - }, - "r": { - "a": 0, - "k": 0 - }, - "sa": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - } - }, - "masksProperties": [ - { - "nm": "마스크 1", - "inv": false, - "mode": "a", - "x": { - "a": 0, - "k": 0 - }, - "o": { - "a": 0, - "k": 100 - }, - "pt": { - "a": 1, - "k": [ - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -10.483, - 141.92 - ], - [ - -9.453, - 2.325 - ], - [ - -566.571, - -61.956 - ], - [ - 198.399, - 519.639 - ] - ] - } - ], - "t": 24 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -10.483, - 141.92 - ], - [ - -11.453, - -39.675 - ], - [ - -564.275, - -169.827 - ], - [ - 198.399, - 519.639 - ] - ] - } - ], - "t": 25 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -10.483, - 141.92 - ], - [ - -15.453, - -53.675 - ], - [ - -470.571, - -259.956 - ], - [ - -485.601, - -14.361 - ] - ] - } - ], - "t": 26 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -38.483, - 163.92 - ], - [ - -43.453, - -31.675 - ], - [ - -288.571, - -397.956 - ], - [ - -513.601, - 7.639 - ] - ] - } - ], - "t": 27 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -89.453, - -51.675 - ], - [ - 43.429, - -399.956 - ], - [ - -533.601, - -240.361 - ] - ] - } - ], - "t": 28 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -89.453, - -51.675 - ], - [ - 187.429, - -255.956 - ], - [ - -291.601, - -422.361 - ] - ] - } - ], - "t": 29 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -89.453, - -51.675 - ], - [ - 201.429, - -133.956 - ], - [ - -133.601, - -436.361 - ] - ] - } - ], - "t": 30 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -79.453, - -41.675 - ], - [ - 207.429, - -65.956 - ], - [ - -133.601, - -436.361 - ] - ] - } - ], - "t": 31 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -41.453, - 8.325 - ], - [ - 213.429, - 10.044 - ], - [ - -133.601, - -436.361 - ] - ] - } - ], - "t": 32 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -500.483, - -72.08 - ], - [ - -41.453, - 8.325 - ], - [ - 195.429, - 88.044 - ], - [ - 148.399, - -442.361 - ] - ] - } - ], - "t": 33 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -694.483, - -18.08 - ], - [ - 30.547, - -25.675 - ], - [ - 67.429, - 164.044 - ], - [ - 318.399, - -408.361 - ] - ] - } - ], - "t": 34 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -722.483, - 9.92 - ], - [ - 30.547, - -25.675 - ], - [ - -24.571, - 266.044 - ], - [ - 550.399, - -510.361 - ] - ] - } - ], - "t": 35 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -722.483, - 9.92 - ], - [ - 30.547, - -25.675 - ], - [ - -24.571, - 266.044 - ], - [ - 550.399, - -510.361 - ] - ] - } - ], - "t": 62.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -694.483, - -18.08 - ], - [ - 30.547, - -25.675 - ], - [ - 67.429, - 164.044 - ], - [ - 318.399, - -408.361 - ] - ] - } - ], - "t": 63.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -500.483, - -72.08 - ], - [ - -41.453, - 8.325 - ], - [ - 195.429, - 88.044 - ], - [ - 148.399, - -442.361 - ] - ] - } - ], - "t": 64.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -41.453, - 8.325 - ], - [ - 213.429, - 10.044 - ], - [ - -133.601, - -436.361 - ] - ] - } - ], - "t": 65.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -79.453, - -41.675 - ], - [ - 207.429, - -65.956 - ], - [ - -133.601, - -436.361 - ] - ] - } - ], - "t": 66.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -89.453, - -51.675 - ], - [ - 201.429, - -133.956 - ], - [ - -133.601, - -436.361 - ] - ] - } - ], - "t": 67.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -89.453, - -51.675 - ], - [ - 187.429, - -255.956 - ], - [ - -291.601, - -422.361 - ] - ] - } - ], - "t": 68.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -378.483, - 93.92 - ], - [ - -89.453, - -51.675 - ], - [ - 43.429, - -399.956 - ], - [ - -533.601, - -240.361 - ] - ] - } - ], - "t": 69.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -38.483, - 163.92 - ], - [ - -43.453, - -31.675 - ], - [ - -288.571, - -397.956 - ], - [ - -513.601, - 7.639 - ] - ] - } - ], - "t": 70.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -10.483, - 141.92 - ], - [ - -15.453, - -53.675 - ], - [ - -470.571, - -259.956 - ], - [ - -485.601, - -14.361 - ] - ] - } - ], - "t": 71.999 - }, - { - "o": { - "x": 0.167, - "y": 0.167 - }, - "i": { - "x": 0.833, - "y": 0.833 - }, - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -10.483, - 141.92 - ], - [ - -11.453, - -39.675 - ], - [ - -564.275, - -169.827 - ], - [ - 198.399, - 519.639 - ] - ] - } - ], - "t": 72.999 - }, - { - "s": [ - { - "c": true, - "i": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "o": [ - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ], - [ - 0, - 0 - ] - ], - "v": [ - [ - -10.483, - 141.92 - ], - [ - -9.453, - 2.325 - ], - [ - -566.571, - -61.956 - ], - [ - 198.399, - 519.639 - ] - ] - } - ], - "t": 73.999 - } - ] - } - } - ], - "shapes": [ - { - "ty": "sh", - "bm": 0, - "hd": false, - "nm": "패스 1", - "d": 1, - "ks": { - "a": 0, - "k": { - "c": false, - "i": [ - [ - -0.292, - 147.305 - ], - [ - -1.047, - 0.003 - ], - [ - 84.495, - -1.63 - ] - ], - "o": [ - [ - 0.258, - -130.016 - ], - [ - 108.101, - -0.344 - ], - [ - -83.334, - 1.607 - ] - ], - "v": [ - [ - -178.61, - -48.814 - ], - [ - -77.448, - -180.221 - ], - [ - 37.118, - 66.162 - ] - ] - } - } - }, - { - "ty": "gs", - "bm": 0, - "hd": false, - "nm": "그라디언트 선 1", - "e": { - "a": 0, - "k": [ - 38, - 31 - ] - }, - "g": { - "p": 3, - "k": { - "a": 0, - "k": [ - 0, - 0.6588235294117647, - 0.7764705882352941, - 1, - 0.5, - 0.5176470588235295, - 0.6862745098039216, - 1, - 1, - 0.3803921568627451, - 0.596078431372549, - 1 - ] - } - }, - "t": 1, - "a": { - "a": 0, - "k": 0 - }, - "h": { - "a": 0, - "k": 0 - }, - "s": { - "a": 0, - "k": [ - -99, - -212 - ] - }, - "lc": 1, - "lj": 2, - "ml": 4, - "o": { - "a": 0, - "k": 100 - }, - "w": { - "a": 0, - "k": 72 - } - } - ], - "ind": 4 - } - ], - "v": "5.7.0", - "fr": 30, - "op": 120, - "ip": 0, - "assets": [] -} From 0c6d885113ec860d1928589fc3a35d16230d7f2a Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 03:28:13 +0900 Subject: [PATCH 53/63] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/team/prezel/core/auth/AuthClient.kt | 4 + .../com/team/prezel/core/auth/AuthManager.kt | 36 ++------ .../team/prezel/core/auth/KakaoAuthClient.kt | 29 +++++++ .../team/prezel/feature/my/impl/MyScreen.kt | 4 +- .../prezel/feature/my/impl/MyViewModel.kt | 85 ++----------------- .../feature/my/impl/contract/MyUiEffect.kt | 3 +- 6 files changed, 48 insertions(+), 113 deletions(-) diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt index 7cf5c259..38a61981 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt @@ -4,7 +4,11 @@ import android.content.Context import com.team.prezel.core.auth.model.AuthResult interface AuthClient { + suspend fun isLoggedIn(): Boolean + suspend fun login(context: Context): AuthResult suspend fun logout(): Result + + suspend fun unlink(): Result } diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt index e49db547..b0b3e858 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt @@ -9,39 +9,15 @@ import javax.inject.Singleton class AuthManager @Inject constructor( private val authClient: AuthClient, ) { - private var isLoggedInToOAuthProvider: Boolean = false - - suspend fun login(context: Context): AuthResult { - val result = authClient.login(context = context) - - if (result is AuthResult.Success) { - isLoggedInToOAuthProvider = true - } - - return result - } + suspend fun login(context: Context): AuthResult = authClient.login(context = context) suspend fun logout(): Result { - if (!isLoggedInToOAuthProvider) { - return Result.failure(IllegalStateException("로그인된 OAuth 세션이 없습니다.")) - } - - return authClient.logout().onSuccess { - isLoggedInToOAuthProvider = false - } - } - - fun clearLoginState() { - isLoggedInToOAuthProvider = false + if (!authClient.isLoggedIn()) return Result.success(Unit) + return authClient.logout() } - suspend fun clearAuthSession(): Result { - if (!isLoggedInToOAuthProvider) { - return Result.success(Unit) - } - - return authClient - .logout() - .also { isLoggedInToOAuthProvider = false } + suspend fun unlink(): Result { + if (!authClient.isLoggedIn()) return Result.success(Unit) + return authClient.unlink() } } diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt index 0f5a4a58..11d10653 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.auth import android.content.Context +import com.kakao.sdk.auth.AuthApiClient import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.AuthError import com.kakao.sdk.common.model.AuthErrorCause @@ -15,6 +16,18 @@ import javax.inject.Inject import kotlin.coroutines.resume class KakaoAuthClient @Inject constructor() : AuthClient { + override suspend fun isLoggedIn(): Boolean { + if (!AuthApiClient.instance.hasToken()) return false + + return suspendCancellableCoroutine { continuation -> + UserApiClient.instance.accessTokenInfo { _, error -> + if (continuation.isActive) { + continuation.resume(error == null) + } + } + } + } + override suspend fun login(context: Context): AuthResult = suspendCancellableCoroutine { continuation -> if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { @@ -41,6 +54,22 @@ class KakaoAuthClient @Inject constructor() : AuthClient { } } + override suspend fun unlink(): Result = + suspendCancellableCoroutine { continuation -> + Timber.d("카카오 회원탈퇴 시도") + + UserApiClient.instance.unlink { error -> + if (error != null) { + Timber.e(error, "카카오 회원탈퇴에 실패했습니다.") + continuation.resume(Result.failure(error)) + return@unlink + } + + Timber.d("카카오 회원탈퇴에 성공했습니다.") + continuation.resume(Result.success(Unit)) + } + } + private fun loginWithKakaoTalk( context: Context, continuation: CancellableContinuation, diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt index eadacb4f..8546c09d 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt @@ -47,8 +47,8 @@ internal fun MyScreen( MyScreenContent( uiState = uiState, - onLogout = viewModel::logout, - onWithdraw = viewModel::withdraw, + onLogout = {}, + onWithdraw = {}, modifier = modifier, ) } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt index 17bc9c05..0496092c 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -1,95 +1,20 @@ package com.team.prezel.feature.my.impl -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.team.prezel.core.auth.AuthManager import com.team.prezel.core.domain.usecase.auth.LogoutUseCase import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase -import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.my.impl.contract.MyUiEffect +import com.team.prezel.feature.my.impl.contract.MyUiIntent import com.team.prezel.feature.my.impl.contract.MyUiState -import com.team.prezel.feature.my.impl.model.MyUiMessage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @HiltViewModel internal class MyViewModel @Inject constructor( - private val authManager: AuthManager, private val logoutUseCase: LogoutUseCase, private val withdrawUseCase: WithdrawUseCase, -) : ViewModel() { - private val _uiState = MutableStateFlow(MyUiState()) - val uiState = _uiState.asStateFlow() - - private val _uiEffect = MutableSharedFlow() - val uiEffect = _uiEffect.asSharedFlow() - - fun logout() { - if (_uiState.value.isLoading) return - - viewModelScope.launch { - try { - _uiState.update { it.copy(isLoading = true) } - val result = logoutUseCase() - handleResult( - result = result, - onSuccess = { - authManager - .logout() - .onFailure { throwable -> - Timber.w(throwable, "로컬 인증 세션 정리에 실패했습니다.") - } - }, - failureLog = "로그아웃에 실패했습니다.", - failureMessage = MyUiMessage.LOGOUT_FAILED, - ) - } finally { - _uiState.update { it.copy(isLoading = false) } - } - } - } - - fun withdraw() { - if (_uiState.value.isLoading) return - - viewModelScope.launch { - try { - _uiState.update { it.copy(isLoading = true) } - val result = withdrawUseCase(reason = WithdrawReason.Other("임시 테스트 탈퇴")) - handleResult( - result = result, - onSuccess = { authManager.clearLoginState() }, - failureLog = "회원탈퇴에 실패했습니다.", - failureMessage = MyUiMessage.WITHDRAW_FAILED, - ) - } finally { - _uiState.update { it.copy(isLoading = false) } - } - } - } - - private suspend fun handleResult( - result: Result, - onSuccess: suspend () -> Unit, - failureLog: String, - failureMessage: MyUiMessage, - ) { - result.fold( - onSuccess = { - onSuccess() - _uiEffect.emit(MyUiEffect.NavigateToLogin) - }, - onFailure = { throwable -> - Timber.e(throwable, failureLog) - _uiEffect.emit(MyUiEffect.ShowMessage(failureMessage)) - }, - ) +) : BaseViewModel(MyUiState()) { + override fun onIntent(intent: MyUiIntent) { + // todo: 프로필 화면 구현 작업에서 진행 } } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt index 21d82549..594b4df2 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt @@ -1,8 +1,9 @@ package com.team.prezel.feature.my.impl.contract +import com.team.prezel.core.ui.base.UiEffect import com.team.prezel.feature.my.impl.model.MyUiMessage -sealed interface MyUiEffect { +sealed interface MyUiEffect : UiEffect { data object NavigateToLogin : MyUiEffect data class ShowMessage( From 5440023c315ab97efd950a11c862d250b5e6bab4 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 03:32:07 +0900 Subject: [PATCH 54/63] =?UTF-8?q?refactor:=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EA=B5=AC=ED=98=84=EC=B2=B4=20=EB=AA=85?= =?UTF-8?q?=EB=AA=85=20=EA=B7=9C=EC=B9=99=20=EB=B3=80=EA=B2=BD=20(Default?= =?UTF-8?q?=20->=20Impl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 주요 인터페이스 구현체의 클래스명 변경** * 기존 `Default-` 접두사를 사용하던 구현체들을 `-Impl` 접미사 형태로 변경하여 명명 규칙을 통일했습니다. * `DefaultAuthRepository` -> `AuthRepositoryImpl` * `DefaultGlobalEventBus` -> `GlobalEventBusImpl` * `DefaultTokenProvider` -> `TokenProviderImpl` * **refactor: DI 모듈 내 변경된 구현체 참조 반영** * `AuthModule`, `RepositoryModule`, `GlobalEventModule`에서 `@Binds`를 통해 주입되는 클래스명을 새로운 구현체 이름으로 업데이트했습니다. --- Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt | 4 ++-- .../event/{DefaultGlobalEventBus.kt => GlobalEventBusImpl.kt} | 2 +- .../auth/{DefaultTokenProvider.kt => TokenProviderImpl.kt} | 2 +- .../src/main/java/com/team/prezel/core/data/di/AuthModule.kt | 4 ++-- .../java/com/team/prezel/core/data/di/RepositoryModule.kt | 4 ++-- .../{DefaultAuthRepository.kt => AuthRepositoryImpl.kt} | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) rename Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/{DefaultGlobalEventBus.kt => GlobalEventBusImpl.kt} (89%) rename Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/{DefaultTokenProvider.kt => TokenProviderImpl.kt} (92%) rename Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/{DefaultAuthRepository.kt => AuthRepositoryImpl.kt} (97%) diff --git a/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt b/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt index bff168ef..a3935a39 100644 --- a/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt +++ b/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt @@ -1,7 +1,7 @@ package com.team.prezel -import com.team.prezel.core.common.event.DefaultGlobalEventBus import com.team.prezel.core.common.event.GlobalEventBus +import com.team.prezel.core.common.event.GlobalEventBusImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -13,5 +13,5 @@ import javax.inject.Singleton internal abstract class GlobalEventModule { @Binds @Singleton - abstract fun bindsGlobalEventBus(globalEventBus: DefaultGlobalEventBus): GlobalEventBus + abstract fun bindsGlobalEventBus(globalEventBus: GlobalEventBusImpl): GlobalEventBus } diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/DefaultGlobalEventBus.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt similarity index 89% rename from Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/DefaultGlobalEventBus.kt rename to Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt index 791334ad..141317ea 100644 --- a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/DefaultGlobalEventBus.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt @@ -8,7 +8,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class DefaultGlobalEventBus @Inject constructor() : GlobalEventBus { +class GlobalEventBusImpl @Inject constructor() : GlobalEventBus { private val _events = MutableSharedFlow( extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultTokenProvider.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/TokenProviderImpl.kt similarity index 92% rename from Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultTokenProvider.kt rename to Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/TokenProviderImpl.kt index 4fdf8f5f..7ebacc19 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/DefaultTokenProvider.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/TokenProviderImpl.kt @@ -6,7 +6,7 @@ import com.team.prezel.core.network.auth.TokenProvider import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject -internal class DefaultTokenProvider @Inject constructor( +internal class TokenProviderImpl @Inject constructor( private val dataSource: AuthLocalDataSource, ) : TokenProvider { override suspend fun getTokens(): AuthTokens? = dataSource.tokens.firstOrNull() diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt index 73a01005..dcce5276 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt @@ -1,6 +1,6 @@ package com.team.prezel.core.data.di -import com.team.prezel.core.data.auth.DefaultTokenProvider +import com.team.prezel.core.data.auth.TokenProviderImpl import com.team.prezel.core.network.auth.TokenProvider import dagger.Binds import dagger.Module @@ -13,5 +13,5 @@ import javax.inject.Singleton internal abstract class AuthModule { @Binds @Singleton - abstract fun bindsTokenProvider(tokenProvider: DefaultTokenProvider): TokenProvider + abstract fun bindsTokenProvider(tokenProvider: TokenProviderImpl): TokenProvider } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index 9baee783..b027abf2 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,6 +1,6 @@ package com.team.prezel.core.data.di -import com.team.prezel.core.data.repository.DefaultAuthRepository +import com.team.prezel.core.data.repository.AuthRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.repository.profile.UserRepository @@ -15,7 +15,7 @@ import javax.inject.Singleton internal abstract class RepositoryModule { @Binds @Singleton - abstract fun bindAuthRepository(impl: DefaultAuthRepository): AuthRepository + abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository @Binds @Singleton diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt similarity index 97% rename from Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt rename to Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt index 78bcc855..b7971164 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/DefaultAuthRepository.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject -internal class DefaultAuthRepository @Inject constructor( +internal class AuthRepositoryImpl @Inject constructor( private val authRemoteDataSource: AuthRemoteDataSource, private val authLocalDataSource: AuthLocalDataSource, @param:ApplicationScope private val externalScope: CoroutineScope, From 9ff6884fd32f21ef06d3c1d919432642e48eced4 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 03:33:57 +0900 Subject: [PATCH 55/63] =?UTF-8?q?refactor:=20GlobalEventModule=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: GlobalEventModule을 core:common 모듈로 이동** * `GlobalEventModule`을 `app` 모듈에서 `core:common` 모듈의 `di` 패키지로 이동했습니다. * 패키지 경로를 `com.team.prezel`에서 `com.team.prezel.core.common.di`로 변경했습니다. --- .../kotlin/com/team/prezel/core/common/di}/GlobalEventModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename Prezel/{app/src/main/java/com/team/prezel => core/common/src/main/kotlin/com/team/prezel/core/common/di}/GlobalEventModule.kt (92%) diff --git a/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/GlobalEventModule.kt similarity index 92% rename from Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt rename to Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/GlobalEventModule.kt index a3935a39..da6ba518 100644 --- a/Prezel/app/src/main/java/com/team/prezel/GlobalEventModule.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/GlobalEventModule.kt @@ -1,4 +1,4 @@ -package com.team.prezel +package com.team.prezel.core.common.di import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.common.event.GlobalEventBusImpl From a4b2ca2e75fb994d015647b04cb33f76708ed228 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 03:37:40 +0900 Subject: [PATCH 56/63] =?UTF-8?q?refactor:=20MyViewModel=20=EB=82=B4=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `MyViewModel` 생성자 및 의존성 정리** * 현재 사용되지 않는 `LogoutUseCase` 및 `WithdrawUseCase` 의존성을 생성자에서 제거했습니다. * 의존성 제거에 따라 불필요해진 관련 import 구문을 삭제했습니다. --- .../java/com/team/prezel/feature/my/impl/MyViewModel.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt index 0496092c..c380fea8 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -1,7 +1,5 @@ package com.team.prezel.feature.my.impl -import com.team.prezel.core.domain.usecase.auth.LogoutUseCase -import com.team.prezel.core.domain.usecase.auth.WithdrawUseCase import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.my.impl.contract.MyUiEffect import com.team.prezel.feature.my.impl.contract.MyUiIntent @@ -10,10 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel -internal class MyViewModel @Inject constructor( - private val logoutUseCase: LogoutUseCase, - private val withdrawUseCase: WithdrawUseCase, -) : BaseViewModel(MyUiState()) { +internal class MyViewModel @Inject constructor() : BaseViewModel(MyUiState()) { override fun onIntent(intent: MyUiIntent) { // todo: 프로필 화면 구현 작업에서 진행 } From 26d24f97fa51d9bfb178dc83c0c118fc548c4b4c Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 28 Apr 2026 03:58:16 +0900 Subject: [PATCH 57/63] =?UTF-8?q?refactor:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20`HttpClientFactory`=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `HttpClientFactory` 추가를 통한 클라이언트 설정 로직 캡슐화** * `NetworkModule`에 집중되어 있던 Ktor `HttpClient` 구성 로직을 전담 클래스인 `HttpClientFactory`로 이동했습니다. * Default Request, Content Negotiation, UserAgent, Logging, Auth 등 설정을 개별 메서드로 분리하여 가독성과 유지보수성을 높였습니다. * `buildUserAgent` 메서드를 팩토리 내부로 이동하여 Android OS 버전, SDK 버전, 제조사 및 모델명을 포함한 정형화된 User-Agent 형식을 정의했습니다. * **refactor: 인증 및 토큰 재발급 로직 고도화** * `Bearer` 인증 설정 내 `refreshTokens` 로직을 개선하여 토큰 재발급 실패 시 `tokenProvider`를 초기화하고 `GlobalEventBus`를 통해 `ForceLogout` 이벤트를 전송하도록 수정했습니다. * `AuthTokens` 및 `ReissueResponse` 모델을 Ktor의 `BearerTokens`로 변환하는 확장 함수를 추가했습니다. * `authServiceProvider`를 `Provider` 형태로 주입받아 순환 참조 문제를 방지하고 필요한 시점에 `AuthService`를 호출하도록 개선했습니다. * **refactor: `NetworkModule` 구조 단순화** * `HttpClient` 생성 시 `HttpClientFactory.create()`를 사용하도록 변경하여 모듈 내 불필요한 설정 코드를 제거했습니다. * `NetworkModule`에서 직접 관리하던 `networkJson` 및 로깅 설정을 팩토리 내부로 이동했습니다. --- .../core/network/client/HttpClientFactory.kt | 153 ++++++++++++++++++ .../prezel/core/network/di/NetworkModule.kt | 105 +----------- 2 files changed, 155 insertions(+), 103 deletions(-) create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt new file mode 100644 index 00000000..ab1722b5 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt @@ -0,0 +1,153 @@ +package com.team.prezel.core.network.client + +import android.os.Build +import com.team.prezel.core.common.event.GlobalEvent +import com.team.prezel.core.common.event.GlobalEventBus +import com.team.prezel.core.model.auth.AuthTokens +import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.auth.AuthRequestAttributes +import com.team.prezel.core.network.auth.TokenProvider +import com.team.prezel.core.network.model.auth.reissue.ReissueRequest +import com.team.prezel.core.network.model.auth.reissue.ReissueResponse +import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.service.AuthService +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.clearAuthTokens +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +internal class HttpClientFactory @Inject constructor( + private val tokenProvider: TokenProvider, + private val authServiceProvider: Provider, + private val globalEventBus: GlobalEventBus, +) { + private val networkJson: Json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = true + } + + private val ktorLogger: Logger = + object : Logger { + override fun log(message: String) { + Timber.tag("KTOR-LOG").d(message) + } + } + + fun create( + block: HttpClientConfig<*>.() -> Unit = { + configureDefaultRequest() + installContentNegotiation() + installUserAgent() + installLogging() + installAuth() + }, + ): HttpClient = HttpClient(OkHttp) { block() } + + internal fun HttpClientConfig<*>.configureDefaultRequest() { + defaultRequest { + contentType(ContentType.Application.Json) + } + } + + internal fun HttpClientConfig<*>.installContentNegotiation() { + install(ContentNegotiation) { + json(networkJson) + } + } + + internal fun HttpClientConfig<*>.installUserAgent() { + install(UserAgent) { + agent = buildUserAgent() + } + } + + internal fun HttpClientConfig<*>.installLogging() { + install(Logging) { + logger = ktorLogger + sanitizeHeader { header -> header == HttpHeaders.Authorization } + level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE + } + } + + internal fun HttpClientConfig<*>.installAuth() { + install(Auth) { + bearer { + cacheTokens = true + + loadTokens { tokenProvider.getTokens()?.toBearerTokens() } + + refreshTokens { refreshBearerTokens() } + + sendWithoutRequest { request -> + request.attributes.getOrNull(AuthRequestAttributes.SkipAuthKey) != true + } + } + } + } + + private suspend fun RefreshTokensParams.refreshBearerTokens(): BearerTokens? { + val refreshToken = oldTokens?.refreshToken ?: return null + + return try { + authServiceProvider + .get() + .reissue(request = ReissueRequest(refreshToken)) + .requireData() + .let { response -> + tokenProvider.updateTokens(accessToken = response.accessToken, refreshToken = response.refreshToken) + client.clearAuthTokens() + response.toBearerTokens() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + tokenProvider.clearTokens() + client.clearAuthTokens() + globalEventBus.emit(GlobalEvent.ForceLogout) + null + } + } + + private fun AuthTokens.toBearerTokens(): BearerTokens = + BearerTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + + private fun ReissueResponse.toBearerTokens(): BearerTokens = + BearerTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + + private fun buildUserAgent(): String = + buildString { + append("Prezel-Android/${BuildConfig.BUILD_TYPE} ") + append("(Android ${Build.VERSION.RELEASE}; ") + append("SDK ${Build.VERSION.SDK_INT}; ") + append("${Build.MANUFACTURER} ${Build.MODEL})") + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 3832ab7d..48aebadd 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,13 +1,7 @@ package com.team.prezel.core.network.di -import android.os.Build -import com.team.prezel.core.common.event.GlobalEvent -import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.network.BuildConfig -import com.team.prezel.core.network.auth.AuthRequestAttributes -import com.team.prezel.core.network.auth.TokenProvider -import com.team.prezel.core.network.model.auth.reissue.ReissueRequest -import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.client.HttpClientFactory import com.team.prezel.core.network.service.AuthService import com.team.prezel.core.network.service.createAuthService import dagger.Module @@ -16,101 +10,14 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.UserAgent -import io.ktor.client.plugins.auth.Auth -import io.ktor.client.plugins.auth.clearAuthTokens -import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.bearer -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger -import io.ktor.client.plugins.logging.Logging -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.CancellationException -import kotlinx.serialization.json.Json -import timber.log.Timber -import javax.inject.Provider import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object NetworkModule { - private val networkJson: Json = - Json { - ignoreUnknownKeys = true - encodeDefaults = true - prettyPrint = true - } - @Provides @Singleton - internal fun provideHttpClient( - tokenProvider: TokenProvider, - authServiceProvider: Provider, - globalEventBus: GlobalEventBus, - ): HttpClient = - HttpClient(OkHttp) { - defaultRequest { - contentType(ContentType.Application.Json) - } - install(ContentNegotiation) { json(networkJson) } - - install(UserAgent) { agent = buildUserAgent() } - - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - Timber.tag("KTOR-LOG").d(message) - } - } - sanitizeHeader { header -> header == HttpHeaders.Authorization } - level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE - } - - install(Auth) { - bearer { - cacheTokens = true - loadTokens { - tokenProvider.getTokens()?.let { tokens -> - BearerTokens( - accessToken = tokens.accessToken, - refreshToken = tokens.refreshToken, - ) - } - } - - refreshTokens { - val oldRefreshToken = oldTokens?.refreshToken ?: return@refreshTokens null - return@refreshTokens try { - val response = authServiceProvider - .get() - .reissue(request = ReissueRequest(oldRefreshToken)) - .requireData() - with(response) { - tokenProvider.updateTokens(accessToken = accessToken, refreshToken = refreshToken) - BearerTokens(accessToken = accessToken, refreshToken = refreshToken).also { client.clearAuthTokens() } - } - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - tokenProvider.clearTokens() - client.clearAuthTokens() - globalEventBus.emit(GlobalEvent.ForceLogout) - null - } - } - - sendWithoutRequest { request -> - request.attributes.getOrNull(AuthRequestAttributes.SkipAuthKey) != true - } - } - } - } + internal fun provideHttpClient(factory: HttpClientFactory): HttpClient = factory.create() @Provides @Singleton @@ -124,12 +31,4 @@ object NetworkModule { @Provides @Singleton internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() - - private fun buildUserAgent(): String = - buildString { - append("Prezel-Android/${BuildConfig.BUILD_TYPE} ") - append("(Android ${Build.VERSION.RELEASE}; ") - append("SDK ${Build.VERSION.SDK_INT}; ") - append("${Build.MANUFACTURER} ${Build.MODEL})") - } } From d1f7c4bf4c0b9fb10ffa6a09527af87ad308ef39 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 30 Apr 2026 16:56:59 +0900 Subject: [PATCH 58/63] =?UTF-8?q?refactor:=20Ktor=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: Ktor용 JSON Pretty Logger 구현** * `KtorPrettyLogger` 클래스를 추가하여 Ktor 네트워크 로그의 JSON 데이터를 가독성 좋게 출력하도록 개선했습니다. * `HttpClientFactory`에서 기존 인라인 로거를 `KtorPrettyLogger`로 교체했습니다. * **refactor: `BaseResponse` 및 응답 검증 로직 개선** * `BaseResponse` 모델의 `code`, `data`, `message` 필드를 Nullable(`?`)로 변경하여 유연한 응답 처리를 지원합니다. * 성공 여부만 확인하는 `requireSuccess()` 확장 함수를 추가했습니다. * `requireData()` 내부에서 `requireSuccess()`를 호출하도록 구조를 변경하고, 데이터가 null인 경우에 대한 예외 처리를 강화했습니다. * **refactor: 인증 관련 API 응답 타입 및 처리 방식 변경** * `AuthService`: `logout` 및 `withdraw` API의 반환 타입을 `BaseResponse`에서 명확한 성공 여부 판단을 위해 `BaseResponse`으로 변경했습니다. * `AuthRemoteDataSourceImpl`: 로그아웃 및 회원 탈퇴 시 `requireData()` 대신 `requireSuccess()`를 사용하여 데이터를 파싱하지 않고 성공 상태만 확인하도록 수정했습니다. * **build: 빈 테스트 디렉토리 유지를 위한 .gitkeep 추가** * `core:common` 모듈의 테스트 경로에 `.gitkeep` 파일을 추가했습니다. --- .../com/team/prezel/core/common/.gitkeep | 0 .../core/network/client/HttpClientFactory.kt | 9 +---- .../core/network/client/KtorPrettyLogger.kt | 37 +++++++++++++++++++ .../datasource/AuthRemoteDataSourceImpl.kt | 5 ++- .../prezel/core/network/model/BaseResponse.kt | 30 +++++++++------ .../core/network/service/AuthService.kt | 4 +- 6 files changed, 62 insertions(+), 23 deletions(-) create mode 100644 Prezel/core/common/src/test/kotlin/com/team/prezel/core/common/.gitkeep create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt diff --git a/Prezel/core/common/src/test/kotlin/com/team/prezel/core/common/.gitkeep b/Prezel/core/common/src/test/kotlin/com/team/prezel/core/common/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt index ab1722b5..c2f88b7a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt @@ -23,7 +23,6 @@ import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.http.ContentType import io.ktor.http.HttpHeaders @@ -31,7 +30,6 @@ import io.ktor.http.contentType import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.Json -import timber.log.Timber import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -49,12 +47,7 @@ internal class HttpClientFactory @Inject constructor( prettyPrint = true } - private val ktorLogger: Logger = - object : Logger { - override fun log(message: String) { - Timber.tag("KTOR-LOG").d(message) - } - } + private val ktorLogger = KtorPrettyLogger(networkJson) fun create( block: HttpClientConfig<*>.() -> Unit = { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt new file mode 100644 index 00000000..e0088c28 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt @@ -0,0 +1,37 @@ +package com.team.prezel.core.network.client + +import io.ktor.client.plugins.logging.Logger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import timber.log.Timber + +internal class KtorPrettyLogger( + private val json: Json, +) : Logger { + override fun log(message: String) { + Timber.tag(TAG).d(message.formatJsonLogMessage()) + } + + private fun String.formatJsonLogMessage(): String = + toPrettyJsonOrNull() + ?: lineSequence() + .joinToString(separator = "\n") { line -> + line.toPrettyJsonOrNull() ?: line + } + + private fun String.toPrettyJsonOrNull(): String? { + val candidate = trim() + if (!candidate.isJsonObjectOrArray()) return null + + return runCatching { + val jsonElement = json.parseToJsonElement(candidate) + json.encodeToString(JsonElement.serializer(), jsonElement) + }.getOrNull() + } + + private fun String.isJsonObjectOrArray(): Boolean = (startsWith("{") && endsWith("}")) || (startsWith("[") && endsWith("]")) + + private companion object { + const val TAG = "KTOR-LOG" + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt index eaedd5ab..abdd00fe 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -4,6 +4,7 @@ import com.team.prezel.core.network.model.auth.LoginRequest import com.team.prezel.core.network.model.auth.LoginResponse import com.team.prezel.core.network.model.auth.WithdrawRequest import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.model.requireSuccess import com.team.prezel.core.network.service.AuthService import javax.inject.Inject @@ -11,7 +12,7 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( private val authService: AuthService, ) : AuthRemoteDataSource { override suspend fun logout() { - authService.logout().requireData() + authService.logout().requireSuccess() } override suspend fun login(idToken: String): LoginResponse = authService.login(request = LoginRequest(idToken = idToken)).requireData() @@ -23,6 +24,6 @@ internal class AuthRemoteDataSourceImpl @Inject constructor( authService .withdraw( request = WithdrawRequest(reasonCategory = reasonCategory, reasonText = reasonText), - ).requireData() + ).requireSuccess() } } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt index 0d9e6467..86b6b403 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt @@ -10,21 +10,29 @@ data class BaseResponse( @SerialName("status") val status: Int, @SerialName("code") - val code: String, + val code: String?, @SerialName("data") - val data: T, + val data: T?, @SerialName("message") - val message: String, + val message: String?, ) internal fun BaseResponse.requireData(): T { - if (status !in 200..299) { - throw ApiException( - status = status, - errorCode = ServerErrorCode.from(code), - message = message, - ) - } + requireSuccess() - return data + return data ?: throw ApiException( + status = status, + errorCode = ServerErrorCode.UNKNOWN, + message = "Response data is null", + ) +} + +internal fun BaseResponse<*>.requireSuccess() { + if (status in 200..299) return + + throw ApiException( + status = status, + errorCode = ServerErrorCode.from(code), + message = message.orEmpty(), + ) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt index b5008e0f..b5eab043 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -14,7 +14,7 @@ import de.jensklingenberg.ktorfit.http.Tag internal interface AuthService { @POST("auth/logout") - suspend fun logout(): BaseResponse + suspend fun logout(): BaseResponse @POST("auth/login") suspend fun login( @@ -25,7 +25,7 @@ internal interface AuthService { @DELETE("auth/withdraw") suspend fun withdraw( @Body request: WithdrawRequest, - ): BaseResponse + ): BaseResponse @POST("auth/reissue") suspend fun reissue( From 7e1bd7688dd3cd78038a8c3e2bd7b2ed9e9d1050 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 30 Apr 2026 17:21:09 +0900 Subject: [PATCH 59/63] =?UTF-8?q?refactor:=20KtorPrettyLogger=20=EC=8B=B1?= =?UTF-8?q?=EA=B8=80=ED=86=A4=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `KtorPrettyLogger`를 `object`로 변경 및 내부 구성 최적화** * 외부에서 `Json` 인스턴스를 주입받는 대신, 클래스를 `object`로 전환하고 내부에 전용 `Json` 설정을 정의했습니다. * 가독성 향상을 위해 `prettyPrintIndent`를 탭(`\t`)으로 설정하고 `isLenient = true`를 적용했습니다. * `formatJsonLogMessage`, `toPrettyJsonOrNull` 등의 내부 함수명을 `toPrettyLogMessage`, `parsePrettyJson` 등으로 변경하고 구현 로직을 간결하게 다듬었습니다. * **refactor: `HttpClientFactory` 내 로거 사용 방식 수정** * `HttpClientFactory` 내에서 별도로 생성하던 `ktorLogger` 프로퍼티를 제거했습니다. * `installLogging` 설정 시 `KtorPrettyLogger` 싱글톤 객체를 직접 사용하도록 변경했습니다. * **cleanup: 테스트 패키지 경로 유지를 위한 파일 추가** * `core:network` 모듈의 테스트 디렉토리 구조를 유지하기 위해 `.gitkeep` 파일을 추가했습니다. --- .../core/network/client/HttpClientFactory.kt | 4 +- .../core/network/client/KtorPrettyLogger.kt | 43 +++++++++++-------- .../com/team/prezel/core/network/.gitkeep | 0 3 files changed, 25 insertions(+), 22 deletions(-) create mode 100644 Prezel/core/network/src/test/java/com/team/prezel/core/network/.gitkeep diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt index c2f88b7a..8778808a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt @@ -47,8 +47,6 @@ internal class HttpClientFactory @Inject constructor( prettyPrint = true } - private val ktorLogger = KtorPrettyLogger(networkJson) - fun create( block: HttpClientConfig<*>.() -> Unit = { configureDefaultRequest() @@ -79,7 +77,7 @@ internal class HttpClientFactory @Inject constructor( internal fun HttpClientConfig<*>.installLogging() { install(Logging) { - logger = ktorLogger + logger = KtorPrettyLogger sanitizeHeader { header -> header == HttpHeaders.Authorization } level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt index e0088c28..e6026b03 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt @@ -1,37 +1,42 @@ package com.team.prezel.core.network.client import io.ktor.client.plugins.logging.Logger +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement import timber.log.Timber -internal class KtorPrettyLogger( - private val json: Json, -) : Logger { +@OptIn(ExperimentalSerializationApi::class) +internal object KtorPrettyLogger : Logger { + private val json = Json { + prettyPrint = true + prettyPrintIndent = "\t" + isLenient = true + } + override fun log(message: String) { - Timber.tag(TAG).d(message.formatJsonLogMessage()) + Timber.tag(TAG).d(message.toPrettyLogMessage()) } - private fun String.formatJsonLogMessage(): String = - toPrettyJsonOrNull() - ?: lineSequence() - .joinToString(separator = "\n") { line -> - line.toPrettyJsonOrNull() ?: line - } + private fun String.toPrettyLogMessage(): String = + parsePrettyJson() ?: lines() + .joinToString(separator = "\n") { line -> + line.parsePrettyJson() ?: line + } - private fun String.toPrettyJsonOrNull(): String? { + private fun String.parsePrettyJson(): String? { val candidate = trim() - if (!candidate.isJsonObjectOrArray()) return null + + if (!candidate.looksLikeJson()) return null return runCatching { - val jsonElement = json.parseToJsonElement(candidate) - json.encodeToString(JsonElement.serializer(), jsonElement) + val element = json.parseToJsonElement(candidate) + json.encodeToString(element) }.getOrNull() } - private fun String.isJsonObjectOrArray(): Boolean = (startsWith("{") && endsWith("}")) || (startsWith("[") && endsWith("]")) + private fun String.looksLikeJson(): Boolean = + (startsWith("{") && endsWith("}")) || + (startsWith("[") && endsWith("]")) - private companion object { - const val TAG = "KTOR-LOG" - } + private const val TAG = "KTOR-LOG" } diff --git a/Prezel/core/network/src/test/java/com/team/prezel/core/network/.gitkeep b/Prezel/core/network/src/test/java/com/team/prezel/core/network/.gitkeep new file mode 100644 index 00000000..e69de29b From ee3a53e27e809868a26795cbd36a4c6adb9acf5e Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 30 Apr 2026 17:29:44 +0900 Subject: [PATCH 60/63] =?UTF-8?q?style:=20SplashViewModel=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=B0=8F=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **style: `SplashViewModel` 클래스 포맷팅 및 가독성 향상** * `BaseViewModel` 초기화 식의 불필요한 줄 바꿈을 제거하고 한 줄로 정리했습니다. * `checkLoginStatus` 함수 내에서 `first` 연산자의 람다 매개변수 이름을 `it`에서 `status`로 명시하여 로직의 명확성을 높였습니다. * `viewModelScope.launch` 블록 및 메서드 호출부의 들여쓰기와 코드 형식을 정리했습니다. --- .../com/team/prezel/feature/splash/impl/SplashViewModel.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index a1295b37..3378e332 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -15,9 +15,7 @@ import javax.inject.Inject @HiltViewModel internal class SplashViewModel @Inject constructor( private val checkLoginStatusUseCase: CheckLoginStatusUseCase, -) : BaseViewModel( - SplashUiState(), - ) { +) : BaseViewModel(SplashUiState()) { override fun onIntent(intent: SplashUiIntent) { when (intent) { SplashUiIntent.CheckLoginStatus -> checkLoginStatus() @@ -29,7 +27,7 @@ internal class SplashViewModel @Inject constructor( viewModelScope .launch { - when (checkLoginStatusUseCase().first { it != LoginStatus.LOADING }) { + when (checkLoginStatusUseCase().first { status -> status != LoginStatus.LOADING }) { LoginStatus.AUTHENTICATED -> sendEffect(SplashUiEffect.NavigateToHome) LoginStatus.UNAUTHENTICATED -> sendEffect(SplashUiEffect.NavigateToLogin) LoginStatus.LOADING -> Unit From 4561d483910e17037b7c1beeaa13b1702d7396b2 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 30 Apr 2026 19:44:32 +0900 Subject: [PATCH 61/63] =?UTF-8?q?feat:=20Logger=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?PrettyLoggerTree=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `orhanobut/logger` 라이브러리 의존성 추가** * 로그 가독성을 향상시키기 위해 `orhanobut/logger` 라이브러리를 도입했습니다. * `libs.versions.toml` 및 `app/build.gradle.kts`에 관련 의존성을 정의했습니다. * **feat: Timber 연동을 위한 `PrettyLoggerTree` 클래스 추가** * Timber의 로그를 `orhanobut/logger` 형식으로 출력하기 위해 `Timber.DebugTree`를 상속받은 `PrettyLoggerTree`를 구현했습니다. * 스레드 정보 표시 안 함, 메서드 카운트 0, 기본 태그 "PREZEL" 설정 등 커스텀 포맷 전략을 적용했습니다. --- Prezel/app/build.gradle.kts | 1 + .../java/com/team/prezel/PrezelApplication.kt | 5 ++- .../com/team/prezel/util/PrettyLoggerTree.kt | 31 +++++++++++++++++++ Prezel/gradle/libs.versions.toml | 2 ++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 Prezel/app/src/main/java/com/team/prezel/util/PrettyLoggerTree.kt diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index a4f876e6..5237d05f 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.navigation3.ui) + implementation(libs.orhanobut.logger) implementation(libs.timber) implementation(libs.kotlinx.collections.immutable) } diff --git a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt index 4a7ff0bb..9a894668 100644 --- a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt +++ b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt @@ -2,6 +2,7 @@ package com.team.prezel import android.app.Application import com.team.prezel.core.auth.AuthInitializer +import com.team.prezel.util.PrettyLoggerTree import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -9,10 +10,8 @@ import timber.log.Timber class PrezelApplication : Application() { override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - } + Timber.plant(PrettyLoggerTree()) AuthInitializer.init(this) } } diff --git a/Prezel/app/src/main/java/com/team/prezel/util/PrettyLoggerTree.kt b/Prezel/app/src/main/java/com/team/prezel/util/PrettyLoggerTree.kt new file mode 100644 index 00000000..23929804 --- /dev/null +++ b/Prezel/app/src/main/java/com/team/prezel/util/PrettyLoggerTree.kt @@ -0,0 +1,31 @@ +package com.team.prezel.util + +import com.orhanobut.logger.AndroidLogAdapter +import com.orhanobut.logger.Logger +import com.orhanobut.logger.PrettyFormatStrategy +import timber.log.Timber + +internal class PrettyLoggerTree : Timber.DebugTree() { + init { + Logger.clearLogAdapters() + Logger.addLogAdapter( + AndroidLogAdapter( + PrettyFormatStrategy + .newBuilder() + .showThreadInfo(false) + .methodCount(0) + .tag("PREZEL") + .build(), + ), + ) + } + + override fun log( + priority: Int, + tag: String?, + message: String, + t: Throwable?, + ) { + Logger.log(priority, tag, message, t) + } +} diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index e38ba928..0b64faba 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -20,6 +20,7 @@ desugarJdk = "2.1.5" ktor = "3.4.3" ktorfit = "2.7.2" timber = "5.0.1" +orhanobutLogger = "2.2.0" navigation3 = "1.0.0" kotlinxDatetime = "0.7.1" kotlinxCoroutines = "1.10.2" @@ -66,6 +67,7 @@ ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } ktorfit-ksp = { module = "de.jensklingenberg.ktorfit:ktorfit-ksp", version.ref = "ktorfit" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +orhanobut-logger = { group = "com.orhanobut", name = "logger", version.ref = "orhanobutLogger" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } From 36320daffc5b8beb5bc89a5ef1a14d20b5ca6e4d Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 30 Apr 2026 19:57:01 +0900 Subject: [PATCH 62/63] =?UTF-8?q?refactor:=20KtorPrettyLogger=20=EB=84=A4?= =?UTF-8?q?=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EB=A1=9C=EA=B7=B8=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=EB=B0=8F=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `KtorPrettyLogger` 로그 출력 구조 고도화** * 로그 메시지 전체를 파싱하던 방식에서 `BODY START`와 `BODY END` 구간의 JSON 데이터만 추출하여 Pretty Print를 적용하도록 개선했습니다. * `formatSections` 메서드를 도입하여 불필요한 헤더 마커(`COMMON HEADERS`, `CONTENT HEADERS` 등)를 제거하고 줄 바꿈을 조정하여 가독성을 높였습니다. * **style: 네트워크 로그 시각적 식별자 추가 및 태그 변경** * 요청(🚀)과 응답(📥) 메시지를 쉽게 구분할 수 있도록 이모지 헤더를 추가했습니다. * 로그 태그를 `KTOR-LOG`에서 `NETWORK`로 변경하고, 로직 내 사용되는 문자열들을 상수로 구조화했습니다. * `looksLikeJson` 등 유틸리티 함수의 구현을 간결하게 정리했습니다. --- .../core/network/client/KtorPrettyLogger.kt | 78 ++++++++++++++++--- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt index e6026b03..0312cdbb 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt @@ -7,6 +7,16 @@ import timber.log.Timber @OptIn(ExperimentalSerializationApi::class) internal object KtorPrettyLogger : Logger { + private const val TAG = "NETWORK" + private const val REQUEST = "REQUEST:" + private const val RESPONSE = "RESPONSE:" + private const val REQUEST_EMOJI = "🚀" + private const val RESPONSE_EMOJI = "📥" + private const val COMMON_HEADERS = "COMMON HEADERS" + private const val CONTENT_HEADERS = "CONTENT HEADERS" + private const val BODY_START = "BODY START" + private const val BODY_END = "BODY END" + private val json = Json { prettyPrint = true prettyPrintIndent = "\t" @@ -14,18 +24,68 @@ internal object KtorPrettyLogger : Logger { } override fun log(message: String) { - Timber.tag(TAG).d(message.toPrettyLogMessage()) + Timber.tag(TAG).d( + message + .prettifyBodyJson() + .formatSections(), + ) + } + + private fun String.prettifyBodyJson(): String { + val original = this + + val bodyStartIndex = original.indexOf(BODY_START) + if (bodyStartIndex == -1) return original + + val bodyEndIndex = original.indexOf(BODY_END, startIndex = bodyStartIndex) + if (bodyEndIndex == -1) return original + + val body = original + .substring( + startIndex = bodyStartIndex + BODY_START.length, + endIndex = bodyEndIndex, + ).trim() + + val prettyBody = body.parsePrettyJson() ?: body + + return buildString { + append(original.substring(startIndex = 0, endIndex = bodyStartIndex)) + appendLine(BODY_START) + appendLine(prettyBody) + append(BODY_END) + append(original.substring(startIndex = bodyEndIndex + BODY_END.length)) + } } - private fun String.toPrettyLogMessage(): String = - parsePrettyJson() ?: lines() - .joinToString(separator = "\n") { line -> - line.parsePrettyJson() ?: line + private fun String.formatSections(): String { + if (!isKtorHttpLog()) return this + + return buildList { + lines().forEachIndexed { index, line -> + when { + index != 0 && line.isSkipMarker() -> Unit + index != 0 && line.isBlankLineMarker() -> add("") + else -> add(line.withEmojiHeader()) + } } + }.joinToString(separator = "\n") + } + + private fun String.isKtorHttpLog(): Boolean = contains(REQUEST) || contains(RESPONSE) + + private fun String.isSkipMarker(): Boolean = startsWith(COMMON_HEADERS) || startsWith(CONTENT_HEADERS) || startsWith(BODY_END) + + private fun String.isBlankLineMarker(): Boolean = startsWith(BODY_START) + + private fun String.withEmojiHeader(): String = + when { + startsWith(REQUEST) -> replaceFirst(REQUEST, "$REQUEST_EMOJI $REQUEST") + startsWith(RESPONSE) -> replaceFirst(RESPONSE, "$RESPONSE_EMOJI $RESPONSE") + else -> this + } private fun String.parsePrettyJson(): String? { val candidate = trim() - if (!candidate.looksLikeJson()) return null return runCatching { @@ -34,9 +94,5 @@ internal object KtorPrettyLogger : Logger { }.getOrNull() } - private fun String.looksLikeJson(): Boolean = - (startsWith("{") && endsWith("}")) || - (startsWith("[") && endsWith("]")) - - private const val TAG = "KTOR-LOG" + private fun String.looksLikeJson(): Boolean = (startsWith("{") && endsWith("}")) || (startsWith("[") && endsWith("]")) } From 6e7516aa91deaabf0e25c3fc640fb1dc8aac1835 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 30 Apr 2026 19:57:15 +0900 Subject: [PATCH 63/63] =?UTF-8?q?refactor:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=ED=99=94=EB=A9=B4=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?=EB=8D=B0=EC=BD=94=EB=A0=88=EC=9D=B4=ED=84=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **cleanup: `LoggingDecorator` 클래스 삭제** * 화면 진입(`SCREEN_ENTER`) 및 이탈(`SCREEN_EXIT`) 시 Timber를 통해 로그를 출력하던 `LoggingDecorator` 클래스를 삭제했습니다. * **refactor: `NavigationState` 내 데코레이터 리스트 수정** * `rememberPrezelNavigationState` 함수에서 더 이상 사용하지 않는 `LoggingDecorator` 구성을 제거하고 불필요한 import를 정리했습니다. --- .../prezel/core/navigation/NavigationState.kt | 2 -- .../navigation/decorator/LoggingDecorator.kt | 26 ------------------- 2 files changed, 28 deletions(-) delete mode 100644 Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/decorator/LoggingDecorator.kt diff --git a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt b/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt index abe707f1..fe306753 100644 --- a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt +++ b/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt @@ -13,7 +13,6 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import com.team.prezel.core.navigation.decorator.LoggingDecorator import kotlinx.collections.immutable.ImmutableSet /** @@ -84,7 +83,6 @@ fun NavigationState.toEntries(entryProvider: (NavKey) -> NavEntry): Snap val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), - LoggingDecorator(), ) rememberDecoratedNavEntries( diff --git a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/decorator/LoggingDecorator.kt b/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/decorator/LoggingDecorator.kt deleted file mode 100644 index 19369194..00000000 --- a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/decorator/LoggingDecorator.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.team.prezel.core.navigation.decorator - -import androidx.compose.runtime.DisposableEffect -import androidx.navigation3.runtime.NavEntryDecorator -import timber.log.Timber - -private const val NAVIGATION_LOGGING_TAG = "Navigation" - -class LoggingDecorator : - NavEntryDecorator( - decorate = { entry -> - DisposableEffect(entry.contentKey, entry.metadata) { - Timber - .tag(NAVIGATION_LOGGING_TAG) - .d("SCREEN_ENTER | screen=${entry.contentKey} | metadata=${entry.metadata}") - - onDispose { - Timber - .tag(NAVIGATION_LOGGING_TAG) - .d(message = "SCREEN_EXIT | screen=${entry.contentKey} | metadata=${entry.metadata}") - } - } - - entry.Content() - }, - )