Skip to content

feat: 로그인-로그인 구현(#27)#34

Open
dada4679 wants to merge 9 commits into
developfrom
feature/27-kakao-login-token
Open

feat: 로그인-로그인 구현(#27)#34
dada4679 wants to merge 9 commits into
developfrom
feature/27-kakao-login-token

Conversation

@dada4679

@dada4679 dada4679 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

📄 작업 내용 요약

-카카오 소셜 로그인 연동
-토큰 재발급 로직 구현
-서버 인증 토큰 관리


📎 Issue 번호


✅ 작업 목록

  • 기능 구현
  • 코드 리뷰 반영
  • 테스트 코드 작성
  • 문서 업데이트

📝 기타 참고사항

Summary by CodeRabbit

  • 새로운 기능
    • 카카오 로그인(카카오톡/카카오계정) 및 웹 인증 연동을 추가했습니다.
    • 약관 목록 조회, 개별/전체 동의, 약관 상세 보기를 제공합니다.
  • 개선 사항
    • 앱 실행 시 저장된 토큰 상태에 따라 자동으로 로그인/홈으로 이동합니다.
    • 인증 만료 시 토큰을 자동 재발급하고 토큰 저장/정리를 강화했습니다.
    • 로그인 및 약관 처리 실패 시 오류를 명확히 안내합니다.

@dada4679 dada4679 linked an issue Jul 24, 2026 that may be closed by this pull request
4 tasks
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

카카오 SDK 로그인과 서버 인증을 연결하고, 토큰 저장·재발급 및 자동 로그인을 구현했습니다. 약관 조회·동의 상태 관리와 랜딩부터 로그인·약관 화면까지의 내비게이션 흐름도 추가했습니다.

Changes

카카오 인증 및 로그인 흐름

Layer / File(s) Summary
SDK 빌드 및 초기화
app/build.gradle.kts, app/src/main/AndroidManifest.xml, app/src/main/java/.../App.kt, gradle/libs.versions.toml, settings.gradle.kts, feature/login/impl/build.gradle.kts
카카오 네이티브 앱 키 주입, SDK 초기화, OAuth 콜백 액티비티, 카카오 SDK 의존성이 추가되었습니다.
토큰 저장 및 인증 네트워크
core/datastore/..., core/network/...
DataStore 토큰 관리, 인증 헤더 주입, 401 토큰 재발급 및 전용 Retrofit 구성이 추가되었습니다.
로그인 및 약관 데이터 계층
core/domain/..., core/network/..., core/data/...
로그인·약관 계약과 DTO, Retrofit API, Repository 구현 및 Hilt 바인딩이 추가되었습니다.
랜딩 및 로그인 상태 흐름
feature/login/impl/.../auth/*, feature/login/impl/.../viewmodel/*
카카오톡 로그인 폴백, 서버 로그인 처리, 토큰 기반 자동 로그인 및 Orbit 상태·사이드 이펙트가 추가되었습니다.
약관 및 로그인 내비게이션
feature/login/api/..., feature/login/impl/..., app/src/main/java/.../AppScreen.kt
동적 약관 목록·동의 제출과 랜딩·로그인·약관·약관 상세 화면의 Navigation3 연결이 구현되었습니다.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: jiwonlee42

🚥 Pre-merge checks | ✅ 4 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Ui 변경 시 스크린샷 첨부 확인 ⚠️ Warning UI 변경(AppScreen.kt)이 포함됐지만 PR 설명에 스크린샷/화면 녹화 첨부가 없습니다. PR 설명에 변경 화면의 스크린샷 또는 짧은 녹화(GIF/영상)와 링크를 첨부해 주세요.
모듈 의존성 방향 검증 ⚠️ Warning feature/login/impl이 TokenDataStore와 project(":core:data")에 직접 의존해 domain 인터페이스만 쓰지 않습니다. feature 모듈에서 core:data/core:datastore 직접 의존을 제거하고, 필요한 상태는 domain 인터페이스와 앱/데이터 계층 바인딩으로 주입하세요.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed 카카오 로그인, 토큰 저장/재발급, 인증 인터셉터, 자동 로그인 구현이 모두 포함됩니다.
Out of Scope Changes check ✅ Passed 변경 사항은 로그인 연동과 이를 뒷받침하는 빌드/네비게이션/DI 구성으로 보이며, 뚜렷한 무관 변경은 없습니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Conventional Commits 형식을 따르며 feat 타입과 변경 내용을 반영한 설명이 포함되어 있습니다.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/27-kakao-login-token

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Gemini AI 코드리뷰

안녕하세요, 시니어 Android 개발자입니다. 이번 PR은 카카오 로그인, 약관 동의, 그리고 토큰 관리를 위한 네트워크 설정을 포함하는 중요한 기능 개발이네요. 전반적으로 코드 품질이 좋고, 아키텍처 원칙을 잘 따르려고 노력한 점이 보입니다. 특히 Orbit MVI 패턴과 Jetpack Compose의 상태 관리를 잘 적용하려 한 부분이 인상 깊습니다. 몇 가지 개선할 점과 질문이 있어 코멘트 남깁니다.


1. Kotlin 코드 리뷰

  • Coroutine 사용 시 Dispatcher 명시 여부, viewModelScope/lifecycleScope 오남용
    • AuthInterceptorTokenAuthenticator에서 runBlocking을 사용하여 TokenDataStoresuspend 함수를 호출하는 부분은 InterceptorAuthenticator가 동기적으로 동작해야 하는 OkHttp의 제약 때문에 불가피한 선택으로 보입니다. 이 경우 runBlocking 사용은 허용할 수 있습니다. 하지만 일반적인 비즈니스 로직에서는 viewModelScopelifecycleScope를 사용하여 코루틴을 관리하고, 필요한 경우 withContext(Dispatchers.IO) 등으로 Dispatcher를 명시하는 것이 좋습니다.
    • AgreementViewModelintent 블록 내에서 runCatching을 사용한 것은 좋습니다. Orbit intent는 기본적으로 Dispatchers.Default에서 실행되지만, 내부의 suspend 함수들은 자신에게 맞는 Dispatcher(예: 네트워크 호출은 Dispatchers.IO)로 전환될 것이므로 명시적인 Dispatcher 지정이 필수적이지는 않습니다.
  • Flow/StateFlow 사용 시 collect 시점 (repeatOnLifecycle 사용 여부)
    • Jetpack Compose에서 Orbit의 collectAsState()collectSideEffect()를 사용하는 것은 Composable의 Lifecycle을 인식하여 자동으로 repeatOnLifecycle과 유사하게 동작하므로 적절합니다. 불필요한 리소스 낭비 없이 UI 상태와 SideEffect를 안전하게 수집하고 있습니다.
  • null 안전성 (!! 사용 지양)
    • response.result.orEmpty(), response.result ?: throw IllegalStateException(...), error.message ?: "...", requestAccessToken ?: return null!! 연산자 사용을 잘 지양하고 null 안전성을 확보하려는 노력이 돋보입니다. 훌륭합니다.
  • data class에 불필요한 var 사용 여부
    • core/domaincore/network의 모든 data classval만 사용되었습니다. 불필요한 var 사용 없이 잘 정의되었습니다.
  • Hilt DI 시 생성자 주입 원칙 준수 여부
    • @Inject constructor()를 통한 생성자 주입 원칙을 잘 준수하고 있습니다. RepositoryModule, DataStoreModule, NetworkModule에서 인터페이스와 써드파티 라이브러리(Retrofit, OkHttpClient, DataStore)에 대한 Provider를 제공하는 방식도 올바릅니다.
  • 하드코딩된 문자열/매직 넘버
    • KAKAO_NATIVE_APP_KEY: local.properties에서 로드하고 BuildConfigAndroidManifest.xml에 잘 적용했습니다.
    • core/network/src/main/java/kr/co/call/network/di/NetworkModule.ktbaseUrl: https://api.lovecall.example.com/api/v1/ 이 부분은 하드코딩된 문자열입니다. 빌드 타입(debug/release) 또는 환경에 따라 변경될 수 있는 부분이므로 BuildConfigField로 주입하거나, 별도의 설정 파일을 통해 관리하는 것이 좋습니다. (예: BuildConfig.BASE_URL)

2. Jetpack Compose 코드 리뷰

  • Composable 함수의 불필요한 recomposition 유발 여부 (remember, key 사용)
    • LoginEntryBuilder에서 remember { KakaoLoginManager() }를 사용하여 불필요한 객체 생성을 막은 점, AgreementScreen에서 rememberScrollState()를 사용한 점 등 remember를 적절히 활용하여 recomposition 성능을 고려했습니다.
    • Orbit의 collectAsStatecollectSideEffect는 Composable의 Lifecycle을 인식하므로 recomposition 최적화에 기여합니다.
  • State hoisting 원칙 준수
    • AgreementScreenLoginScreen 모두 UI 상태(uiState)와 이벤트 핸들러(콜백 람다)를 ViewModel에서 받아 사용하고, 내부적으로 상태를 직접 변경하지 않는 State hoisting 원칙을 잘 준수했습니다.
    • AppScreen에서 loginEntry에 다양한 콜백을 전달하여 상위 컴포넌트에서 내비게이션을 관리하도록 한 점도 좋습니다.
  • side effect (LaunchedEffect, DisposableEffect) 사용의 적절성
    • 직접적인 LaunchedEffectDisposableEffect 사용은 보이지 않지만, Orbit의 collectSideEffect를 통해 AgreementSideEffect, LoginSideEffect, LandingSideEffect를 처리하고 있습니다. 이는 Compose 컴포넌트 내부에서 직접 Side Effect를 처리하기보다 ViewModel에서 관리하도록 하여 책임 분리를 명확히 한 좋은 방식입니다.
  • UI 상태를 data class로 표현했는지
    • feature/login/impl/viewmodel/state/AgreementUiState.kt 파일로 UI 상태를 data class로 표현한 것은 매우 훌륭합니다. terms, checkedTermIds, isLoading 등 UI를 그리는 데 필요한 모든 정보를 담고 있습니다.
  • 로딩/에러 상태를 Boolean 대신 LoadStatus로 관리하는지
    • AgreementUiState에서 isLoading: Boolean으로 로딩 상태를 관리하고 있습니다. LoadStatus (예: data class LoadStatus<T>(val status: Status, val data: T? = null, val error: Throwable? = null))와 같이 더 풍부한 상태 관리 패턴을 사용하는 것이 좋지만, 현재 PR의 범위에서는 Boolean으로 관리하는 것도 허용 가능한 수준입니다. 에러는 AgreementSideEffect.ShowError로 한 번만 전달하고 있습니다.

3. Repository/DataSource 레이어

  • Retrofit 에러 핸들링 (try-catch, Result 래핑)
    • AgreementRepositoryImplLoginRepositoryImpl에서 네트워크 응답(response.isSuccess)이 실패할 경우 throw IllegalStateException(response.message)를 직접 던지고 있습니다. ViewModel에서 runCatching으로 이 예외를 잡고 있지만, Repository 레이어에서 네트워크 응답을 Result 타입(예: Result<DomainModel>)으로 래핑하여 반환하는 것을 강력히 권장합니다. 이렇게 하면 Repository가 네트워크 계층의 에러를 더 의미 있는 형태로 변환하여 도메인/ViewModel 레이어로 전달할 수 있으며, ViewModel에서는 onSuccessonFailure 블록에서 더욱 명확하게 성공/실패 로직을 분리할 수 있습니다. 현재 방식은 Raw Exception이 상위 레이어로 전파될 가능성이 있습니다.
    • 개선 제안: NetworkResult와 같은 sealed class를 만들어 Success(data: T), Error(exception: Throwable, code: Int?) 등으로 나누어 반환하는 방식도 고려해볼 수 있습니다.
  • 네트워크 응답과 도메인 모델 매핑 분리 여부
    • LoginRepositoryImpl에서 LoginTokenResultLoginToken으로, AgreementRepositoryImpl에서 TermDtoAgreementTerm으로 매핑하는 작업을 수행하고 있습니다. 네트워크 응답 DTO를 도메인 모델로 변환하는 책임 분리가 잘 이루어졌습니다.
  • 캐싱 전략 (로컬 DB vs 메모리)
    • TokenDataStore를 사용하여 Access Token과 Refresh Token을 로컬에 저장하는 전략은 매우 적절합니다. DataStore는 PreferencesDataStore를 통해 키-값 형태로 데이터를 안전하게 저장하는 좋은 방법입니다.

4. ViewModel

  • UI 상태와 비즈니스 로직 분리
    • AgreementViewModelAgreementUiState를 관리하고, loadTerms(), toggleAgreement(), submitAgreements() 등 비즈니스 로직을 intent 함수 내에 잘 캡슐화했습니다. UI 상태와 비즈니스 로직 분리가 잘 되어 있습니다.
  • Orbit ContainerHost 패턴 준수 여부
    • AgreementViewModelContainerHost 인터페이스를 구현하고 container(), intent(), reduce(), postSideEffect() 등 Orbit MVI 패턴을 정확하게 준수하고 있습니다. 매우 훌륭합니다.
  • intent 내부에서 상태 변경(reduce)만 수행하는지
    • intent 함수 내부에서 reduce 블록은 오직 상태 변경(state.copy(...))만 담당하고, 네트워크 호출과 같은 비동기/블로킹 작업은 runCatching 블록 내에서 reduce 외부에서 처리됩니다. Orbit 패턴을 잘 이해하고 적용했습니다.
  • postSideEffect 남용 여부
    • AgreementSideEffect.NavigateToNextAgreementSideEffect.ShowError는 일회성 이벤트를 나타내므로 postSideEffect 사용이 적절합니다. 남용된 부분은 보이지 않습니다.
  • blocking 작업을 intent 내부에서 직접 수행하지 않는지
    • 네트워크 호출과 같은 블로킹 작업은 intent 블록 내에서 runCatching으로 감싸져 수행되므로, ViewModel의 메인 스레드를 블로킹하지 않습니다. reduce 블록 외부에서 처리되는 방식은 올바른 패턴입니다.

5. FCM/SSE/실시간 통신 관련 코드

  • 해당 PR에는 FCM/SSE/실시간 통신 관련 코드가 포함되어 있지 않아 리뷰에서 제외됩니다.

전반적인 의견 및 추가 제안

  • TokenAuthenticator의 tokenReissueApi 주입: TokenAuthenticatorTokenReissueApi를 주입받는데, 이때 Lazy<TokenReissueApi>로 주입받는 것을 고려해볼 수 있습니다. OkHttpClient 빌딩 과정에서 AuthInterceptorTokenAuthenticator가 순환 참조를 일으킬 가능성이 있다면 Lazy를 사용해야 합니다. 현재 구조에서는 TokenReissueApi@Named("reissueRetrofit")을 사용하여 일반 Retrofit 인스턴스와 다른 OkHttpClient를 사용하므로 직접 참조가 발생하지 않을 수 있습니다. 하지만 일반적으로 Authenticator에서 다른 Retrofit API를 호출할 때는 Lazy를 사용하여 잠재적인 순환 의존성 문제를 예방하는 것이 좋은 관행입니다.
  • AgreementViewModel.toggleAllAgreements(isChecked: Boolean) 함수 누락: AgreementScreenonAllAgreementsCheckedChange 콜백에 대한 AgreementViewModeltoggleAllAgreements 함수가 PR diff에 보이지 않습니다. 이 함수를 추가하여 모든 약관 동의/해제 로직을 처리해야 합니다.

PR 작성자에게 묻는 질문:

  1. NetworkModule.ktbaseUrlBuildConfigField로 관리하지 않고 하드코딩한 특별한 이유가 있으신가요?
  2. Repository 레이어에서 네트워크 응답 에러를 Result 타입으로 래핑하여 반환하는 대신 IllegalStateException을 직접 던진 이유가 궁금합니다. 현재 ViewModel에서 runCatching으로 처리하고 있지만, Repository가 더 낮은 수준의 에러 처리를 담당하여 ViewModel의 책임과 역할을 좀 더 명확히 분리할 수 있습니다.

결론적으로, 이번 PR은 매우 잘 작성되었습니다. 위에서 언급한 몇 가지 개선 사항(특히 baseUrl 하드코딩과 Repository의 에러 핸들링)을 반영하면 더욱 견고하고 유지보수하기 쉬운 코드가 될 것입니다.

수고 많으셨습니다!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt (1)

164-170: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

약관을 받기 전에는 다음 단계로 진행하지 못하게 하세요.

terms가 비어 있으면 isRequiredChecked가 true가 되어 로딩/조회 실패 상태에서도 submitAgreements()를 실행할 수 있습니다. uiState.terms.isNotEmpty()를 함께 확인하고, 제출 측에서도 필수 약관을 검증하세요. 제공된 AgreementUiState 정의에서 빈 컬렉션의 all이 true가 되는 것을 확인했습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt`
around lines 164 - 170, Update the NextButton enabled condition to require both
uiState.isRequiredChecked and uiState.terms.isNotEmpty(), preventing progression
without loaded terms. Also add the same non-empty terms validation in the
submitAgreements() flow so submission remains blocked even if the UI state is
bypassed.
🧹 Nitpick comments (4)
core/domain/src/main/java/kr/co/call/domain/model/login/LoginToken.kt (1)

7-10: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

토큰을 보유한 모든 data class의 기본 toString()을 차단하세요.

기본 toString()이 토큰 원문을 출력하므로 객체가 로그나 예외 메시지에 포함될 때 인증 정보가 유출될 수 있습니다.

  • core/domain/src/main/java/kr/co/call/domain/model/login/LoginToken.kt#L7-L10: 토큰을 마스킹한 toString()을 구현하세요.
  • core/network/src/main/java/kr/co/call/network/dto/login/LoginRequestDto.kt#L7-L10: access token을 마스킹하거나 DTO 로깅을 금지하세요.
  • core/network/src/main/java/kr/co/call/network/dto/login/LoginResponseDto.kt#L7-L20: LoginResponseDtoLoginTokenResult 모두 토큰을 마스킹하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/domain/src/main/java/kr/co/call/domain/model/login/LoginToken.kt` around
lines 7 - 10, Override the default toString() for every token-bearing data class
so raw credentials are never exposed. In
core/domain/src/main/java/kr/co/call/domain/model/login/LoginToken.kt:7-10, mask
both tokens; in
core/network/src/main/java/kr/co/call/network/dto/login/LoginRequestDto.kt:7-10,
mask the access token or prevent DTO logging; and in
core/network/src/main/java/kr/co/call/network/dto/login/LoginResponseDto.kt:7-20,
mask tokens in both LoginResponseDto and LoginTokenResult.
settings.gradle.kts (1)

24-26: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Kakao 저장소에 콘텐츠 필터를 추가하세요.

현재 저장소가 모든 group에 대해 활성화되어 있어, 향후 의존성 추가 시 불필요한 저장소 탐색 및 dependency-confusion 위험이 커집니다. com.kakao.sdk만 허용하도록 제한하세요.

제안
         maven {
-            url=uri("https://devrepo.kakao.com/nexus/content/groups/public/")
+            url = uri("https://devrepo.kakao.com/nexus/content/groups/public/")
+            content {
+                includeGroup("com.kakao.sdk")
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@settings.gradle.kts` around lines 24 - 26, Restrict the Kakao Maven
repository declaration to the com.kakao.sdk group by adding an exclusive content
filter around the repository in the dependency repositories configuration. Keep
the existing repository URL unchanged and ensure other dependency groups
continue using the appropriate repositories.
core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt (1)

20-28: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

TokenAuthenticator@Singleton 스코프를 명시하는 것을 권장합니다.

refreshLock은 앱 전체에서 동시 토큰 재발급을 막기 위한 목적인데, 클래스에 스코프가 지정되지 않아 Dagger가 다른 주입 지점에서 별도 인스턴스를 생성하면 락이 인스턴스별로 분리되어 동기화가 무력화될 수 있습니다. 현재는 NetworkModule의 한 곳에서만 주입되어 문제가 드러나지 않지만, 명시적으로 @Singleton을 붙여 의도를 고정하는 것이 안전합니다.

♻️ 제안하는 수정
+@Singleton
 class TokenAuthenticator `@Inject` constructor (
     private val tokenDataStore: TokenDataStore,
     private val tokenReissueApi: TokenReissueApi,
 ): Authenticator {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt`
around lines 20 - 28, Mark TokenAuthenticator with the `@Singleton` scope so all
injection sites share the same refreshLock instance and token reissue
synchronization remains app-wide. Keep the existing constructor injection and
Authenticator implementation unchanged.
core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt (1)

61-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

baseUrl 문자열이 두 곳에 중복 하드코딩되어 있습니다.

provideRetrofitprovideReissueRetrofit에 동일한 baseUrl이 중복 작성되어 있어, 서버 주소 변경 시 한쪽을 누락할 위험이 있습니다. 상수로 추출해 공유하는 것을 권장합니다.

♻️ 제안하는 수정
+object NetworkConfig {
+    const val BASE_URL = "https://api.lovecall.example.com/api/v1/"
+}
+
     fun provideRetrofit(...): Retrofit {
         return Retrofit.Builder()
-            .baseUrl("https://api.lovecall.example.com/api/v1/")
+            .baseUrl(NetworkConfig.BASE_URL)

Also applies to: 90-90

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt` at line
61, Extract the duplicated API base URL used by provideRetrofit and
provideReissueRetrofit into a shared constant, then reference that constant in
both Retrofit builders so future server address changes require updating only
one value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/kr/co/call/callfromai/AppScreen.kt`:
- Around line 112-114: Update the LandingSideEffect.NavigateToHome callback in
AppScreen to navigate saved-token users to HomeNavKey instead of
AgreementNavKey. If agreement consent requires separate handling, split it into
a distinct side effect rather than reusing NavigateToHome.

In
`@core/data/src/main/java/kr/co/call/data/repositoryImpl/AgreementRepositoryImpl.kt`:
- Around line 13-17: Update AgreementRepositoryImpl.getTerms so all
agreementApi.getTerms failures, including HTTP, IO, and serialization
exceptions, are caught and converted to the project’s established domain error
or Result type; also map unsuccessful responses to that same domain-level
representation while preserving the response failure details. Do not let
Retrofit or transport exceptions or IllegalStateException escape the repository
boundary.
- Line 18: 성공 응답의 약관 변환 로직에서 response.result.orEmpty()를 사용하지 말고 result 누락을 필수
검증하도록 수정하세요. result가 null이면 빈 목록으로 진행하지 말고 기존 도메인 에러 처리 경로로 반환하며, 값이 존재할 때만 현재
map 변환을 수행하세요.

In
`@core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt`:
- Around line 35-46: Update LoginRepositoryImpl’s token handling to validate
both result.accessToken and result.refreshToken with isNotBlank() before
tokenDataStore.saveTokens. Treat null, empty, or whitespace-only token values as
a domain-level error, and persist tokens only when both values are valid.
- Around line 23-33: Update LoginRepositoryImpl around loginApi.login so both
Retrofit/IO exceptions and unsuccessful responses are mapped to the project’s
domain error contract rather than propagated directly. Replace the generic
IllegalStateException(response.message) path and wrap DataSource failures using
the existing domain error type or mapper, preserving successful login behavior.

In `@core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt`:
- Around line 18-58: Update TokenDataStore so ACCESS_TOKEN and REFRESH_TOKEN are
encrypted at rest instead of stored as plaintext Preferences values. Apply the
same secure storage mechanism consistently in getAccessToken, getRefreshToken,
saveTokens, and clearTokens, using androidx.security:security-crypto or the
project’s established encrypted serializer/secret-box utility while preserving
the existing nullable token behavior and APIs.
- Around line 22-29: Update getAccessToken and getRefreshToken in TokenDataStore
so dataStore.data reads catch IOException, including FileNotFoundException, and
fall back to emptyPreferences(). Preserve the existing token lookup behavior
while ensuring storage read failures return null instead of propagating. Apply
the same emptyPreferences fallback consistently with the AuthInterceptor and
TokenAuthenticator runBlocking flows.

In `@core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt`:
- Around line 36-51: Update the logging configuration in provideOkHttpClient and
provideReissueOkHttpClient so BODY-level HttpLoggingInterceptor logging is not
enabled in release or other non-debug builds, preventing Authorization headers
and token request bodies from being logged. Preserve appropriate debug-only
logging behavior while ensuring reissue requests never expose raw accessToken or
refreshToken outside debug builds.

In
`@core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt`:
- Around line 42-50: Update String.isAuthFreePath and AUTH_FREE_ENDPOINTS to
compare normalized exact paths instead of using endsWith. Normalize the request
path and configured endpoints consistently, accounting for the base path and
trailing slashes, then return true only for an exact endpoint match so paths
such as /other/auth/kakao remain authenticated.
- Around line 35-39: Update the HttpLoggingInterceptor configuration in
provideOkHttpClient() to redact both Authorization and Cookie headers before
logging, and restrict Level.BODY logging to DEBUG builds only. Apply the same
redaction and DEBUG-only BODY configuration to the token-refresh client so
headers added by AuthInterceptor are never exposed.
- Around line 25-28: Update AuthInterceptor so intercept does not call
tokenDataStore.getAccessToken() through runBlocking or read DataStore
synchronously; instead, use a thread-safe in-memory access-token cache, and
ensure token persistence or refresh flows update that cache whenever the token
changes.

In `@feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt`:
- Around line 71-92: Update the login failure flow in LoginEntryBuilder so Kakao
login onFailure dispatches the error to a LoginViewModel intent instead of only
logging it. Handle the resulting LoginSideEffect.ShowError in the screen with a
user-visible message such as a Snackbar, while retaining logging only as
supplemental diagnostics.

In `@feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt`:
- Around line 45-52: PR 설명에 변경된 화면의 시각 검증 자료를 첨부하세요.
feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt
45-52의 약관 목록·선택 상태 화면,
feature/login/impl/src/main/java/kr/co/call/impl/screen/LandingScreen.kt 80-91의
랜딩 화면, feature/login/impl/src/main/java/kr/co/call/impl/screen/LoginScreen.kt
113-125의 카카오 로그인 화면을 각각 스크린샷 또는 화면 녹화로 제공하세요.

In
`@feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingViewModel.kt`:
- Around line 6-18: ViewModel들이 데이터 저장소와 비즈니스 로직을 직접 의존하지 않고 도메인 UseCase 경계를
사용하도록 변경하세요.
feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingViewModel.kt:6-18의
LandingViewModel은 TokenDataStore 주입을 CheckAutoLoginUseCase로 교체하세요.
feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt:5-18의
LoginViewModel은 서버 로그인과 토큰 저장을 담당하는 도메인 UseCase를 주입하세요.
feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt:5-15의
AgreementViewModel은 약관 조회와 동의 제출을 각각 도메인 UseCase에 위임하세요.

In
`@feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt`:
- Around line 62-68: Update LoginViewModel.kt lines 62-68 so ShowError is
rendered to the user through the existing Snackbar or Dialog mechanism. In
AgreementViewModel.kt lines 36-42, display terms-loading failures and expose a
retry event; in lines 70-74, display submission failures and allow the user to
retry. Ensure all three ShowError paths provide actionable UI feedback instead
of only logging with Timber.e.

In
`@feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/state/AgreementUiState.kt`:
- Around line 14-17: AgreementUiState의 isRequiredChecked에서 terms가 비어 있지 않은 경우에만
필수 동의 완료로 판단하도록 수정하세요. isAllChecked와 동일한 비어 있지 않은 목록 조건을 추가하고, 약관 로드 전에는 false를
유지하면서 약관이 로드된 뒤의 기존 필수 항목 검사 동작은 보존하세요.

---

Outside diff comments:
In `@feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt`:
- Around line 164-170: Update the NextButton enabled condition to require both
uiState.isRequiredChecked and uiState.terms.isNotEmpty(), preventing progression
without loaded terms. Also add the same non-empty terms validation in the
submitAgreements() flow so submission remains blocked even if the UI state is
bypassed.

---

Nitpick comments:
In `@core/domain/src/main/java/kr/co/call/domain/model/login/LoginToken.kt`:
- Around line 7-10: Override the default toString() for every token-bearing data
class so raw credentials are never exposed. In
core/domain/src/main/java/kr/co/call/domain/model/login/LoginToken.kt:7-10, mask
both tokens; in
core/network/src/main/java/kr/co/call/network/dto/login/LoginRequestDto.kt:7-10,
mask the access token or prevent DTO logging; and in
core/network/src/main/java/kr/co/call/network/dto/login/LoginResponseDto.kt:7-20,
mask tokens in both LoginResponseDto and LoginTokenResult.

In `@core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt`:
- Line 61: Extract the duplicated API base URL used by provideRetrofit and
provideReissueRetrofit into a shared constant, then reference that constant in
both Retrofit builders so future server address changes require updating only
one value.

In
`@core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt`:
- Around line 20-28: Mark TokenAuthenticator with the `@Singleton` scope so all
injection sites share the same refreshLock instance and token reissue
synchronization remains app-wide. Keep the existing constructor injection and
Authenticator implementation unchanged.

In `@settings.gradle.kts`:
- Around line 24-26: Restrict the Kakao Maven repository declaration to the
com.kakao.sdk group by adding an exclusive content filter around the repository
in the dependency repositories configuration. Keep the existing repository URL
unchanged and ensure other dependency groups continue using the appropriate
repositories.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 973e64e7-f808-40f9-8f7d-0382dd6c51df

📥 Commits

Reviewing files that changed from the base of the PR and between 8d1e029 and 70cb1d8.

📒 Files selected for processing (44)
  • app/build.gradle.kts
  • app/src/main/AndroidManifest.xml
  • app/src/main/java/kr/co/call/callfromai/App.kt
  • app/src/main/java/kr/co/call/callfromai/AppScreen.kt
  • core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt
  • core/data/src/main/java/kr/co/call/data/repositoryImpl/AgreementRepositoryImpl.kt
  • core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt
  • core/datastore/build.gradle.kts
  • core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt
  • core/datastore/src/main/java/kr/co/call/datastore/di/DataStoreModule.kt
  • core/domain/src/main/java/kr/co/call/domain/model/login/AgreementTerm.kt
  • core/domain/src/main/java/kr/co/call/domain/model/login/LoginToken.kt
  • core/domain/src/main/java/kr/co/call/domain/repository/AgreementRepository.kt
  • core/domain/src/main/java/kr/co/call/domain/repository/LoginRepository.kt
  • core/network/build.gradle.kts
  • core/network/src/main/java/kr/co/call/network/api/AgreementApi.kt
  • core/network/src/main/java/kr/co/call/network/api/LoginApi.kt
  • core/network/src/main/java/kr/co/call/network/api/TokenReissueApi.kt
  • core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt
  • core/network/src/main/java/kr/co/call/network/dto/login/LoginRequestDto.kt
  • core/network/src/main/java/kr/co/call/network/dto/login/LoginResponseDto.kt
  • core/network/src/main/java/kr/co/call/network/dto/login/TermsDto.kt
  • core/network/src/main/java/kr/co/call/network/dto/login/TokenReissueDto.kt
  • core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt
  • core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt
  • feature/login/api/src/main/java/kr/co/call/api/LoginRoute.kt
  • feature/login/impl/build.gradle.kts
  • feature/login/impl/src/main/java/kr/co/call/impl/auth/KakaoLoginManager.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/LandingScreen.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/LoginScreen.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementSideEffect.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementUiState.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingSideEffect.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingViewModel.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginSideEffect.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/state/AgreementUiState.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/state/LandingUiState.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/state/LoginState.kt
  • gradle/libs.versions.toml
  • settings.gradle.kts
💤 Files with no reviewable changes (1)
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementUiState.kt

Comment thread app/src/main/java/kr/co/call/callfromai/AppScreen.kt
Comment thread core/data/src/main/java/kr/co/call/data/repositoryImpl/AgreementRepositoryImpl.kt Outdated
Comment thread core/data/src/main/java/kr/co/call/data/repositoryImpl/AgreementRepositoryImpl.kt Outdated
Comment thread core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt Outdated
Comment thread core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt Outdated
Comment on lines +71 to +92
loginViewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
LoginSideEffect.NavigateToNext -> {
navigateToAgreement()
}

is LoginSideEffect.ShowError -> {
Timber.e(sideEffect.message)
}
}
}

LoginScreen(
onKakaoLoginClick={
kakaoLoginManager.login(
context=context,
onSuccess=loginViewModel::loginWithKakao,
onFailure={error ->
Timber.e(error,"카카오 로그인 실패")
},
onCancel={Timber.d("카카오 로그인 취소")}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

로그인 실패를 사용자에게 표시하세요.

카카오 로그인 실패와 ShowError가 모두 로그로만 처리되어 사용자는 실패 원인을 알 수 없습니다. 실패 콜백을 ViewModel intent로 전달하고, 화면에서 표시 가능한 side effect(예: Snackbar)로 처리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt`
around lines 71 - 92, Update the login failure flow in LoginEntryBuilder so
Kakao login onFailure dispatches the error to a LoginViewModel intent instead of
only logging it. Handle the resulting LoginSideEffect.ShowError in the screen
with a user-visible message such as a Snackbar, while retaining logging only as
supplemental diagnostics.

Comment on lines 45 to 52
fun AgreementScreen(
modifier: Modifier,
uiState: AgreementUiState,
onNextClick:()->Unit,
onAgreementViewClick:(AgreementType)->Unit,
onAgreementToggle: (AgreementType)->Unit,
onAgreementViewClick:(AgreementTerm)->Unit,
onAgreementToggle: (Long)->Unit,
onAllAgreementsCheckedChange: (Boolean) -> Unit
) {

@coderabbitai coderabbitai Bot Jul 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

변경된 화면의 시각 검증 자료를 PR에 첨부해 주세요.

제공된 PR 설명에는 스크린샷 또는 화면 녹화가 없습니다.

  • feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt#L45-L52: 약관 목록·선택 상태가 보이는 화면을 첨부하세요.
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/LandingScreen.kt#L80-L91: 랜딩 화면을 첨부하세요.
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/LoginScreen.kt#L113-L125: 카카오 로그인 화면을 첨부하세요.

As per coding guidelines, "*Screen.kt 변경 시 PR 설명에 스크린샷 또는 화면 녹화가 첨부되었는지 확인"해야 합니다.

📍 Affects 3 files
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt#L45-L52 (this comment)
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/LandingScreen.kt#L80-L91
  • feature/login/impl/src/main/java/kr/co/call/impl/screen/LoginScreen.kt#L113-L125
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt`
around lines 45 - 52, PR 설명에 변경된 화면의 시각 검증 자료를 첨부하세요.
feature/login/impl/src/main/java/kr/co/call/impl/screen/AgreementScreen.kt
45-52의 약관 목록·선택 상태 화면,
feature/login/impl/src/main/java/kr/co/call/impl/screen/LandingScreen.kt 80-91의
랜딩 화면, feature/login/impl/src/main/java/kr/co/call/impl/screen/LoginScreen.kt
113-125의 카카오 로그인 화면을 각각 스크린샷 또는 화면 녹화로 제공하세요.

Source: Coding guidelines

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

스샷 찍어주시면 리뷰하기편해용 ㅎㅎ

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@codebidoof 넵! 이 환경에서는 앱을 실행해 실제 화면을 캡처할 수는 없어서, PR 작성자분께 아래 화면 스크린샷(또는 하나의 화면 녹화)을 PR 설명에 첨부해 달라고 요청드린 상태입니다.

  • 랜딩 화면
  • 카카오 로그인 화면
  • 약관 목록 및 필수/선택 동의 상태가 보이는 화면

첨부되면 시각적으로도 확인하기 훨씬 좋겠습니다 🐰

Comment on lines +62 to +68
// 서버 또는 카카오 로그인 오류 메시지를 화면에 전달
postSideEffect(
LoginSideEffect.ShowError(
message = error.message
?: "로그인 실패",
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

오류 SideEffect를 사용자에게 표시하세요.

제공된 LoginEntryBuilder 소비 코드에서 세 ShowError 경로는 모두 Timber.e만 호출합니다. 로그인 실패는 아무 안내 없이 남고, 약관 조회 실패는 빈 화면에서 재시도 수단도 없습니다.

  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt#L62-L68: 로그인 화면에서 ShowError를 Snackbar 또는 Dialog로 표시하세요.
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt#L36-L42: 약관 조회 실패를 표시하고 재시도 이벤트를 제공하세요.
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt#L70-L74: 약관 제출 실패를 화면에 표시해 사용자가 재시도할 수 있게 하세요.
📍 Affects 2 files
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt#L62-L68 (this comment)
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt#L36-L42
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt#L70-L74
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt`
around lines 62 - 68, Update LoginViewModel.kt lines 62-68 so ShowError is
rendered to the user through the existing Snackbar or Dialog mechanism. In
AgreementViewModel.kt lines 36-42, display terms-loading failures and expose a
retry event; in lines 70-74, display submission failures and allow the user to
retry. Ensure all three ShowError paths provide actionable UI feedback instead
of only logging with Timber.e.

@github-actions

Copy link
Copy Markdown

Gemini AI 코드리뷰

안녕하세요, 시니어 Android 개발자로서 해당 Pull Request를 리뷰하겠습니다. 전반적으로 카카오 로그인, 토큰 관리, 약관 동의 플로우를 잘 구현한 PR로 보입니다. 특히 네트워크 계층에서 인증 및 토큰 재발급 로직을 꼼꼼하게 처리한 점이 인상 깊습니다.

아래는 각 항목별 상세 리뷰 및 몇 가지 개선 제안입니다.


1. app/build.gradle.kts

  • KAKAO_NATIVE_APP_KEYlocal.properties에서 읽어와 buildConfigFieldmanifestPlaceholders에 설정한 것은 좋은 접근입니다. 민감한 정보는 소스 코드에 직접 노출하지 않는 것이 중요합니다.
  • libs.kakao.user 의존성이 추가되었습니다.

2. app/src/main/AndroidManifest.xml

  • 카카오톡 설치 여부 확인을 위한 <queries> 태그를 추가한 것은 Android 11 (API 30) 이상 기기에서 패키지 가시성 문제를 해결하기 위한 올바른 처리입니다.
  • 카카오 로그인 웹 인가 코드를 받기 위한 AuthCodeHandlerActivity를 추가하고, kakao${KAKAO_NATIVE_APP_KEY} 스키마를 설정한 것도 적절합니다.

3. app/src/main/java/kr/co/call/callfromai/App.kt

  • Application 클래스에서 KakaoSdk.init을 호출하여 SDK를 초기화한 것은 올바른 사용법입니다.
  • BuildConfig.DEBUG 조건부 Timber.plant도 좋습니다.

4. app/src/main/java/kr/co/call/callfromai/AppScreen.kt

  • 시작점을 HomeNavKey에서 LandingNavKey로 변경한 것은 로그인 플로우를 고려했을 때 합리적인 변경입니다.
  • loginEntry 컴포저블 함수에 navigateToLogin, navigateToHome 등 다양한 콜백 람다를 전달하는 방식으로 State hoisting 원칙을 잘 준수하고 있습니다. 이는 Compose 컴포넌트의 재사용성과 테스트 용이성을 높여줍니다.
  • AgreementDetailNavKeytermId, title, content를 직접 인자로 전달하여 약관 상세 내용을 보여주는 방식도 좋습니다.

5. core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt

  • LoginRepositoryImplAgreementRepositoryImpl에 대한 Hilt @Binds 바인딩이 @Singleton 스코프로 올바르게 설정되었습니다. Hilt 생성자 주입 원칙을 잘 따르고 있습니다.

6. core/data/src/main/java/kr/co/call/data/repositoryImpl/AgreementRepositoryImpl.kt (신규)

  • Hilt DI: @Inject constructor(private val agreementApi: AgreementApi)로 생성자 주입을 사용한 것은 올바릅니다.
  • Retrofit 에러 핸들링 및 Result 래핑: try-catch 블록으로 네트워크 호출을 감싸고 Result.success 또는 Result.failure로 결과를 반환하는 전략은 매우 좋습니다.
    • CancellationException은 re-throw 하여 코루틴 취소는 일반적인 실패로 처리하지 않도록 한 점도 적절합니다.
    • response.isSuccess를 확인하고, resultnull인 경우 IllegalStateException을 발생시켜 비정상적인 서버 응답에 대비한 점도 좋습니다.
  • 네트워크 응답과 도메인 모델 매핑 분리: TermDtoAgreementTerm으로 매핑하여 도메인 모델을 사용하고 있습니다. 이는 계층 분리를 잘 보여주는 좋은 예시입니다.
  • 하드코딩된 문자열: "약관을 불러오지 못했습니다.", "약관 동의에 실패했습니다." 등의 오류 메시지는 현재 Repository 내부에서만 사용되고 ViewModel로 Exception과 함께 전달되므로 크게 문제되지는 않지만, 만약 이 메시지들이 사용자에게 직접 노출될 가능성이 있다면 strings.xml 또는 별도의 상수 객체로 관리하는 것을 고려해볼 수 있습니다.

7. core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt (신규)

  • Hilt DI: LoginApiTokenDataStore를 생성자 주입으로 받는 것은 올바른 Hilt 사용법입니다.
  • Retrofit 에러 핸들링 및 Result 래핑: try-catchResult 래핑을 통해 에러를 안전하게 처리하고 있습니다. CancellationException 처리도 동일하게 적용되어 있습니다.
  • 네트워크 응답과 도메인 모델 매핑 분리: LoginRequestDto를 사용하고, LoginResponseDto의 결과(LoginTokenResult)를 LoginToken 도메인 모델로 매핑하는 방식은 계층 분리를 잘 지키고 있습니다.
  • 캐싱 전략 (로컬 DB vs 메모리): 서버로부터 받은 accessTokenrefreshTokenTokenDataStore에 저장하는 로컬 캐싱 전략을 사용하고 있습니다. 이는 토큰 영속성을 위해 필수적이며 적절한 구현입니다.
  • 토큰 값이 비어있을 경우 IllegalStateException을 발생시켜 잘못된 토큰이 저장되는 것을 방지하는 유효성 검사도 좋습니다.

8. core/datastore/build.gradle.kts

  • Hilt 의존성을 추가하고 core:common 모듈을 가져온 것은 ApplicationScope와 같은 Hilt 관련 설정을 위해 필요한 변경사항으로 보입니다.

9. core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt (신규)

  • Hilt DI: DataStore<Preferences>@ApplicationScope CoroutineScope를 생성자 주입으로 받는 것은 올바릅니다. ApplicationScope를 명시적으로 주입받아 DataStore 관련 코루틴 작업을 애플리케이션 생명주기에 맞게 관리하는 것은 좋은 디자인입니다.
  • Coroutine 사용 시 Dispatcher 명시: applicationScope.launch를 사용하여 DataStore 변경 내용을 관찰하는 것은 해당 작업이 앱의 백그라운드 스레드에서 안정적으로 실행되도록 합니다.
  • 캐싱 전략 (로컬 DB vs 메모리): DataStore를 사용하여 토큰을 영속적으로 저장하고, AtomicReference<StoredTokens>를 사용하여 메모리에 캐시하는 전략은 매우 효과적입니다. AuthInterceptorrunBlocking 없이 빠르게 토큰을 가져갈 수 있도록 하기 위한 좋은 선택입니다.
  • safeData Flow에서 IOException 발생 시 emptyPreferences()를 emit하여 앱 크래시를 방지하고 비로그인 상태로 처리하는 에러 핸들링은 견고합니다.
  • require 함수를 사용하여 accessTokenrefreshToken이 비어있지 않음을 검증하는 것도 좋습니다.
  • StoredTokens 데이터 클래스의 모든 필드가 val로 선언되어 불필요한 var 사용을 피하고 있습니다.
  • companion objectstringPreferencesKey 상수를 정의하여 하드코딩된 문자열 사용을 줄였습니다.

10. core/datastore/src/main/java/kr/co/call/datastore/di/DataStoreModule.kt (신규)

  • preferencesDataStore를 사용하고 corruptionHandler를 설정하여 DataStore 파일 손상에 대비한 것은 좋은 구성입니다. @Singleton으로 앱 전체에서 단일 인스턴스를 사용하도록 한 것도 올바릅니다.

11. core/domain/src/main/java/kr/co/call/domain/model/login/AgreementTerm.kt, LoginToken.kt (신규)

  • data class 모두 모든 필드가 val로 선언되어 불필요한 var 사용을 피하고 있습니다. 도메인 모델로서 불변성을 유지하는 것은 좋은 사례입니다.

12. core/domain/src/main/java/kr/co/call/domain/repository/AgreementRepository.kt, LoginRepository.kt (신규)

  • Repository 인터페이스 정의를 통해 ViewModel 계층과의 UI 상태 및 비즈니스 로직 분리를 위한 좋은 추상화를 제공하고 있습니다.

13. core/network/build.gradle.kts

  • libs.kotlinx.coroutines.core 의존성을 추가한 것은 네트워크 모듈에서 코루틴을 사용하기 위해 필요합니다. 특히 TokenAuthenticatorrunBlocking 사용을 위해 중요합니다.

14. core/network/src/main/java/kr/co/call/network/api/AgreementApi.kt, LoginApi.kt, TokenReissueApi.kt (신규)

  • Retrofit 인터페이스 정의가 표준을 따르고 있습니다. TokenReissueApi에서 Response<TokenReissueResponseDto>를 반환하여 HTTP 상태 코드를 직접 처리할 수 있게 한 점이 좋습니다.

15. core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt

  • GsonConverterFactory 제공은 표준입니다.

  • 로깅 인터셉터: createLoggingInterceptor()에서 Authorization, Cookie, Set-Cookie 헤더를 redactHeader로 마스킹하여 민감한 정보가 로그에 노출되는 것을 방지한 것은 매우 훌륭한 보안 조치입니다. BuildConfig.DEBUG에 따라 로깅 레벨을 조절하는 것도 좋습니다.

  • 토큰 재발급 전용 로깅 인터셉터: createReissueLoggingInterceptor()에서 HttpLoggingInterceptor.Level.BASIC을 사용한 것도 좋습니다. 재발급 요청 본문에 Refresh Token이 포함될 수 있으므로 BODY 레벨을 피하는 것이 합리적입니다.

  • OkHttpClient 및 Retrofit 분리: 일반 API 통신용 OkHttpClient와 토큰 재발급 전용 @Named("reissueOkHttpClient")를 분리하고, 각각 AuthInterceptorTokenAuthenticator 적용 여부를 다르게 한 점은 아주 좋은 설계입니다. 이를 통해 TokenAuthenticator가 자체 토큰 재발급 API를 호출할 때 순환 참조나 인증 루프에 빠지는 것을 방지할 수 있습니다.

  • provideLoginApi, provideAgreementApi, provideTokenReissueApi를 통해 각 API 인터페이스의 인스턴스를 제공합니다.

  • 리뷰 코멘트 (Major): 하드코딩된 Base URL

    .baseUrl("https://api.lovecall.example.com/api/v1/")

    provideRetrofitprovideReissueRetrofit 함수 내부에 Base URL이 하드코딩되어 있습니다. 이는 개발, 스테이징, 프로덕션 등 여러 환경에 대응하기 어렵게 만듭니다. Base URL은 BuildConfigFieldbuild.gradle.kts에 정의하고, 이를 BuildConfig를 통해 주입받아 사용해야 합니다.

    개선 제안:

    1. core/network/build.gradle.ktsbuildConfigField를 추가합니다:
      android {
          // ...
          buildFeatures {
              buildConfig = true
          }
          defaultConfig {
              // ...
              buildConfigField "String", "BASE_URL", "\"https://api.lovecall.example.com/api/v1/\""
          }
      }
    2. NetworkModule.kt에서 BuildConfig.BASE_URL을 주입받아 사용합니다:
      import kr.co.call.network.BuildConfig // BuildConfig 임포트
      // ...
      @Provides
      @Singleton
      fun provideRetrofit(
          gsonConverterFactory: GsonConverterFactory,
          okHttpClient: OkHttpClient,
      ): Retrofit {
          return Retrofit.Builder()
              .baseUrl(BuildConfig.BASE_URL) // BuildConfig에서 가져온다
              .client(okHttpClient)
              .addConverterFactory(gsonConverterFactory)
              .build()
      }
      // reissueRetrofit도 동일하게 변경
      @Provides
      @Singleton
      @Named("reissueRetrofit")
      fun provideReissueRetrofit(
          gsonConverterFactory: GsonConverterFactory,
          @Named("reissueOkHttpClient")
          reissueOkHttpClient: OkHttpClient,
      ): Retrofit {
          return Retrofit.Builder()
              .baseUrl(BuildConfig.BASE_URL) // BuildConfig에서 가져온다
              .client(reissueOkHttpClient)
              .addConverterFactory(gsonConverterFactory)
              .build()
      }

16. core/network/src/main/java/kr/co/call/network/dto/login/* (신규 DTO 파일들)

  • 모든 DTO 데이터 클래스의 필드가 val로 선언되어 불필요한 var 사용을 피하고 있습니다. result 필드가 null을 허용하는 것도 서버 응답 스펙에 맞춰 적절합니다.

17. core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt (신규)

  • Hilt DI: TokenDataStore를 생성자 주입으로 받는 것은 올바릅니다.
  • 토큰 추가 로직: originalRequest.url.encodedPath.isAuthFreePath()를 통해 로그인 및 토큰 재발급 API에는 Authorization 헤더를 추가하지 않도록 예외 처리한 점이 중요하고 올바릅니다.
  • tokenDataStore.getCachedAccessToken()을 사용하여 OkHttp의 InterceptorrunBlocking 없이 즉시 토큰을 가져올 수 있게 한 것은 매우 좋은 성능 및 동시성 처리입니다.
  • AUTH_FREE_ENDPOINTS 상수를 companion object에 정의한 것도 좋습니다.

18. core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt (신규)

  • Hilt DI: TokenDataStoreTokenReissueApi를 생성자 주입으로 받는 것은 올바릅니다.

  • 동시성 처리: refreshLock (Any())과 synchronized 블록을 사용하여 여러 API 요청에서 동시에 401 응답이 발생하더라도 토큰 재발급 요청이 중복 호출되지 않도록 방지하는 것은 매우 중요하고 올바른 동시성 처리입니다.

  • runBlocking 사용: Authenticator는 OkHttp에서 동기적으로 동작해야 하는 인터페이스이므로, runBlocking을 사용하여 코루틴의 suspend 함수(tokenDataStore.getStoredTokens(), tokenReissueApi.reissue())를 호출하는 것은 이 경우 필요하며 허용되는 패턴입니다. synchronized 블록 내에서 runBlocking을 사용함으로써 동시성 문제를 잘 관리하고 있습니다.

  • 재발급 루프 방지: responseCount(response) >= MAX_RESPONSE_COUNT를 통해 동일한 요청이 무한 루프에 빠지는 것을 방지한 점이 좋습니다.

  • 토큰 재발급 로직:

    • 잠금을 기다리는 동안 다른 요청이 이미 토큰을 재발급했을 경우, 새로 갱신된 토큰으로 재요청하는 로직 (if (requestAccessToken != currentAccessToken))은 다중 요청 동시성 시나리오에 대한 훌륭한 처리입니다.
    • 재발급 API 호출 후 isSuccessfulisSuccess 응답을 확인하고 newTokens의 유효성까지 검증한 후 tokenDataStore.saveTokens를 호출하는 과정이 견고합니다.
    • 재발급 API 응답이 401일 경우 (Refresh Token까지 만료된 경우) tokenDataStore.clearTokens()를 호출하여 자동 로그인 상태를 해제한 점은 보안 및 사용자 경험 측면에서 매우 중요합니다.
  • HttpUrl.normalizedPath() 확장 함수를 통해 trailing slash 문제를 해결한 것도 좋습니다.

  • AUTH_FREE_PATHS 상수를 companion object에 정의한 것은 좋습니다. 다만 NetworkModule의 Base URL 하드코딩 문제와 연관되어 "/api/v1/" 프리픽스가 중복되므로, Base URL이 동적으로 관리되면 이 부분도 개선될 여지가 있습니다.

  • 리뷰 코멘트 (Minor): Magic Number

    • MAX_RESPONSE_COUNT = 2는 마법의 숫자(magic number)입니다. 이 숫자가 의미하는 바를 명확히 하는 상수로 변경하는 것이 가독성에 좋습니다. (예: MAX_TOKEN_REFRESH_ATTEMPTS)

19. feature/login/api/src/main/java/kr/co/call/api/LoginRoute.kt

  • LandingNavKey, AgreementNavKey, AgreementDetailNavKey 등 새로운 Navigation Key를 추가한 것은 좋습니다.
  • AgreementDetailNavKeytermId, title, content를 포함하는 데이터 클래스인 것은 Compose Navigation에서 인자 전달을 위한 좋은 패턴입니다.

20. feature/login/impl/build.gradle.kts

  • libs.kakao.user 의존성이 추가되었습니다.

21. feature/login/impl/src/main/java/kr/co/call/impl/auth/KakaoLoginManager.kt (신규)

  • 카카오 SDK의 로그인 API를 캡슐화한 유틸리티 클래스입니다.
  • 카카오톡 앱 로그인 가능 여부를 확인하고, 불가능할 경우 카카오 계정(웹) 로그인을 시도하는 폴백(fallback) 로직이 잘 구현되어 있습니다.
  • 사용자가 로그인을 취소했을 때(ClientErrorCause.Cancelled) onCancel 콜백을 호출하여 명확하게 처리하는 점도 좋습니다.
  • Timber.eTimber.d를 사용하여 로깅을 수행하는 것도 좋습니다.
  • 콜백 기반으로 onSuccess, onFailure, onCancel을 제공하여 ViewModel에서 처리하기 용이하게 설계되었습니다.
  • 이 클래스는 Hilt로 주입받지 않아도 되므로 일반 클래스로 선언한 것이 적절합니다.

22. feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt

  • Composable Recomposition: val kakaoLoginManager = remember { KakaoLoginManager() }를 사용하여 KakaoLoginManager 인스턴스가 리컴포지션 간에도 유지되도록 한 것은 올바른 remember 사용법입니다. hiltViewModel() 사용도 표준입니다.

  • Side Effect: collectSideEffect를 사용하여 LandingSideEffect, LoginSideEffect, AgreementSideEffect를 처리하고 있습니다. 이는 Orbit 라이브러리에서 단발성 이벤트(one-off event) 처리를 위한 가장 적절한 패턴입니다. Toast 메시지를 보여주거나 화면을 전환하는 등의 작업을 여기서 처리하는 것은 올바른 접근입니다.

  • UI 상태를 data class로 표현: agreementViewModel.collectAsState().value를 통해 UI 상태를 수집하고 있는데, 이는 ViewModel이 data class 또는 불변 객체로 UI 상태를 관리하고 있음을 암시하며, 좋은 패턴입니다.

  • 하드코딩된 문자열 (Major): 사용자에게 노출되는 문자열

    Toast.makeText(
        context,
        "로그인에 실패했습니다.", // 이 부분
        Toast.LENGTH_SHORT,
    ).show()

    사용자에게 직접 노출되는 Toast 메시지는 strings.xml 리소스에서 가져와야 합니다. 현재 sideEffect.message가 에러 메시지를 포함할 수도 있으므로, 이를 직접 Toast 메시지로 보여줄 때는 특히 현지화(localization)를 고려해야 합니다.

    개선 제안:

    • res/values/strings.xml<string name="login_failed">로그인에 실패했습니다.</string>와 같이 추가하고, Toast.makeText(context, context.getString(R.string.login_failed), Toast.LENGTH_SHORT).show()와 같이 사용합니다.
    • sideEffect.message가 사용자에게 친숙하지 않은 기술적인 오류 메시지일 수 있으므로, 사용자에게 보여줄 메시지는 ViewModel에서 미리 포맷팅하거나, UI 레이어에서 적절한 사용자 친화적인 메시지로 변환하는 로직을 추가하는 것을 고려해야 합니다.

총평

이 PR은 전반적으로 매우 높은 품질의 코드를 보여줍니다. 특히 네트워크 계층에서의 인증 및 토큰 재발급 로직은 Android 개발에서 흔히 발생하는 복잡한 문제를 안정적이고 모범적인 방식으로 해결하고 있습니다. Hilt, Orbit MVI, Jetpack Compose 등 최신 기술 스택을 잘 이해하고 적용했습니다.

주요 개선점은 Base URL 하드코딩 문제와 사용자에게 노출되는 문자열의 하드코딩 문제입니다. 이 두 가지를 수정하면 훨씬 더 견고하고 유지보수하기 쉬운 코드가 될 것입니다.

훌륭한 PR입니다!

다음은 제가 제시한 개선점들을 요약한 목록입니다. (개선 시 이 목록을 체크리스트로 활용하시면 좋습니다)

  • [Major] core/network/di/NetworkModule.ktbaseUrlBuildConfigField로 관리하도록 변경
  • [Major] feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.ktToast 메시지 등 사용자 노출 문자열을 strings.xml로 이동
  • [Minor] core/network/interceptor/TokenAuthenticator.ktMAX_RESPONSE_COUNT를 더 명확한 상수로 변경 (예: MAX_TOKEN_REFRESH_ATTEMPTS)
  • [Minor] core/data/repositoryImpl의 내부 오류 메시지들도 상수 또는 strings.xml로 관리하는 것을 고려 (선택 사항)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt`:
- Around line 165-169: Update Preferences.toStoredTokens so both accessToken and
refreshToken are normalized to null when their stored values are blank, ensuring
getAccessToken() and getCachedAccessToken() do not treat whitespace-only tokens
as valid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 69422401-26fd-4a1b-aec8-5eeb886425fd

📥 Commits

Reviewing files that changed from the base of the PR and between 70cb1d8 and 24214f2.

📒 Files selected for processing (15)
  • app/src/main/java/kr/co/call/callfromai/AppScreen.kt
  • core/data/src/main/java/kr/co/call/data/repositoryImpl/AgreementRepositoryImpl.kt
  • core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt
  • core/datastore/build.gradle.kts
  • core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt
  • core/datastore/src/main/java/kr/co/call/datastore/di/DataStoreModule.kt
  • core/domain/src/main/java/kr/co/call/domain/repository/AgreementRepository.kt
  • core/domain/src/main/java/kr/co/call/domain/repository/LoginRepository.kt
  • core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt
  • core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt
  • core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/state/AgreementUiState.kt
🚧 Files skipped from review as they are similar to previous changes (9)
  • core/datastore/build.gradle.kts
  • core/domain/src/main/java/kr/co/call/domain/repository/LoginRepository.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/state/AgreementUiState.kt
  • core/datastore/src/main/java/kr/co/call/datastore/di/DataStoreModule.kt
  • app/src/main/java/kr/co/call/callfromai/AppScreen.kt
  • core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/AgreementViewModel.kt
  • feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt
  • core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt

Comment thread core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt
@github-actions

Copy link
Copy Markdown

Gemini AI 코드리뷰

안녕하세요, 시니어 Android 개발자입니다. PR을 꼼꼼히 리뷰해 보았습니다.

이번 PR은 카카오 로그인 및 약관 동의 기능을 구현하며, Hilt DI, Retrofit 네트워크 처리, DataStore 기반 토큰 관리 등 중요한 아키텍처 요소들을 매우 안정적으로 통합하고 있습니다. 전반적으로 코드 품질이 높고, 여러 상황에 대한 예외 처리와 견고성이 돋보입니다.

PR 제목: 카카오 로그인 및 약관 동의 기능 구현


✅ 전반적인 리뷰 요약

  • 긍정적인 부분:

    • 아키텍처 준수: data, datastore, domain, network, feature 모듈 간의 명확한 역할 분리와 계층 분리가 잘 이루어져 있습니다.
    • Kotlin Best Practices: data class의 불변성(val 사용), null 안전성(옵셔널 체이닝, takeIf, isNullOrBlank 활용, !! 사용 지양), require()를 통한 사전 조건 검증 등이 잘 지켜져 있습니다.
    • Hilt DI: 생성자 주입 원칙을 충실히 따르고 있으며, 적절한 스코프(예: @Singleton)가 사용되었습니다.
    • 네트워크 견고성: Retrofit 에러 핸들링(try-catch, Result 래핑), 네트워크 응답과 도메인 모델 매핑 분리, TokenAuthenticator를 통한 토큰 재발급 로직이 매우 상세하고 안정적으로 구현되었습니다.
    • 토큰 관리: TokenDataStore를 통해 DataStore와 메모리 캐시를 동시에 활용하는 효율적이고 안전한 토큰 캐싱 전략을 사용한 점이 인상 깊습니다. IOException 처리도 훌륭합니다.
    • Jetpack Compose: Navigation3의 NavKey를 통한 타입 안전한 네비게이션, remember를 이용한 객체 생명주기 관리, Orbit MVI의 collectAsState/collectSideEffect를 통한 상태 및 사이드 이펙트 처리가 적절하게 사용되었습니다. State hoisting 원칙도 잘 지키고 있습니다.
  • 개선이 필요한 부분:

    • 하드코딩된 문자열 (Base URL): 네트워크 모듈의 Base URL이 하드코딩되어 있습니다.
    • 하드코딩된 문자열 (사용자 메시지): Compose UI에서 사용자에게 표시되는 Toast 메시지가 하드코딩되어 있습니다.

🛠️ 상세 코드 리뷰

1. Kotlin 코드 리뷰

  • Coroutine 사용 시 Dispatcher 명시 여부, viewModelScope/lifecycleScope 오남용:
    • TokenDataStore에서 applicationScope.launch를 사용하여 앱 전반에 걸쳐 DataStore 변경 사항을 관찰하는 것은 적절합니다.
    • TokenAuthenticator에서 runBlocking을 사용한 점은 OkHttp Authenticator 인터페이스가 동기적인 특성을 가지고 있어 비동기 DataStore 및 Retrofit 호출을 연결하기 위한 필연적인 선택으로 보입니다. 이는 OkHttp에서 권장되는 패턴 중 하나이며, AuthInterceptor가 캐시된 토큰을 먼저 사용하여 대부분의 요청에서 runBlocking이 발생하지 않도록 최적화된 점을 고려할 때 합리적입니다.
    • 나머지 Repository 계층에서는 suspend 함수로만 구현되어 있으며, 호출하는 상위 계층(ViewModel 또는 UseCase)에서 Dispatchers를 명시할 것으로 예상됩니다. 현재로서는 문제가 없습니다.
  • Flow/StateFlow 사용 시 collect 시점 (repeatOnLifecycle 사용 여부):
    • TokenDataStoreinit 블록에서 applicationScope.launch { safeData.collect { ... } }는 앱 생명주기 전반에 걸쳐 데이터를 수집하므로 repeatOnLifecycle은 필요하지 않으며 올바른 사용입니다.
    • Compose 코드(LoginEntryBuilder.kt)에서 collectAsState()collectSideEffect()는 Orbit MVI 라이브러리에서 제공하는 것으로, Composable의 생명주기에 맞게 자동으로 구독/해제되므로 repeatOnLifecycle을 직접 사용할 필요가 없습니다. 이는 Jetpack Compose와 OrbitMVI의 올바른 통합 방식입니다.
  • null 안전성 (!! 사용 지양):
    • 모든 새로 추가된 코드에서 ?., takeIf, ifBlank, isNullOrBlank() 등 Kotlin의 null 안전 기능을 적극적으로 활용하여 !! 연산자를 사용하지 않고 안전하게 null을 처리하고 있습니다. 매우 훌륭합니다.
  • data class에 불필요한 var 사용 여부:
    • 새로 추가된 모든 data class (예: StoredTokens, AgreementTerm, LoginToken, DTOs)는 모든 필드를 val로 선언하여 불변성을 유지하고 있습니다. 모범 사례를 따르고 있습니다.
  • Hilt DI 시 생성자 주입 원칙 준수 여부:
    • AgreementRepositoryImpl, LoginRepositoryImpl, TokenDataStore, AuthInterceptor, TokenAuthenticator 등 새로 생성된 모든 클래스에서 생성자 주입을 통해 의존성을 받고 있습니다. Hilt 모듈(RepositoryModule, DataStoreModule, NetworkModule) 또한 Hilt 원칙에 따라 @Provides@Binds를 사용하여 의존성을 제공합니다. 완벽하게 준수하고 있습니다.
  • 하드코딩된 문자열/매직 넘버:
    • 개선 필요: API Base URL (core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt)
      .baseUrl("https://api.lovecall.example.com/api/v1/") // 🚨 이 부분이 하드코딩되어 있습니다.
      -> 권장사항: 이 Base URL은 개발, 스테이징, 운영 환경에 따라 달라질 수 있는 중요한 정보이므로 app/build.gradle.ktsbuildConfigField로 정의하고 BuildConfig를 통해 접근하도록 변경해야 합니다.
    • 개선 필요: 사용자에게 노출되는 Toast 메시지 (feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt)
      Toast.makeText(context, "로그인에 실패했습니다.", Toast.LENGTH_SHORT,).show()
      Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT,).show()
      -> 권장사항: 사용자에게 직접 노출되는 문자열은 res/values/strings.xml에 리소스로 정의하고 @string/login_failure_message와 같이 참조하여 사용해야 합니다. 이는 다국어 지원 및 유지보수에 필수적입니다.
    • DataStore 키(ACCESS_TOKEN, REFRESH_TOKEN)나 API 경로(AUTH_FREE_ENDPOINTS)는 내부적인 상수이며, 변경 가능성이 낮고 외부에 노출되지 않으므로 하드코딩으로 간주하지 않아도 괜찮습니다.
    • MAX_RESPONSE_COUNT (2)는 매직 넘버이지만, TokenAuthenticator 내부에서 재시도 횟수를 제한하는 명확한 목적을 가지고 있고 주석으로 설명되어 있어 허용할 만합니다.

2. Jetpack Compose 코드

  • Composable 함수의 불필요한 recomposition 유발 여부 (remember, key 사용):
    • KakaoLoginManager 인스턴스를 remember { KakaoLoginManager() }로 감싸 recomposition 시에도 동일한 인스턴스를 유지하도록 한 점은 올바른 최적화입니다.
    • hiltViewModel은 ViewModel 인스턴스를 Compose 생명주기에 맞춰 관리하므로 불필요한 recomposition을 유발하지 않습니다.
    • Orbit MVI의 collectAsStatecollectSideEffect는 상태 변화에 효율적으로 반응하도록 설계되어 있어 recomposition을 최소화합니다.
  • State hoisting 원칙 준수:
    • AppScreen.kt에서 loginEntry를 호출할 때 navigateToLogin, navigateToHome 등 모든 내비게이션 관련 이벤트를 람다로 전달하여 상위 컴포넌트(AppScreen)가 내비게이션의 제어권을 갖도록 한 점은 State hoisting 원칙을 잘 준수하고 있습니다. 이는 하위 컴포넌트의 재사용성과 테스트 용이성을 높입니다.
  • side effect (LaunchedEffect, DisposableEffect) 사용의 적절성:
    • Orbit MVI의 collectSideEffect를 사용하여 Toast 메시지 표시나 내비게이션 같은 일회성 이벤트를 처리하는 것은 Side Effect 처리에 대한 모범적인 사례입니다. LaunchedEffectDisposableEffect를 직접 사용하지 않아도 collectSideEffect가 ViewModel의 Side Effect를 안전하게 처리합니다.
  • UI 상태를 data class로 표현했는지:
    • 새로 추가된 Domain 모델인 AgreementTermdata class로 잘 표현되었습니다. LoginEntryBuilder.kt에서 ViewModel의 collectAsState().value를 사용하는 것으로 보아, ViewModel의 UI 상태도 data class로 관리되고 있을 것으로 예상됩니다. (이 부분은 ViewModel 구현 코드가 PR에 포함되어 있지 않아 정확히 확인할 수는 없었습니다.)
  • 로딩/에러 상태를 Boolean 대신 LoadStatus로 관리하는지:
    • 에러 상태는 AgreementSideEffect.ShowErrorLoginSideEffect.ShowError와 같이 SideEffect를 통해 메시지를 전달하는 방식으로 관리되고 있습니다. 로딩 상태를 Boolean 대신 LoadStatus (또는 이와 유사한 Sealed Class)로 관리하는지 여부는 ViewModel의 UI State 정의를 확인해야 합니다. (LoadingViewModel, AgreementViewModel 등). 이 부분은 ViewModel 구현 시 확인이 필요합니다.

3. Repository/DataSource 레이어

  • Retrofit 에러 핸들링 (try-catch, Result 래핑):
    • AgreementRepositoryImplLoginRepositoryImpl 모두 try-catch 블록 내에서 Retrofit 호출을 수행하고, 성공/실패 여부를 Result 객체로 래핑하여 반환합니다. CancellationException을 별도로 처리하여 코루틴 취소로 인한 예외가 일반적인 실패로 처리되지 않도록 한 점도 훌륭합니다. API 응답의 isSuccess 필드를 확인하여 서버 측 비즈니스 로직 에러까지 포괄적으로 다루는 점은 매우 바람직합니다.
  • 네트워크 응답과 도메인 모델 매핑 분리 여부:
    • AgreementRepositoryImpl에서 TermDtoAgreementTerm으로, LoginRepositoryImpl에서 LoginTokenResultLoginToken으로 명확히 매핑하여 도메인 계층이 네트워크 DTO에 의존하지 않도록 분리한 점은 모범적입니다.
  • 캐싱 전략 (로컬 DB vs 메모리):
    • TokenDataStoreDataStore를 사용하여 Access Token 및 Refresh Token을 로컬에 영구 저장하고, AtomicReference를 사용하여 메모리에도 캐싱함으로써 빠른 접근과 영속성이라는 두 가지 목표를 동시에 달성하고 있습니다. AuthInterceptor가 메모리 캐시를 사용하여 네트워크 스레드를 블로킹하지 않도록 한 설계는 매우 뛰어납니다.

4. ViewModel

  • LoginEntryBuilder.kt에서 ViewModel 사용 패턴을 통해 다음과 같은 내용을 확인할 수 있습니다.
  • UI 상태와 비즈니스 로직 분리:
    • Compose UI는 ViewModel의 상태(collectAsState())를 관찰하고 ViewModel이 발생하는 Side Effect(collectSideEffect())에 반응합니다. 이는 UI와 비즈니스 로직이 잘 분리되어 있음을 시사합니다.
  • Orbit ContainerHost 패턴 준수 여부:
    • org.orbitmvi.orbit.compose.collectAsStatecollectSideEffect를 사용하고 있으므로 Orbit MVI의 ContainerHost 패턴을 잘 따르고 있을 것으로 예상됩니다.
  • intent 내부에서 상태 변경(reduce)만 수행하는지:
    • ViewModel 구현 코드가 포함되어 있지 않아 정확히 확인할 수 없지만, KakaoLoginManageronSuccess 콜백이 loginViewModel::loginWithKakao로 연결되는 방식은 intent (또는 action) 내부에서 비동기 작업을 시작하고, 그 결과를 받아 reduce 함수를 통해 상태를 변경할 것으로 예상됩니다.
  • postSideEffect 남용 여부:
    • LoginSideEffect.NavigateToNext, LoginSideEffect.ShowError와 같이 내비게이션이나 일회성 메시지 표시에 postSideEffect를 사용하는 것은 적절한 사용례입니다. 남용으로 보이지 않습니다.
  • blocking 작업을 intent 내부에서 직접 수행하지 않는지:
    • KakaoLoginManager.login은 비동기적으로 카카오 로그인을 처리하며, 그 결과는 콜백으로 전달됩니다. 이 콜백 내에서 ViewModel의 메서드를 호출하더라도, 해당 ViewModel 메서드 내부에서 네트워크나 DataStore 접근과 같은 긴 작업은 코루틴으로 처리되어 메인 스레드를 블로킹하지 않을 것으로 예상됩니다. (ViewModel 구현 확인 필요)

5. FCM/SSE/실시간 통신 관련 코드

  • 해당 PR에는 FCM/SSE/실시간 통신 관련 코드가 포함되어 있지 않습니다.

💡 최종 결론 및 권장사항

이 PR은 매우 높은 품질의 코드를 포함하고 있으며, 핵심적인 기능과 아키텍처 원칙을 훌륭하게 준수하고 있습니다. 특히 토큰 관리 및 네트워크 처리 부분은 많은 고려와 노력이 들어간 것으로 보입니다.

두 가지 권장사항을 반영하면 더욱 완벽해질 것입니다:

  1. API Base URL을 BuildConfig로 관리: core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt에 하드코딩된 Base URL을 build.gradle.kts에서 buildConfigField로 정의하고 BuildConfig를 통해 가져와 사용하세요.
  2. 사용자 메시지(Toast)를 strings.xml로 외부화: feature/login/impl/src/main/java/kr/co/call/impl/entry/LoginEntryBuilder.kt에 있는 Toast 메시지 등 사용자에게 노출되는 모든 문자열을 res/values/strings.xml로 옮겨서 관리해 주세요.

이러한 개선사항들을 적용하면 더욱 유지보수하기 쉽고, 확장성 있는 코드가 될 것입니다. 수고 많으셨습니다!

val content: String,
val isRequired: Boolean,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

이거 나중에 아원이가 공통 응답 data class 만들면 수정하면 될듯합니당

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

data class AgreementUiState(
val terms: List<AgreementTerm> = emptyList(),
val checkedTermIds: Set<Long> = emptySet(),
val isLoading: Boolean = false,

@JiwonLee42 JiwonLee42 Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

val status: LoadStatus = LoadStatus.Idle 요런식으로 수정하는게 좋을 것 같아용
다른 UiState도 마찬가지로 하면 좋을 것 같습니다!!

@codebidoof codebidoof left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

고생했어!! LandingViewModel 쪽은 수정이 필요할 듯 해잉

),
)
},
navigateAfterAgreement={

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

오 파라미터 이름 직관적이라 너무 조아요

Comment thread app/build.gradle.kts

val kakaoNativeAppKey =
localProperties.getProperty("KAKAO_NATIVE_APP_KEY")
?: error("local.properties에 KAKAO_NATIVE_APP_KEY가 없음")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

val kakaoNativeAppKey =
    localProperties.getProperty("KAKAO_NATIVE_APP_KEY")
        ?: throw GradleException(
            """
            local.properties에 KAKAO_NATIVE_APP_KEY가 없습니다.
            
            local.properties에 다음을 추가해주세요.
            KAKAO_NATIVE_APP_KEY=xxxxxxxxxxxxxxxx
            """.trimIndent()
        )

이런 식으로 error 말구 GradleException을 발생시키는 게 더 조을 것 가타용

interface AgreementRepository {
suspend fun getTerms(): Result<List<AgreementTerm>>
suspend fun agreeTerms(
agreements: Map<Long, Boolean>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

그냥 인자 2개 넣는 거로 해도 될 것 같은데? Map 이 오히려 코드 복잡도를 늘리는 느낌이야

okHttpClient: OkHttpClient,
): Retrofit{
return Retrofit.Builder()
.baseUrl("https://api.lovecall.example.com/api/v1/")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

아원이가 세팅한 뒤에는 이거 BuildConfig에서 불러오는 식으로 수정이 필요할 것 같아용 보안 문제때문에

import com.kakao.sdk.user.UserApiClient
import timber.log.Timber

class KakaoLoginManager {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

별도 클래스로 뺀거 아주 굿굿

Comment on lines 45 to 52
fun AgreementScreen(
modifier: Modifier,
uiState: AgreementUiState,
onNextClick:()->Unit,
onAgreementViewClick:(AgreementType)->Unit,
onAgreementToggle: (AgreementType)->Unit,
onAgreementViewClick:(AgreementTerm)->Unit,
onAgreementToggle: (Long)->Unit,
onAllAgreementsCheckedChange: (Boolean) -> Unit
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

스샷 찍어주시면 리뷰하기편해용 ㅎㅎ

Comment on lines +5 to +18
data class AgreementUiState(
val terms: List<AgreementTerm> = emptyList(),
val checkedTermIds: Set<Long> = emptySet(),
val isLoading: Boolean = false,
){
val isAllChecked: Boolean
get() = terms.isNotEmpty() &&
terms.all { it.termId in checkedTermIds }

val isRequiredChecked: Boolean
get() = terms.isNotEmpty() && terms
.filter { it.isRequired }
.all { it.termId in checkedTermIds }
} No newline at end of file

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

주석 남겨주시면 리뷰할 때 훨씬 편할 것 같아용

Comment on lines +7 to +9
data class LandingUiState(
val isCheckingAutoLogin:Boolean=true,
) No newline at end of file

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

필드가 하나뿐인데 data class 로 정의하신 이유가 있을까용?

Comment on lines +16 to +50
@HiltViewModel
class LandingViewModel @Inject constructor(
private val tokenDataStore: TokenDataStore,
): ViewModel(),
ContainerHost<LandingUiState, LandingSideEffect> {
override val container= container <LandingUiState,LandingSideEffect>(
initialState=LandingUiState(),
)
init {
// 랜딩 화면이 생성되면 자동 로그인 확인을 바로 시작
checkAutoLoginAfterSplash()
}
/**
* 스플래시 화면 노출 후 저장된 Access Token을 확인한다.
* 현재는 토큰 존재 여부를 기준으로 자동 로그인 화면을 결정한다.
*/
private fun checkAutoLoginAfterSplash()=intent{
delay(SPLASH_DURATION_MILLIS)

val accessToken = tokenDataStore.getAccessToken()
reduce{
state.copy(
isCheckingAutoLogin = false,
)
}
if (accessToken.isNullOrBlank()){
postSideEffect(LandingSideEffect.NavigateToLogin)
}else{
postSideEffect(LandingSideEffect.NavigateToHome)
}
}
private companion object {
const val SPLASH_DURATION_MILLIS=3_000L
}
} No newline at end of file

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

현재 3초 뒤에 토큰 읽기 작업을 시작하는 방식으로 로직이 짜여져 있는데, SPLASH_DURATION_MILLIS는 스플래시 화면(랜딩스크린)을 띄우는 최소 시간만을 정의하는 식으로 사용하고 자동 로그인 체크는 화면 진입과 동시에 수행하는 방식이 더 적절할 것 같아!! 이후 체크가 완료되면 남은 시간만큼만 대기한 뒤 화면을 전환하면 될 것 같앙 예를 들면 이런 느낌으로?

private fun checkAutoLoginAfterSplash() = intent {
    val splashStartTime = System.currentTimeMillis()

    // 토큰 조회는 바로 시작
    val accessToken = tokenDataStore.getAccessToken()

    // 스플래시 최소 노출 시간 보장
    val elapsedTime = System.currentTimeMillis() - splashStartTime
    val remainingTime = SPLASH_DURATION_MILLIS - elapsedTime
    if (remainingTime > 0) {
        delay(remainingTime)
    }

    reduce {
        state.copy(
            isCheckingAutoLogin = false,
        )
    }

    if (accessToken.isNullOrBlank()) {
        postSideEffect(LandingSideEffect.NavigateToLogin)
    } else {
        postSideEffect(LandingSideEffect.NavigateToHome)
    }
}

private companion object {
    const val SPLASH_DURATION_MILLIS = 3_000L
}

@codebidoof codebidoof assigned codebidoof and dada4679 and unassigned codebidoof Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 카카오 로그인 및 토큰 관리 구현

3 participants