Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 프로필 이미지 변경 및 타임라인 닉네임 표시 #538 #603

Open
wants to merge 32 commits into
base: develop
Choose a base branch
from

Conversation

s6m1n
Copy link
Member

@s6m1n s6m1n commented Jan 18, 2025

⭐️ Issue Number

🚩 Summary

화면별 변경 내용

타임라인

  • ui : 프로필 이미지 표시
  • ui : 닉네임 표시

마이페이지

  • ui : 마이페이지 프로필 이미지 표시
  • feat : 마이페이지 프로필 이미지 변경 기능

스타카토

  • ui : 댓글 프로필 이미지 표시

주요 리팩토링

SharedPreference에 MemberProfile 정보 저장

기존에는 SharedPreference에서 사용자의 token만 저장하고 있었으며,
이번 PR에서 MemberProfile 도메인 모델의 아래 세 프로퍼티를 추가로 저장하도록 수정했습니다.

data class MemberProfile(
    val profileImageUrl: String? = null, // 프로필 이미지
    val nickname: String, // 닉네임
    val uuidCode: String, // 복구 코드 (토큰 아님!)
)

앱을 최초로 실행해 캐시된 멤버 프로필이 없는 상태에서의 흐름 요약

  1. 로그인 화면에 진입한다.
  • 사용자가 닉네임/복구코드로 로그인을 성공한다.
  • 성공 응답으로 온 토큰을 SharedPreference에 저장한다.
  1. 메인 화면에 처음 진입한다
  • 네트워크 요청으로 멤버 프로필을 불러온다.
  • 성공 응답으로 온 멤버 프로필을 SharedPreference에 저장한다.
  • RESULT_OK일 때 SharedPreference의 멤버 프로필을 불러와 viewModel에 set하는 ActivityResultLauncher를 설정한다.
  1. 마이 페이지에서 프로필 사진을 변경하는 네트워크 요청을 보낸다.
  • 성공 시 새로운 프로필 이미지를 SharedPreference에 저장한다.
  • setResult(RESULT_OK)를 수행한다.
  1. 마이 페이지에서 메인 화면으로 돌아간다.
  • RESULT_OK이므로 메인 화면에서는 변경된 프로필이 보여진다.

🙂 To Reviewer

멤버 관련 Repository 네이밍 수정 제안 (feat.백엔드)

MyPageRepository -> MemberProfileRepository

기존의 MyPageRepository는 아래 두 메서드를 갖고 있습니다.

interface MyPageRepository {
    // MemberProfile을 GET
    suspend fun getMemberProfile(): ResponseResult<MemberProfile>
    // MemberProfile의 profileImageUrl을 POST
    suspend fun changeProfileImage(profileImageFile: MultipartBody.Part): ResponseResult<ProfileImageResponse>
}

기존에는 두 메서드 모두 마이페이지 화면에서 호출되었습니다.
그러나 멤버 프로필을 SharedPreference에 캐시로 저장하도록 리팩터링 하는 과정에서 getMemberProfile()의 호출 시점이 메인 화면으로 바뀌었습니다.
이에 따라 MyPageRepository라는 이름이 부적절하다고 생각하였고, 해당 레포지토리에서 MemberProfile 관련 데이터를 다룬다는 의미를 살려 MemberProfileRepository로 변경하는 것을 제안합니다.

MemberRepository -> LoginRepository

또한, 기존의 MemberRepository는 아래와 같습니다.

interface MemberRepository {
    suspend fun fetchTokenWithRecoveryCode(recoveryCode: String): ResponseResult<String>
}

복구 코드로 토큰을 불러오는 로직이 아이디와 비밀번호로 기존의 계정을 불러오는 '로그인'의 로직과 비슷해,
LoginRepository에 있는 게 더 적절할 것 같다는 생각이 듭니다.

LoginRepository -> SignUpRepository

interface LoginRepository {
    suspend fun loginWithNickname(nickname: String): ResponseResult<String>
}

또한 현재 LoginRepository의 loginWithNickname은 닉네임을 입력해 새 계정을 생성한다는 로직을 수행하므로,
로그인 보다는 오히려 회원가입(SignUp)에 가깝다고 생각합니다.

이 부분에 대해 안드로이드 내에서 1차로 논의해보고, 모두 동의한다면 백엔드에게 전달해 URL 변경을 요청해봐도 좋을 것 같습니다.

📋 To Do

글자 수 규칙 정책 논의 & 확정

@s6m1n s6m1n added android We are android>< feat 기능 (새로운 기능) ui xml 작업 labels Jan 18, 2025
@s6m1n s6m1n added this to the sprint-9 milestone Jan 18, 2025
@s6m1n s6m1n self-assigned this Jan 18, 2025
@s6m1n s6m1n force-pushed the feature/#538-member-profile-image-change branch from 08c1ed4 to feae3c7 Compare January 18, 2025 06:35
@hxeyexn hxeyexn linked an issue Jan 18, 2025 that may be closed by this pull request
2 tasks
@Junyoung-WON
Copy link
Contributor

Junyoung-WON commented Jan 19, 2025

getMemberProfile()의 호출 시점이 메인 화면으로 바뀌었습니다.
이에 따라 MyPageRepository라는 이름이 부적절하다고 생각하였고, 해당 레포지토리에서 MemberProfile 관련 데이터를 다룬다는 의미를 살려 MemberProfileRepository로 변경하는 것을 제안합니다.

너무 좋은 것 같아요! 프로필 정보를 불러오는 요청이 마이페이지에만 국한되지 않아서 적절하다고 생각합니다.

복구 코드로 토큰을 불러오는 로직이 아이디와 비밀번호로 기존의 계정을 불러오는 '로그인'의 로직과 비슷해,
LoginRepository에 있는 게 더 적절할 것 같다는 생각이 듭니다.

이 의견도 동의합니다!
닉네임 로그인과 복구코드로 이전 기록을 불러오는 것 모두 토큰 값을 응답으로 받으니, 함께 두는 것이 적합할 것 같아요.

LoginRepository의 loginWithNickname은 닉네임을 입력해 새 계정을 생성한다는 로직을 수행하므로,
로그인 보다는 오히려 회원가입(SignUp)에 가깝다고 생각합니다.

이 부분도 좋은 의견이에요! 사실 새로운 계정을 생성한다는 의미에서는 빙티의 말대로 "SignUp"이라는 이름이 더 적합한 것 같아요.
하지만 우리 서비스의 로그인이 일반적으로 생각하는 "회원가입"이라는 절차와는 달라서 거리가 있다고 생각해요.

저희가 지금과 같이 서비스의 로그인 기능을 구현한 이유는, 사용자에게 회원가입 같은 귀찮은 절차를 없애고, 바로 서비스를 시작할 수 있도록 제공하기 위함이었어요. 리니와 함께 다른 서비스의 회원 등록 절차를 참고했을 때에도 "카카오로 로그인하기", "Login with Google" 과 같이 "로그인"을 강조하는 것을 보았어요. 아마도 저희가 생각한 것과 비슷한 이유일거라고 추측했습니다!

그래서 회원가입(SignUp)을 나타내는 표현보다는, 지금의 이름을 유지하거나 다른 적절한 이름을 짓는 것이 좋다고 생각합니다!
저는 다른 네이밍을 함께 고민해보는 것을 제안하고 싶어요! 어떻게 생각하시나용??

추가로 마지막 의견을 보고 빙티의 의견이 조금 헷갈리는데,
빙티의 의견대로 네이밍을 변경한다면, 혹시 아래와 같이 변경되는 것일까요?

interface MemberProfileRepository {
    // MemberProfile을 GET
    suspend fun getMemberProfile(): ResponseResult<MemberProfile>
    // MemberProfile의 profileImageUrl을 POST
    suspend fun changeProfileImage(profileImageFile: MultipartBody.Part): ResponseResult<ProfileImageResponse>
}

interface LoginRepository {
    suspend fun fetchTokenWithRecoveryCode(recoveryCode: String): ResponseResult<String>
}

interface SignUpRepository {
    suspend fun signUpWithNickname(nickname: String): ResponseResult<String>
}

Copy link
Contributor

@Junyoung-WON Junyoung-WON left a comment

Choose a reason for hiding this comment

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

너무 고생 많으셨어요 빙티!! 빠르게 구현 완료해주셨네요...! 쵝오~~ 😁
드디어 사용자들이 프로필 이미지를 변경할 수 있게 되겠군요...! 👍

또 코드에서도 많은 고민과 개선의 흔적이 보였습니다!! 전체적으로 깔끔해지고, 또 세세한 부분도 신경쓴 것이 느껴졌습니다!
몇 가지 질문들과 개선하면 좋을 부분 코멘트로 남겨놓았어요~ 천천히 확인 부탁드려요!

그리고 도메인 모델인 MemberProfile이 Activity에 나타나있는 것이 아쉬워서, 어떻게하면 좋을까 고민해보고 있어요. 빙티도 혹시 고민 중이라면, 잠시 뒤 도서관에서 같이 이야기해봅시다...!

@@ -11,7 +12,32 @@ class UserInfoPreferencesManager(context: Context) {

suspend fun getToken(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(TOKEN_KEY_NAME, "")
mUserInfoPrefs.getString(TOKEN_KEY_NAME, EMPTY_STRING)
Copy link
Contributor

Choose a reason for hiding this comment

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

빈 문자열도 상수화 처리를 해주셨네요!
저는 예전에 빈 문자열에 대해서도 상수화를 하면 가독성이 떨어질 수 있다는 리뷰를 받은 적이 있었습니다. 빈 문자열인 경우에는 ""로 두는게 더 명시적이라고 느껴져서, 빈 문자열은 따로 처리하지 않아요.

빙티는 혹시 이 의견에 대해서 어떻게 생각하시나요...! 변경을 원하는 부분은 아니고, 순수하게 빙티의 의견이 궁금합니다!

Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분은 빈 문자열을 EMPTY_STRING이라는 이름으로 상수화해서 가독성이 떨어진다고 느껴질 수도 있을 것 같아요~
만약 상수화 하는 방향으로 간다면 좀 더 명시적인 이름으로 변경해봐도 좋을 것 같습니다!


ex) 값이 없는 상태를 뜻하는 단어, defValue라는 것을 뜻하는 단어 등

Copy link
Member Author

@s6m1n s6m1n Jan 31, 2025

Choose a reason for hiding this comment

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

상수화 할래 말래?

""는 꽤 일반적인 문자열이라 현재 프로젝트 곳곳에서 쓰이고 있습니다.
image

따라서 개인적인 선호 + 유지보수의 용이성을 위해,
UserInfoPreferencesManager 내에서 getString의 반환값이 null인 경우를 처리하기 위해 사용되는 것들을 묶어 상수화 했습니다!
호두는 이런 경우를 고려하여도 ""로 두는 게 더욱 명시적이라고 생각하시는지요?.?
답은 없기 때문에 저희 셋이 이야기를 나눠보며 가장 보기 편한 걸로 하면 좋을 것 같아요!~

상수 위치 논의

또한 ""는 MemberProfile 클래스의 isValid에서 Empty 여부를 검사해 유효성을 검사하는 로직과 연관되어 있습니다.

data class MemberProfile(
    val profileImageUrl: String? = null,
    val nickname: String,
    val uuidCode: String,
) {
    fun isValid() = uuidCode.isNotEmpty() && nickname.isNotEmpty()
}

그래서 const val DEFAULT_STRING = ""를 MemberProfile이 들고 있도록 하고 UserInfoPreferencesManager에서 가져다쓰는 게 더 적절하지 않나 생각이 드네요!
(참고로 UserInfoPreferencesManager 클래스에는 이미 import com.on.staccato.domain.model.MemberProfile 가 있습니다.)

상수 이름 추천

값이 없는 상태를 뜻하는 단어, defValue라는 것을 뜻하는 단어 등

또다시 천하제일 작명대회가 열렸습니다.
만약 상수화를 한다면 어떤 이름이 적절할까요?
EMPTY_STRING, DEFAULT_STRING 저는 이 두 개밖에 안 떠오르는데요. 나름 괜찮지 않나..ㅋ.ㅋ

@@ -106,7 +106,7 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler {
if (context is OnUrisSelectedListener) {
uriSelectedListener = context
} else {
throw RuntimeException()
throw IllegalStateException("Activity or Fragment must implement OnUrisSelectedListener")
Copy link
Contributor

Choose a reason for hiding this comment

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

IllegalStateException으로 변경하면서 적절한 예외와 에러 메시지를 나타내게 되었네요! 👍

Comment on lines +19 to +22
@HiltViewModel
class SharedViewModel
@Inject
constructor(private val myPageRepository: MyPageRepository) : ViewModel() {
Copy link
Contributor

Choose a reason for hiding this comment

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

SharedViewModel도 Hilt를 적용해주셨네요~ 👍

@@ -177,7 +178,7 @@ class MemoryUpdateViewModel
context: Context,
uri: Uri,
): Job {
val thumbnailFile = convertMemoryUriToFile(context, uri, name = MEMORY_FILE_NAME)
val thumbnailFile = convertMemoryUriToFile(context, uri, IMAGE_FORM_DATA_NAME)
Copy link
Contributor

Choose a reason for hiding this comment

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

UtilIMAGE_FORM_DATA_NAME를 정의해서 사용했군요!
Activity마다 있던 같은 값의 동반객체가 제거되어서 더욱 깔끔해지고 좋은 것 같습니다!

Comment on lines 13 to 15
<variable
name="viewModel"
type="com.on.staccato.presentation.main.viewmodel.MapsViewModel" />
Copy link
Contributor

Choose a reason for hiding this comment

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

데이터바인딩한 MapsViewModel은 사용되지 않고 있는 것 같아요...! 혹시 따로 추가하신 이유가 있을까요??

Copy link
Member Author

Choose a reason for hiding this comment

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

앗 제거하는 것을 깜빡했네용..

android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:contentDescription="@string/mypage_update_profile_image_description"
android:onClick="@{()->menuHandler.onProfileImageChangeClicked()}"
Copy link
Contributor

Choose a reason for hiding this comment

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

직접 실행해서 터치해보니, 아이콘 크기가 작아서 터치가 잘 되지 않았어요!
그래서 터치가 쉽도록 프로필 변경 이미지 Handler를 프로필 이미지 View에 연결하거나, 아이콘의 터치 영역을 조금 더 넓히는 것이 좋아보여요! (사실 이 작업은 제가 했어야 했습니다...🥲)

추가로 궁금한 점이 있어요! 혹시 handler를 프로필 이미지가 아닌 아이콘에 연결한 이유는, 나중에 프로필 이미지를 터치했을 때 이미지를 확대해서 보여주는 화면 구현을 생각해서인가요??

Copy link
Member Author

@s6m1n s6m1n Jan 31, 2025

Choose a reason for hiding this comment

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

그래서 터치가 쉽도록 프로필 변경 이미지 Handler를 프로필 이미지 View에 연결하거나, 아이콘의 터치 영역을 조금 더 넓히는 것이 좋아보여요!

아이콘에 18dp의 padding을 주어 터치 영역을 넓혔습니다!

추가로 궁금한 점이 있어요! 혹시 handler를 프로필 이미지가 아닌 아이콘에 연결한 이유는, 나중에 프로필 이미지를 터치했을 때 이미지를 확대해서 보여주는 화면 구현을 생각해서인가요??

아뇨 딱히 그런 기능을 의도하진 않았습니다! 여유가 된다면 추가해도 좋을 것 같네요.
단순히 제가 사용자라면 아이콘을 눌러 수정하는 기능을 기대할 것이라고 생각했기 때문이에요..!

@@ -50,17 +50,16 @@
tools:src="@drawable/icon_member" />

<ImageView
android:id="@+id/iv_mypage_profile_image_icon"
android:id="@+id/iv_mypage_profile_image_change_icon"
Copy link
Contributor

Choose a reason for hiding this comment

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

id 이름에 change가 없었군요...?? 🥲
고쳐주셔서 감사해요!

import com.on.staccato.databinding.ActivityMypageBinding
import com.on.staccato.domain.model.MemberProfile
Copy link
Contributor

Choose a reason for hiding this comment

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

Activity 이지만, domainMemberProfile 모델이 import되어있어요...! 현재 구현 상 어쩔 수 없지만, View와 Model 사이의 의존성이 생겨 아쉽습니다.
UiModel을 적용해보는건 어떨까 했지만, SharedPreferences에 접근하여 데이터를 불러오고 설정하기 때문에, UiModel을 도입하는 것도 불필요하다고 느껴졌습니다.

적어도 import 만이라도 없애고 싶은데, 혹시 따로 생각해놓은 개선 방법이 있으신가요...!

Comment on lines +163 to +170
fun startWithResultLauncher(
context: Context,
activityLauncher: ActivityResultLauncher<Intent>,
) {
Intent(context, MyPageActivity::class.java).apply {
activityLauncher.launch(this)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

ActivityResultLauncher 메서드 설정 좋습니다!! 👍

import com.on.staccato.databinding.ActivityMainBinding
import com.on.staccato.domain.model.MemberProfile
Copy link
Contributor

Choose a reason for hiding this comment

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

여기도... 😿
지금 생각 중인 구현이 있는데, 혹시 괜찮다면 코드를 공유해볼까 합니다!

Copy link
Contributor

Choose a reason for hiding this comment

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

아래에 코멘트로 관련 의견을 남겨두었어요...!
이렇게 한다면 View와 Model 간의 의존성을 분리시키고, SharedPreferences의 활용성을 높일 수 있을 것 같습니다!

Copy link
Contributor

@hxeyexn hxeyexn Jan 22, 2025

Choose a reason for hiding this comment

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

View와 Model 간의 의존성을 분리시키고, SharedPreferences의 활용성을 높일 수 있을 것 같습니다!

저도 이와 관련하여 MainActivityUserInfoPreferencesManager에 대한 의견을 남길 부분이 있는데, 몸 상태가 나아지면 생각을 정리해서 공유하겠습니다..! 죄송합니다 ㅠㅠ

➡️ 이와 관련된 코멘트 남겨뒀습니당! #603 (comment)

Comment on lines +48 to +58
override fun onProfileImageChangeClicked() {
if (!photoAttachFragment.isAdded) {
photoAttachFragment.show(fragmentManager, PhotoAttachFragment.TAG)
}
}

override fun onUrisSelected(vararg uris: Uri) {
val imageFile = convertMemoryUriToFile(this, uris.first(), IMAGE_FORM_DATA_NAME)
myPageViewModel.changeProfileImage(imageFile)
}

Copy link
Contributor

Choose a reason for hiding this comment

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

initStartView가 로직 순서 상 제일 먼저 실행되어서 초기화 작업을 해주고 있으니까, 해당 오버라이드 메서드들이 initStartView 보다 아래에 위치하는게 좋을 것 같아요! 순서만 옮겨주시길 요청드려요!

@Junyoung-WON
Copy link
Contributor

Junyoung-WON commented Jan 20, 2025

빙티가 SharedPreferencesManager를 활용하여 기능을 구현하면서, SharedPreferencesManager에 대한 숨어있던 문제점이 나타난 것 같아요...!

현재 SharedPreferencesManager에서 사용자 토큰과 프로필 정보를 함께 관리하고 있어요.
사용자 토큰은 로그인 단계가 아니면 접근하고 변경될 일이 없지만, 프로필의 경우는 서비스 이용 도중에도 접근하거나 변경할 수 있게 바뀌었어요.
지금의 SharedPreferencesManager를 그대로 사용하는 경우, 아래와 같은 문제점이 나타나는 것 같아요.

  • View와 Model(data 레이어)의 의존성 : SharedPreferencesManager는 data 레이어에 있어요. ApplicationContext를 참조하고 있지만, 내부 저장소의 역할을 하고 있습니다. 그래서 Activity가 SharedPreferencesManager에 접근하는 것은 View와 Model 간의 의존성이 생긴다고 생각했습니다.
  • 책임이 너무 많아진 SharedPreferences : 또, 책임이 많아진 SharedPreferences를 Activity가 바로 접근하는게 불필요하다고 느껴졌어요. MainActivityMyPageActivity는 사용자 프로필에 대한 접근만 하면 좋을 것 같았습니다.
  • View와 Model 간의 의존성 발생 : Activity에서 도메인인 MemberProfile에 대한 참조를 가지고 있어요. 또, MemberProfile이 Valid 한지 아닌지에 대한 확인도 이루어지고 있어요.

위의 문제점을 해결하기 위해서는, MVVM 아키텍처를 따르고, 추상화와 의존성 주입을 활용하여 의존 관계를 최대한 끊어내는 것이 방법이 될 것 같아요. 그래서 아래 구현을 제안해봅니다!

  1. SharedPreferencesManager를 interface로 추상화 : 고수준 모듈이 저수준 모듈에 의존적인 관계를 제거
  2. SharedPreferencesManagerHilt 적용 : 싱글턴 모듈을 만들어서 싱글턴 인스턴스로 관리 가능, 의존성 주입 가능
  3. MemberProfile의 접근, 수정에 대한 책임을 MyPageRepository에게 위임 : SharedPreferences의 의존성 주입, MemberProfile 조회 및 수정을 같은 data 레이어에서 처리 가능

1. SharedPreferencesManager를 interface로 추상화

DIP 원칙에 따라, SharedPreferencesManager를 추상화하여 접근하도록 합니다.

  • MemberProfile의 접근, 수정을 하는 MemberProfileManager interface
interface MemberProfileManager {
    suspend fun getMemberProfile(): MemberProfile

    suspend fun setMemberProfile(memberProfile: MemberProfile)

    suspend fun setProfileImageUrl(url: String)
}
  • MemberProfile의 접근, 수정을 하는 MemberProfileManager interface
class UserInfoPreferencesManager(context: Context) : MemberProfileManager {
    /* ... */
    override suspend fun getMemberProfile(): MemberProfile { /* 내부 구현 */ }
    /* 그 외 나머지 override, private 메서드 정의 */
}

MemberProfileManager 인터페이스를 추가로 만들지 않고, 기존의 MyPageRepository 인터페이스를 활용해도 좋을 것 같아요!

2. SharedPreferencesManager의 Hilt 적용

SharedPreferencesManager 또한 의존성 주입이 가능하도록 Hilt를 적용합니다.

  • SharedPreferencesManager 인스턴스를 제공하는 싱글톤 모듈을 생성
@Module
@InstallIn(SingletonComponent::class)
object SharedPreferencesModule {
    @Singleton
    @Provides
    fun provideMemberProfileManager(@ApplicationContext context: Context): MemberProfileManager =
        UserInfoPreferencesManager(context)
}

3. MemberProfile의 접근, 수정에 대한 책임을 MyPageRepository에게 위임

같은 data 레이어인 MyPageDefaultRepository에서 SP를 이용해 내부 저장소에 접근하고 사용자 프로필 정보를 조회, 수정하도록 한다면,
Activity 에서 사용자 프로필을 관리하는 책임을 없앨 수 있습니다.

  • MyPageDefaultRepository에 추상화된 MemberProfileManager을 의존성 주입
class MyPageDefaultRepository
    @Inject
    constructor(
        private val myPageApiService: MyPageApiService,
        private val memberProfileManager: MemberProfileManager,  // MemberProfileManager 프로퍼티 추가
    ) : MyPageRepository {
  • MyPageDefaultRepository에서 MemberProfileManager를 이용해 MemberProfile을 가져오고 저장하는 로직 추가
    CallAdapter가 적용되기 이전이니, 감안하고 참고용으로만 봐주세요!
        override suspend fun getMemberProfile(): ResponseResult<MemberProfile> {
            // memberProfileManager에 접근하여 내부 저장소의 프로필 정보 불러오기
            val cachedMemberProfile = memberProfileManager.getMemberProfile()
            return when {
                cachedMemberProfile.isValid() -> ResponseResult.Success(cachedMemberProfile)
                else -> {
                    val responseResult = handleApiResponse { myPageApiService.getMemberProfile() }
                    when (responseResult) {
                        /* ... */

                        is ResponseResult.Success -> {
                            val myProfile = responseResult.data.toDomain()
                            // memberProfileManager에 접근하여 서버로부터 받은 프로필을 저장
                            memberProfileManager.setMemberProfile(myProfile)
                            ResponseResult.Success(myProfile)
                        }
                    }
                }
            }
        }

        override suspend fun changeProfileImage(profileImageFile: MultipartBody.Part): ResponseResult<ProfileImageResponse> {
            val responseResult =
                handleApiResponse { myPageApiService.postProfileImageChange(profileImageFile) }
            return when (responseResult) {
                /* ... */

                is ResponseResult.Success -> {
                    // memberProfileManager에 접근하여 변경된 프로필 이미지 저장
                    memberProfileManager.setProfileImageUrl(responseResult.data.profileImageUrl ?: "")
                    ResponseResult.Success(responseResult.data)
                }
            }
        }

Copy link
Contributor

@hxeyexn hxeyexn left a comment

Choose a reason for hiding this comment

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

빙티 덕에 드디어 프로필 이미지 기능이 빛을 발하게 되었네요!! 수고많으셨습니다~~👏😆

@@ -11,7 +12,32 @@ class UserInfoPreferencesManager(context: Context) {

suspend fun getToken(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(TOKEN_KEY_NAME, "")
mUserInfoPrefs.getString(TOKEN_KEY_NAME, EMPTY_STRING)
Copy link
Contributor

Choose a reason for hiding this comment

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

@Junyoung-WON

mUserInfoPrefs 변수에 m 키워드를 붙이신 이유가 있으신가요? 혹시 m이 멤버 변수를 뜻한다면 m 키워드는 제거해도 괜찮을 것 같아서요..! 이 부분은 예전에 호두가 작업했던 부분이라 호두에게 여쭤봅니다😄

Copy link
Contributor

@Junyoung-WON Junyoung-WON Jan 31, 2025

Choose a reason for hiding this comment

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

혹시 m이 멤버 변수를 뜻한다면 m 키워드는 제거해도 괜찮을 것 같아서요..!

맞아요! 멤버 변수의 member를 뜻합니다! 클래스 내부에서만 사용되는 private 필드인 경우, 멤버 변수라는 의미로 member를 붙이는 컨벤션입니다.
다만 자바에서 주로 활용되는 컨벤션이니, 코틀린 코드와는 어울리지 않은 것 같네요!
해나의 의견대로 제거하는 것이 좋을 것 같습니다! 👍

Comment on lines 19 to 24
suspend fun getMemberProfile(): MemberProfile =
MemberProfile(
profileImageUrl = getProfileImageUrl() ?: EMPTY_STRING,
nickname = getNickname() ?: EMPTY_STRING,
uuidCode = getRecoveryCode() ?: EMPTY_STRING,
)
Copy link
Contributor

Choose a reason for hiding this comment

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

data class MemberProfile(
    val profileImageUrl: String? = null,
    val nickname: String,
    val uuidCode: String,
) 

현재 저희의 MemberProfilenicknameuuidCodenullable일 수 없어요!
getNickname(), getRecoveryCode() 에서 nullable 하지 않은 상태로 반환해주는 건 어떨까요?


Suggested change
suspend fun getMemberProfile(): MemberProfile =
MemberProfile(
profileImageUrl = getProfileImageUrl() ?: EMPTY_STRING,
nickname = getNickname() ?: EMPTY_STRING,
uuidCode = getRecoveryCode() ?: EMPTY_STRING,
)
suspend fun getMemberProfile(): MemberProfile =
MemberProfile(
profileImageUrl = getProfileImageUrl() ?: EMPTY_STRING,
nickname = getNickname(),
uuidCode = getRecoveryCode(),
)

Copy link
Member Author

@s6m1n s6m1n Jan 31, 2025

Choose a reason for hiding this comment

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

sharedPreference의 getString 메서드 반환 타입이 Nullable이라 아래처럼 수정해주었습니다!

    override suspend fun getMemberProfile(): MemberProfile =
        MemberProfile(
            profileImageUrl = getProfileImageUrl(),
            nickname = getNickname(),
            uuidCode = getRecoveryCode(),
        )

    private suspend fun getProfileImageUrl(): String? {
        return withContext(Dispatchers.IO) {
            userInfoPrefs.getString(PROFILE_IMAGE_URL_KEY_NAME, null)
        }
    }

    private suspend fun getNickname(): String {
        return withContext(Dispatchers.IO) {
            userInfoPrefs.getString(NICKNAME_KEY_NAME, null) ?: EMPTY_STRING
        }
    }

    private suspend fun getRecoveryCode(): String {
        return withContext(Dispatchers.IO) {
            userInfoPrefs.getString(RECOVERY_CODE_KEY_NAME, null) ?: EMPTY_STRING
        }
    }

Comment on lines 32 to 42
private suspend fun getNickname(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(NICKNAME_KEY_NAME, null)
}
}

private suspend fun getRecoveryCode(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(RECOVERY_CODE_KEY_NAME, EMPTY_STRING)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
private suspend fun getNickname(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(NICKNAME_KEY_NAME, null)
}
}
private suspend fun getRecoveryCode(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(RECOVERY_CODE_KEY_NAME, EMPTY_STRING)
}
}
private suspend fun getNickname(): String {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(NICKNAME_KEY_NAME, EMPTY_STRING) ?: EMPTY_STRING
}
}
private suspend fun getRecoveryCode(): String {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(RECOVERY_CODE_KEY_NAME, EMPTY_STRING) ?: EMPTY_STRING
}
}

Copy link
Member Author

Choose a reason for hiding this comment

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

코멘트를 참고해주세요!


private suspend fun getNickname(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(NICKNAME_KEY_NAME, null)
Copy link
Contributor

Choose a reason for hiding this comment

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

getNickname() 메서드에서만 defValueEMPTY_STRING이 아닌 null로 설정하신 이유가 있으신가용?

Copy link
Member Author

Choose a reason for hiding this comment

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

Just my mistake...

@@ -21,8 +47,36 @@ class UserInfoPreferencesManager(context: Context) {
}
}

suspend fun setMemberProfile(memberProfile: MemberProfile) {
setProfileImageUrl(memberProfile.profileImageUrl ?: "")
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분도 EMPTY_STRING을 사용하지 않으신 이유 있으실까용? 🧐

Copy link
Member Author

Choose a reason for hiding this comment

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

깜빡 이슈... 👍

Comment on lines +69 to 82
<ImageView
android:id="@+id/iv_main_mypage"
android:layout_width="40dp"
android:layout_height="40dp"
android:clickable="true"
android:contentDescription="@string/main_mypage_btn_description"
android:focusable="true"
android:onClick="@{() -> handler.onMyPageClicked()}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
bind:coilCircleImageUrl="@{memberProfile.profileImageUrl}"
bind:coilPlaceHolder="@{@drawable/icon_member}" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
Copy link
Contributor

Choose a reason for hiding this comment

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

ConstraintLayout을 활용하고 있기 때문에 LinearLayout은 사용하지 않아도 될 것 같아요~
아래와 같이 구현하면 ImageView만 있어도 동일하게 동작합니다!!

Suggested change
<ImageView
android:id="@+id/iv_main_mypage"
android:layout_width="40dp"
android:layout_height="40dp"
android:clickable="true"
android:contentDescription="@string/main_mypage_btn_description"
android:focusable="true"
android:onClick="@{() -> handler.onMyPageClicked()}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
bind:coilCircleImageUrl="@{memberProfile.profileImageUrl}"
bind:coilPlaceHolder="@{@drawable/icon_member}" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/iv_main_mypage"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="10dp"
android:layout_marginTop="10dp"
android:clickable="true"
android:contentDescription="@string/main_mypage_btn_description"
android:focusable="true"
android:onClick="@{() -> handler.onMyPageClicked()}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
bind:coilCircleImageUrl="@{memberProfile.profileImageUrl}"
bind:coilPlaceHolder="@{@drawable/icon_member}"
tools:src="@drawable/icon_member" />

Copy link
Member Author

Choose a reason for hiding this comment

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

사실 LinearLayout은 테두리를 주기 위함입니다!
너무 미세한 차이이긴 하지만요~..
어떤 게 더 맘에 드시나요?

@hxeyexn
@Junyoung-WON

테두리O 테두리X
image image
image image

Copy link
Contributor

Choose a reason for hiding this comment

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

icon_member_with_border.xml는 삭제해도 될 것 같아요! icon_member와 동일한 아이콘입니다🙂

setRecoveryCode(memberProfile.uuidCode)
}

suspend fun setProfileImageUrl(url: String) {
Copy link
Contributor

Choose a reason for hiding this comment

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

url은 null일 수 있으니 파라미터 타입을 nullable로 설정하면 더 명시적일 것 같아요!

Suggested change
suspend fun setProfileImageUrl(url: String) {
suspend fun setProfileImageUrl(url: String?) {

import com.on.staccato.databinding.ActivityMainBinding
import com.on.staccato.domain.model.MemberProfile
Copy link
Contributor

@hxeyexn hxeyexn Jan 22, 2025

Choose a reason for hiding this comment

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

View와 Model 간의 의존성을 분리시키고, SharedPreferences의 활용성을 높일 수 있을 것 같습니다!

저도 이와 관련하여 MainActivityUserInfoPreferencesManager에 대한 의견을 남길 부분이 있는데, 몸 상태가 나아지면 생각을 정리해서 공유하겠습니다..! 죄송합니다 ㅠㅠ

➡️ 이와 관련된 코멘트 남겨뒀습니당! #603 (comment)

@@ -11,7 +12,32 @@ class UserInfoPreferencesManager(context: Context) {

suspend fun getToken(): String? {
return withContext(Dispatchers.IO) {
mUserInfoPrefs.getString(TOKEN_KEY_NAME, "")
mUserInfoPrefs.getString(TOKEN_KEY_NAME, EMPTY_STRING)
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분은 빈 문자열을 EMPTY_STRING이라는 이름으로 상수화해서 가독성이 떨어진다고 느껴질 수도 있을 것 같아요~
만약 상수화 하는 방향으로 간다면 좀 더 명시적인 이름으로 변경해봐도 좋을 것 같습니다!


ex) 값이 없는 상태를 뜻하는 단어, defValue라는 것을 뜻하는 단어 등

Comment on lines 203 to 227
private fun loadMemberProfile() {
lifecycleScope.launch {
val cachedMemberProfile = getCachedMemberProfile(this)
if (cachedMemberProfile.isValid()) {
sharedViewModel.setMemberProfile(cachedMemberProfile)
} else {
sharedViewModel.fetchMemberProfile()
}
}
}

private suspend fun getCachedMemberProfile(scope: CoroutineScope): MemberProfile {
return scope.async {
userInfoPrefsManager.getMemberProfile()
}.await()
}

private fun observeMemberProfile() {
sharedViewModel.memberProfile.observe(this) { memberProfile ->
lifecycleScope.launch {
userInfoPrefsManager.setMemberProfile(memberProfile)
}
binding.memberProfile = memberProfile
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

UserInfoPreferencesManagerSharedPreferences를 활용해 데이터를 저장하고 가져오는 등의 역할을 담당하고 있습니다.

data layer 네이밍 컨벤션 가이드에 따르면, SharedPreferences를 사용하는 DataSource에 대해 네이밍을 언급하고 있는 것을 확인할 수 있습니다. 따라서 ~Manager라는 이름보다는 ~Repository 또는 ~DataSource(추상화에 따라 결정)로 네이밍하는 것이 더 명시적이라고 생각해요!

이제, UserInfoPreferencesManager~Repository 또는 ~DataSource로 생각하고 현재 MainActivity를 살펴보겠습니다. 개인적으로 MainActivity에서 사용자의 정보를 불러오거나 저장하기 위해 UserInfoPreferencesManager의 함수를 직접 호출하는 것이 어색하게 느껴졌습니다.

이러한 문제점을 해결하기 위해 UserInfoPreferencesManager의 이름을 ~Manager가 아닌 ~Repository 또는 ~DataSource(추상화에 따라 결정)로 변경하고, MainActivity에서UserInfoPreferencesManager의 함수를 호출하는 부분을 ViewModel에서 처리하도록 변경하는 건 어떨까요? 🧐

cache된 MemberProfile은 ViewModel이 관리
ViewModel 내에서 cachedMemberProfile의 유효성을 검증하고 그 결과에 따라 MemberProfile set 또는 fetch


또한 UserInfoPreferencesManager 에서 Preferences라는 단어는 제거하면 어떨까요?
data layer 네이밍 컨벤션 가이드에 따르면, SharedPreferences를 datastore로 마이그레이션하는 등의 상황을 고려해 구현 세부사항을 이름에 포함하지 않는 것을 권장하고 있어요!

Copy link
Member Author

@s6m1n s6m1n Jan 27, 2025

Choose a reason for hiding this comment

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

MainActivity에서 사용자의 정보를 불러오거나 저장하기 위해 UserInfoPreferencesManager의 함수를 직접 호출하는 것이 어색하게 느껴졌습니다.

또한 UserInfoPreferencesManager 에서 Preferences라는 단어는 제거하면 어떨까요?

저도 해나의 의견에 동의합니다!

다만 현재 UserInfoPreferencesManager 클래스의 경우 해당 PR에 따라 Application에서 인스턴스로 만들어 앱 전역에서 사용할 수 있는 구조인데요..

위 제안에 따라 SharedPreference를 DataSource로 변경한다면 Application.sharedPreference처럼 직접 접근할 수 없고, ViewModel의 Repository를 통해서만 데이터를 CRUD 할 수 있는 구조로 변경해야겠네요.

제가 맞게 이해한 걸까요?.?

Copy link
Contributor

Choose a reason for hiding this comment

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

위 제안에 따라 SharedPreference를 DataSource로 변경한다면 Application.sharedPreference처럼 직접 접근할 수 없고, ViewModel의 Repository를 통해서만 데이터를 CRUD 할 수 있는 구조로 변경해야겠네요.

제가 맞게 이해한 걸까요?.?

네~ 맞습니다😄

Copy link
Member Author

@s6m1n s6m1n Jan 31, 2025

Choose a reason for hiding this comment

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

해당 코멘트와 호두의 코멘트에 대한 부분 반영 완료했습니다!

변경 내용

SharedPreferenceMyPageLocalDataSource로 추상화하고
MyPageDefaultRepository에서 로컬 데이터와 서버 데이터를 적절히 처리하도록 했습니다.

class MyPageDefaultRepository
    @Inject
    constructor(
        private val myPageLocalDataSource: MyPageLocalDataSource,
        private val myPageRemoteDataSource: MyPageRemoteDataSource,
    ) : MyPageRepository {

        // 로컬 or 서버에서 프로필 불러오기
        override suspend fun getMemberProfile(): ApiResult<MemberProfile> {
            val localProfile = myPageLocalDataSource.getMemberProfile() // 로컬 데이터를 불러온다
            return if (localProfile.isValid()) { // 로컬 데이터가 유효하면
                Success(localProfile) // 로컬 데이터 사용
            } else { // 아니면
                syncMemberProfile() // 서버 데이터 사용
            }
        }

        // 로컬 프로필을 서버 프로필과 동기화
        private suspend fun syncMemberProfile(): ApiResult<MemberProfile> {
            return myPageRemoteDataSource.loadMemberProfile().handle { // 서버 프로필을 불러온다
                val serverProfile = it.toDomain()
                myPageLocalDataSource.updateMemberProfile(serverProfile) // 로컬 프로필을 업데이트 한다
                serverProfile
            }
        }

        // 로컬 & 서버 프로필 이미지 업데이트
        override suspend fun changeProfileImage(profileImageFile: MultipartBody.Part): ApiResult<String?> =
            myPageRemoteDataSource.updateProfileImage(profileImageFile).handle { // 서버 프로필 이미지를 업데이트 한다
                val imageUrl = it.profileImageUrl
                myPageLocalDataSource.updateProfileImageUrl(imageUrl) // 로컬 프로필 이미지를 업데이트 한다
                imageUrl
            }
    }

이로써 메인 화면과 마이페이지 화면의 Activity에서 SharedPreferences에 직접 접근하지 않고 ViewModel의 Repository를 통해 데이터를 읽고 쓸 수 있게 되었습니다.!

+)

Activity 이지만, domain의 MemberProfile 모델이 import되어있어요...! 현재 구현 상 어쩔 수 없지만, View와 Model 사이의 의존성이 생겨 아쉽습니다.
UiModel을 적용해보는건 어떨까 했지만, SharedPreferences에 접근하여 데이터를 불러오고 설정하기 때문에, UiModel을 도입하는 것도 불필요하다고 느껴졌습니다.

@Junyoung-WON 의 코멘트 중 위 내용에 대한 질문입니다!
MemberProfile은 도메인 모델 객체 입니다.
저는 도메인 모델이 '도메인의 핵심 개념과 규칙을 객체로 표현한 것', '도메인을 개념적으로 표현한 것'이기에 특정 계층에 종속되지 않고 여러 곳에서 보편적으로 사용될 수 있다고 생각했습니다.
Activity에서 도메인 모델을 참조하는 것View와 Model 간의 의존성이 발생했다고 여겨 무조건 지양하는 게 맞을까? 하는 의문이 드는데, 호두는 어떤 이유로 참조해선 안된다고 생각하시는지 궁금합니다!

s6m1n added 2 commits January 27, 2025 18:52
# Conflicts:
#	android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/viewmodel/MemoryCreationViewModel.kt
#	android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/viewmodel/MemoryUpdateViewModel.kt
#	android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/viewmodel/StaccatoCreationViewModel.kt
#	android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/viewmodel/StaccatoUpdateViewModel.kt
@s6m1n s6m1n force-pushed the feature/#538-member-profile-image-change branch from 75bd753 to ee371e3 Compare January 31, 2025 18:32
Copy link

Test Results

 33 files   33 suites   6s ⏱️
238 tests 238 ✅ 0 💤 0 ❌
255 runs  255 ✅ 0 💤 0 ❌

Results for commit ee371e3.

Copy link

🌻Test Coverage Report

Overall Project 78.55% 🍏
Files changed 100% 🍏

File Coverage
StaccatoDtoMapper.java 96.3% 🍏

@s6m1n
Copy link
Member Author

s6m1n commented Jan 31, 2025

refactor: ApiResult의 제네릭 타입 제약 제거 커밋을 확인해주세요!

사용자 프로필이 없는 경우 ApiResult<String?>를 지원해야 함

MyPageRepository 인터페이스의 changeProfileImage()는 내부에서 MyPageApiService.postProfileImageChange()를 호출하고 있습니다.

interface MyPageRepository {
    suspend fun getMemberProfile(): ApiResult<MemberProfile>

    suspend fun changeProfileImage(profileImageFile: MultipartBody.Part): ApiResult<String?>
}

interface MyPageApiService {
    // 기타 생략 ...
    @Multipart
    @POST(PROFILE_IMAGE_CHANGE_PATH)
    suspend fun postProfileImageChange(
        @Part imageFile: MultipartBody.Part,
    ): ApiResult<ProfileImageResponse>
}

만약 사용자 프로필이 없다면, ProfileImageResponse의 profileImageUrl 값으로 null이 들어옵니다.

@Serializable
data class ProfileImageResponse(
    @SerialName("profileImageUrl") val profileImageUrl: String? = null,
)

따라서 ApiResult의 T가 nullable 타입인 String?을 지원할 수 있도록 Any 타입 제약을 제거하였습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
android We are android>< feat 기능 (새로운 기능) ui xml 작업
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

feat: 프로필 이미지 변경 기능
4 participants