Skip to content

[feat] #71 마이페이지 UI 수정 및 API 연동#79

Merged
Ojongseok merged 78 commits into
developfrom
feat/#71-mypage-api
Feb 6, 2026
Merged

[feat] #71 마이페이지 UI 수정 및 API 연동#79
Ojongseok merged 78 commits into
developfrom
feat/#71-mypage-api

Conversation

@Ojongseok
Copy link
Copy Markdown
Member

@Ojongseok Ojongseok commented Feb 4, 2026

🔗 관련 이슈

📙 작업 설명

  • 계정 설정 / 프로필 편집 화면 분리
  • 닉네임 변경, 탈퇴, 프로필 이미지 변경, 사용자 정보 조회 API 연동 (로그아웃은 API 없이 로컬 토큰만 클리어 후 로그인 화면 이동)
  • HttpClient 내 토큰 캐시를 제거하는 AuthCacheManager 생성
  • 루트 단위 화면을 전환하기 위해 NavigationModule 내 RootNavigationState @provides 정의
  • Pretendard 정상 폰트 교체
  • Release 빌드 환경 구성(signingkey, proguard-rule 설정)

🧪 테스트 내역 (선택)

  • Release 모드 빌드 시 '오픈소스 라이선스 목록' 노출
  • Android OS 12 디바이스 알림 권한 허용 여부에 따른 허용/거부 문구 정상 노출

📸 스크린샷 또는 시연 영상 (선택)

기능 미리보기 기능 미리보기
권한1
KakaoTalk_Video_2026-02-04-23-43-21.mp4
권한2
KakaoTalk_Video_2026-02-04-23-43-15.mp4
라이센스
KakaoTalk_Video_2026-02-04-23-45-57.mp4
닉네임 변경
KakaoTalk_Video_2026-02-04-23-50-31.mp4
프로필 이미지 변경
KakaoTalk_Video_2026-02-04-23-52-16.mp4
탈퇴
KakaoTalk_Video_2026-02-04-23-54-36.mp4

💬 추가 설명 or 리뷰 포인트 (선택)

레전드 고봉밥 죄송합니다...

  • NetworkModule의 HttpClient 속성 설정 중 RefreshToken이 만료되어 로그인 화면으로 이동할 때 BearerAuthConfig 에 null이 들어가게 되는데 Ktor는 인증 문서에 따르면 내부적으로 토큰을 로드하는데 캐싱된 토큰을 가져오기 때문에 로그인 이후에 갱신된 토큰에 접근하지 못하고 캐싱된 null에 접근하는 이슈를 확인했습니다.
...
catch (e: Exception) {
    Timber.e(e)
    tokenRepository.clearTokens()
    authEventManager.emitTokenExpired()
    null
}

그래서 저희처럼 만료 주기가 짧은 경우 캐시를 제어할 수 있도록 3.4.0 버전에서 업데이트가 되었지만 Ktor 버전을 3.4.0으로 올리려 했지만 kotlin, hilt, agp, serialization 등 관련된 다른 버전도 모두 올려주어야 해서 지금 버전을 올리기에는 어렵다고 판단해 소셜로그인 후 캐시를 수동으로 제거하기 위해 AuthCacheManager를 정의했습니다.

  • 플레이스토어 심사 제출을 위해 릴리즈 모드 빌드를 진행했고, 진행 과정에서 signingConfig 설정 및 keystore를 생성하였고, .jks 파일과 패스워드는 노션에 추가해두었습니다. 최상위 루트에 추가해주시면 될 것 같습니다.
  • OssLicenses API와 관련해 아직 edgeToEdge() 제한과 관련해 완전히 대응이 되지 않은 것 같습니다. ISSUE

Q1. 프로필 이미지 변경 시에 서버에서 내려주는 이미지는 String(url)이고(기본 이미지도 url), 앨범에서 선택 시 Uri, 기본 이미지로 설정 시 null 총 3가지 타입에 대해서 상태를 유지해야 하는데 해당 방법 외에 조금 더 나은 아이디어가 있으실까요?

MypageState(
    ...
    val selectedProfileImage: SelectedProfileImage = SelectedProfileImage.NoChange
)

sealed interface SelectedProfileImage {
    data object NoChange : SelectedProfileImage
    data class Selected(val uri: Uri?) : SelectedProfileImage
}

Q2. 프로필 이미지를 변경한 후 이전화면으로 돌아갈 떄 새로운 이미지 url을 로드하는 동안 잠시 아무것도 보이지 않는 현상이 있습니다. 그렇기 때문에 사용자 정보 조회 API를 통해 조회한 새로운 프로필 이미지 url을 캐싱해두고 이전 화면으로 돌아가려 합니다.
캐싱하는 과정에서 context가 필요한데 screen에서 캐싱 요청/캐싱 완료를 의미하는 Intent/Effect를 정의하여 사용자 정보 조회 API -> 캐싱 요청 Effect -> 캐싱 완료 intent -> 뒤로가기 Effect -> 뒤로가기. 의 순서로 진행을 할지, 혹은 Viewmodel에 Context를 주입하여

userRepository.getUserInfo()
      .onSuccess { user ->
          if (isProfileImageChanged) {
              val request = ImageRequest.Builder(context)
                  .data(user.profileImageUrl)
                  .build()
              context.imageLoader.execute(request)
          }

캐싱 완료 후 바로 뒤로가기 Effect를 발생시킬지 고민이 되었는데 현재 ViewModel에 context를 주입하는 방법으로 개발하였습니다. 어떤 방식이 나을지, 혹은 떠오르는 더 좋은 방법이 있으실까요?

캐싱 후 프로필 이미지 url을 로드하는 현상이 제거된 녹화영상입니다.

KakaoTalk_Video_2026-02-05-01-04-00.mp4

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 오픈소스 라이선스 화면 추가
    • 프로필 이미지 업로드·편집 화면 추가
    • 계정 탈퇴(회원탈퇴) 기능 추가
  • Improvements

    • 알림·카메라·위치 권한 관리 흐름 강화 및 권한 안내문구 추가
    • 마이페이지 UI/내비게이션 재구성 및 프로필 관련 흐름 개선
    • 사용자 정보(닉네임, 프로필) 조회·수정 및 관련 네트워크 흐름 개선

@ikseong00
Copy link
Copy Markdown
Contributor

일부에서 선제적으로 호출하더라도 RefreshToken이 만료되는 해당 부분에서 결국 null을 반환하여 메모리에 null이 캐싱된 이후 로그인을 다시하면 캐싱된 null 토큰을 사용하는 현상이 있었기 때문입니다. 그래서 LoginViewModel에서 새로운 토큰을 저장할 떄 authCacheManager.invalidateTokenCache() 구문을 추가하였습니다. 각 ViewModel에서 어떤 처리가 필요하다는 말씀이실까요?

토큰이 업데이트될 때 기존 토큰 캐시가 삭제되어야하기 때문에 invalideTokenCache() 호출은 clearToken 혹은 saveToken 과 동반된다고 생각합니다.

현재 ViewModel 에서 invalideTokenCache()을 호출하고 있는데, LoginViewModel, MyPageViewModel 에서 해당 의존성을 가지고 있고, 토큰을 취소시키는 역할도 가지고 있습니다.

clearToken , saveToken 두 과정에서 토큰 캐시를 없애는 로직이 동반된다면,
TokenRepository에서만 authCacheManager 에 대한 의존성을 참조해서 구현하는 방향으로 할 수 있을 것 같다고 생각됩니다!
그러면 ViewModel에서는 authCacheManager에 대한 의존성이 없어도 된다고 생각해요!

class TokenRepositoryImpl @Inject constructor(
    private val authCacheManager: AuthCacheManager,
) {
    override suspend fun saveTokens(...) {
        authCacheManager.invalidateTokenCache()
        ...
    }
    // clearToken 도 동일
}

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In
`@core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt`:
- Around line 14-29: The code force-unwraps token.idToken in both
UserApiClient.instance.loginWithKakaoTalk and loginWithKakaoAccount which can
cause NPE if idToken is null and also misses the case where error==null &&
token==null; refactor by extracting a single callback handler (e.g.,
handleKakaoLoginResult(token, error, onSuccess, onFailure)) used by both
loginWithKakaoTalk and loginWithKakaoAccount that: 1) checks if error != null ->
call onFailure(error.message ?: "카카오 로그인에 실패했습니다."), 2) else if token?.idToken
!= null -> call onSuccess(token.idToken), 3) else -> call onFailure with a clear
message about missing idToken (e.g., "idToken is null; ensure OIDC/openid scope
is enabled"); this removes duplication and avoids using token.idToken!!.

In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt`:
- Line 12: The initial profileImageState is set to
EditProfileImageType.OriginalImageUrl("") but fetchInitialData's onSuccess
doesn't update it, so the UI won't reflect the server image; update the
ViewModel's fetchInitialData onSuccess reduce block to also set
profileImageState to EditProfileImageType.OriginalImageUrl(user.profileImageUrl)
(i.e., in the reduce { copy(...) } call alongside isLoading and userInfo) so the
ViewState's profileImageState is synchronized with the fetched user data.

In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt`:
- Around line 146-149: The current MyPageViewModel branch calls
uploadProfileImageUseCase(uri = uri) when isProfileImageChanged is true but
casts profileImageState with as? EditProfileImageType.ImageUri which yields null
for EditProfileImageType.Default, causing an unintended null URI; update the
logic in MyPageViewModel so you explicitly branch on profileImageState (check
for EditProfileImageType.ImageUri vs EditProfileImageType.Default) — when
ImageUri present call uploadProfileImageUseCase with that non-null uri, when
Default either skip calling uploadProfileImageUseCase or call it with an
explicit sentinel/flag meaning “reset to default” and add a brief comment
documenting the chosen behavior (or, if null is intended, add a clarifying
comment explaining that uploadProfileImageUseCase(uri = null) denotes
default-reset).
- Around line 52-55: The ClickBackIcon branch sets profileImageState to
EditProfileImageType.OriginalImageUrl but the EditProfileScreen's LaunchedEffect
doesn't handle that case, so update the LaunchedEffect to handle
EditProfileImageType.OriginalImageUrl by setting displayProfileImage =
uiState.profileImageState.url (reference: MyPageIntent.ClickBackIcon,
profileImageState, EditProfileImageType.OriginalImageUrl, displayProfileImage,
LaunchedEffect in EditProfileScreen); also treat empty string URLs as null
before assigning (or map "" -> null) so AsyncImage's model ?: fallback will use
the fallback drawable when state.userInfo.profileImageUrl is empty.

In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt`:
- Around line 85-95: displayProfileImage is initialized with remember {
mutableStateOf(uiState.userInfo.profileImageUrl) } so it never updates when
userInfo.profileImageUrl changes, and the LaunchedEffect ignores the
OriginalImageUrl case; update the logic so OriginalImageUrl sets
displayProfileImage from the latest uiState.userInfo.profileImageUrl (or make
the remembered state depend on uiState.userInfo.profileImageUrl by using
remember(uiState.userInfo.profileImageUrl) { mutableStateOf(...) }) and in the
LaunchedEffect(uiState.profileImageState) handle
EditProfileImageType.OriginalImageUrl by assigning displayProfileImage =
uiState.userInfo.profileImageUrl so the UI reflects server-loaded image updates.

In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt`:
- Around line 46-57: The logout/unlink callbacks currently only call
navigateToLogin on onSuccess, causing the app to hang if kakaoAuthHelper.logout
or kakaoAuthHelper.unlink fails after tokenRepository.clearTokens() ran; update
the handlers in ProfileSettingScreen so both onSuccess and onFailure call
navigateToLogin (and still log the error in onFailure via Timber.e), i.e.,
change the onFailure lambdas for kakaoAuthHelper.logout and
kakaoAuthHelper.unlink to call navigateToLogin() after logging the error to
ensure the user is always routed to the login screen.
🧹 Nitpick comments (3)
core/common/build.gradle.kts (1)

13-13: api 대신 implementation으로 변경 검토

KakaoAuthHelper의 public API는 Kakao SDK 타입을 노출하지 않고 String, Unit, Context만 사용합니다. api로 선언하면 core/common에 의존하는 모든 모듈의 컴파일 클래스패스에 Kakao SDK가 불필요하게 노출되어, 빌드 시간 증가와 의도치 않은 의존성 누출이 발생할 수 있습니다.

-    api(libs.kakao.user)
+    implementation(libs.kakao.user)
core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt (1)

6-8: Context 보유에 대한 메모리 누수 고려

KakaoAuthHelperContext를 필드로 보유하고 있는데, 호출부(LoginScreen.kt)에서 remember { KakaoAuthHelper(context) }로 생성하고 있어 Activity Context가 캡처됩니다. 현재 구조에서는 Composable 생명주기와 함께 해제되므로 큰 문제는 아니지만, 향후 이 헬퍼가 ViewModel이나 싱글턴에서 사용될 경우 Activity 누수 위험이 있습니다. contextlogin()에서만 사용되므로 필드 대신 메서드 파라미터로 전달하는 방식도 고려해 볼 수 있습니다.

feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt (1)

182-197: 회원 탈퇴 실패 시 사용자 피드백이 없습니다.

onFailure에서 Timber.e(it)로 로그만 남기고 isLoading = false로 전환하지만, 사용자에게는 아무런 피드백이 없습니다. 탈퇴는 중요한 작업이므로 실패 시 최소한 토스트나 스낵바를 표시하는 것이 좋습니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt`:
- Around line 182-197: withdrawAccount's onFailure only clears loading and logs
the error, so the user gets no feedback; update the onFailure block to call
postSideEffect with an appropriate MyPageEffect (e.g., MyPageEffect.ShowError or
MyPageEffect.ShowToast) carrying a user-facing message (or the
throwable.message) and keep Timber.e(it) and reduce { copy(isLoading = false) }
as is; reference withdrawAccount, onFailure, postSideEffect, and MyPageEffect
when making the change so the UI can display the error to the user.
- Around line 152-174: The onSuccess handler of userRepository.getUserInfo()
currently only posts MyPageEffect.PreloadImageAndNavigateBack when
isProfileImageChanged is true, so when only the nickname changed
(isNicknameChanged==true and isProfileImageChanged==false) no navigation effect
is emitted; update the onSuccess block in MyPageViewModel.kt so that after
reducing state you post MyPageEffect.PreloadImageAndNavigateBack when
isProfileImageChanged is true, otherwise if isNicknameChanged is true post
MyPageEffect.NavigateBack (ensure you reference the existing flags
isProfileImageChanged and isNicknameChanged and the effects
MyPageEffect.PreloadImageAndNavigateBack / MyPageEffect.NavigateBack in the
userRepository.getUserInfo() onSuccess path).
🧹 Nitpick comments (1)
feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt (1)

80-81: EditProfileScreen의 가시성을 internal로 변경하는 것을 권장합니다.

EditProfileRouteinternal로 선언되어 있지만, EditProfileScreenpublic입니다. 모듈 외부에서 직접 사용될 필요가 없다면 internal로 제한하는 것이 캡슐화에 더 적합합니다.

♻️ 수정 제안
-fun EditProfileScreen(
+internal fun EditProfileScreen(

@Ojongseok
Copy link
Copy Markdown
Member Author

위에서 말씀해주셨던 프로필 이미지 변경 로직 아이디어 반영했습니다. d0f2a1e

갤러리에서 선택한 프로필 이미지와 기존 상태를 동시에 표현하기 위해 val uri : Uri?로 타입을 하나로 표현할까 하다가 오히려 헷갈릴 것 같아 제안해주신 세가지 타입으로 EditProfileImageType을 표현하였습니다.

덕분에 ViewModel 내 Context 제거할 수 있었고, 자연스럽게 동작하게끔 개선할 수 있었습니다!

다만, reduce와 ImageRequest가 병렬로 진행될 시 이미 로딩다이어로그는 화면에서 제거되었지만 바로 뒤로가지 못하고, imageLoader.execute(request)가 완료된 후에 뒤로가는 현상이 있었습니다. (viewModel.store.sideEffects.collectWithLifecycle {} 블럭에서 아직 프리로딩 중 일 때 NavigateBack 이펙트가 들어오더라도 버퍼에 쌓여있기 때문에)

is MyPageEffect.PreloadProfileImage -> {
    val request = ImageRequest.Builder(context)
        .data(sideEffect.url)
        .build()
    context.imageLoader.execute(request)
    navigateBack()
}

그래서 프로필 이미지 변경 후 NavigateBack 이펙트 발생을 제거했고, navigateBack() 구문을 프리로딩 직후에 추가했습니다.


근데 이렇게 수정하니 닉네임만 변경했을 때 뒤로가지 못해서 닉네임만 변경한 경우를 구분해 PreloadImageAndNavigateBack / NavigateBack 이펙트를 발생시키도록 변경했습니다. 602bb8d

Ojongseok added a commit that referenced this pull request Feb 6, 2026
Ojongseok added a commit that referenced this pull request Feb 6, 2026
@Ojongseok
Copy link
Copy Markdown
Member Author

Ojongseok commented Feb 6, 2026

TokenRepository에서만 authCacheManager 에 대한 의존성을 참조해서 구현하는 방향으로 할 수 있을 것 같다고 생각됩니다!
그러면 ViewModel에서는 authCacheManager에 대한 의존성이 없어도 된다고 생각해요!

이해했습니다. 제안해주신 방향이 더 좋겠네요! 반영했습니다. b6062cb

@Ojongseok Ojongseok force-pushed the feat/#71-mypage-api branch from 45c1d16 to b6062cb Compare February 6, 2026 13:02
@Ojongseok Ojongseok merged commit a03d7fd into develop Feb 6, 2026
2 checks passed
@Ojongseok Ojongseok deleted the feat/#71-mypage-api branch February 7, 2026 01:15
Ojongseok added a commit that referenced this pull request May 25, 2026
Ojongseok added a commit that referenced this pull request May 25, 2026
[feat] #71 마이페이지 UI 수정 및 API 연동
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] 마이페이지 API 연동

2 participants