Skip to content

Conversation

@ThirFir
Copy link
Collaborator

@ThirFir ThirFir commented Sep 19, 2025

💻 Work Description

  • Profile feature 리팩토링 진행
  • 도메인, 데이터 레이어에 해당하는 로직들에 대한 새 구현 작성
  • 데이터 레이어
    • Profile Repository 구현 및 테스트
    • Profile Remote 데이터소스 구현 및 테스트
    • Profile Retrofit api 인터페이스 (레트로핏 관련 어노테이션은 작성하지 않은 단계)
  • 도메인 레이어
    • 닉네임 유효성 검사 UseCase + 테스트
    • 생년월일 유효성 검사 UseCase + 테스트
    • api 에러 코드 매핑

Summary by CodeRabbit

  • New Features
    • 닉네임·생일 입력 유효성 검사가 강화되어 잘못된 형식을 즉시 안내합니다.
    • 프로필 정보 로컬 캐시로 재실행 시 프로필 로딩이 더 빨라집니다.
    • 저장한 스팟(북마크) 썸네일 표시 로직 개선으로 빈 이미지 처리 품질이 향상됩니다.
  • Refactor
    • 프로필·프로필 수정·북마크 화면이 레거시 경로로 일원화되어 내비게이션 안정성이 향상되었습니다.
    • 설정 및 스팟 상세 화면의 프로필 관련 연동을 레거시 플로우로 정리해 일관성을 높였습니다.

- ProfileApi, ProfileRemoteDataSource 인터페이스 정의
- ProfileRemoteDataSourceImpl 구현
- 관련 DTO(Request/Response) 클래스 추가
- 모든 public 메서드에 대한 성공/실패 단위 테스트 케이스 작성
- ProfileRepository, ProfileLocalDataSource 인터페이스 정의
- ProfileRepository getProfile 구현
- 관련 모델 추가
- getProfile 성공 케이스에 대한 테스트 작성
- 모든 메서드에 대한 테스트 코드 작성(커버리지 100%)
Repository, Local/Remote DataSource Hilt 모듈
@coderabbitai
Copy link

coderabbitai bot commented Sep 19, 2025

Walkthrough

프로필 전반을 “레거시” 경로로 전환하면서, 네비게이션/화면/레포지토리/DI를 이중화했다. 동시에 “신규” 프로필 도메인/DTO/레포지토리 흐름을 도입해 로컬 캐시 + 원격 API(ProfileApi) 기반으로 재구성했다. 도메인 모델/에러/유스케이스와 테스트가 이에 맞게 추가·정리되었다.

Changes

Cohort / File(s) Summary
App Navigation → Legacy 경로 전환
app/src/main/java/com/acon/acon/navigation/AconNavigation.kt, .../navigation/nested/ProfileNavigationLegacy.kt, .../navigation/nested/SettingsNavigation.kt, .../navigation/nested/SpotNavigation.kt, core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRouteLegacy.kt, core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRoute.kt
프로필 네비게이션 타깃을 Legacy로 교체. ProfileRoute 삭제, ProfileRouteLegacy 추가. 관련 import·호출을 Legacy로 변경.
Profile Feature UI (Legacy)
feature/profile/.../MockSavedSpotList.kt, .../bookmark/BookmarkViewModel.kt, .../bookmark/composable/BookmarkScreen.kt, .../profile/ProfileViewModelLegacy.kt, .../profile/composable/ProfileScreenContainerLegacy.kt, .../profile/composable/ProfileScreenLegacy.kt, .../profile/composable/BookmarkItemLegacy.kt, .../profile/composable/BookmarkSkeletonItemLegacy.kt, .../profileMod/ProfileModViewModelLegacy.kt, .../profileMod/composable/ProfileModScreenContainerLegacy.kt, .../profileMod/composable/ProfileModScreenLegacy.kt
프로필/북마크/프로필 수정 화면을 Legacy 컴포넌트·상태로 분기. SavedSpot → SavedSpotLegacy 사용. 컨테이너/아이템/스켈레톤/뷰모델 명세를 Legacy로 교체.
Settings/Spot에서 Legacy 레포 사용
feature/settings/.../UserVerifiedAreasViewModel.kt, feature/spot/.../SpotDetailViewModel.kt, feature/spot/.../spotlist/SpotListViewModel.kt
DI 의존성을 ProfileRepository → ProfileRepositoryLegacy로 변경(SpotList는 import 정리).
Remote API / DTO 정비
core/data/api/remote/ProfileApi.kt, core/data/api/remote/auth/ProfileAuthApiLegacy.kt, core/data/api/remote/auth/SpotAuthApi.kt, core/data/dto/request/profile/UpdateProfileRequest.kt, core/data/dto/request/UpdateProfileRequestLegacy.kt, core/data/dto/response/profile/ProfileResponse.kt, .../ProfileResponseLegacy.kt, .../SavedSpotResponse.kt, .../SavedSpotsResponseLegacy.kt
신규 ProfileApi 인터페이스 추가. Legacy Auth API 타입 교체. SavedSpot DTO 이원화(현행/레거시). ProfileResponse 리모델링 및 매핑 변경(도메인 Profile).
DataSource/Cache
core/data/datasource/remote/ProfileRemoteDataSource.kt, .../ProfileRemoteDataSourceLegacy.kt, .../SpotRemoteDataSource.kt, core/data/cache/ProfileInfoCache.kt, .../ProfileInfoCacheLegacy.kt, core/data/datasource/local/ProfileLocalDataSource.kt
프로필 원격 DS를 인터페이스+구현으로 재구성(ProfileApi 위임). Legacy 전용 원격 DS 추가. 기존 ProfileInfoCache 삭제, Legacy 캐시 추가. 로컬 DS(Flow 캐시) 신설.
DI 모듈
core/data/di/ApiModule.kt, .../CacheModule.kt, .../LocalDataSourceModule.kt, .../RemoteDataSourceModule.kt, .../RepositoryModule.kt
API/캐시/레포 바인딩을 Legacy·신규 이원화. Local/Remote DS 바인딩 모듈 추가.
Repositories
core/data/repository/ProfileRepositoryImpl.kt, .../ProfileRepositoryLegacyImpl.kt, .../SpotRepositoryImpl.kt, .../UserRepositoryImpl.kt
신규 레포: 로컬 우선 흐름(Flow<Result>)과 단순화된 API 적용. Legacy 레포 추가(기존 기능 유지). Spot/User 레포는 Legacy 타입/캐시로 전환.
Domain 모델/에러/유스케이스
core/model/.../profile/BirthDateStatus.kt, .../Profile.kt, .../ProfileImageStatus.kt, .../SpotThumbnailStatus.kt, .../SavedSpot.kt, .../SavedSpotLegacy.kt, .../ProfileInfoLegacy.kt, domain/.../error/Constants.kt, .../profile/UpdateProfileError.kt, .../ValidateBirthDateError.kt, .../ValidateNicknameError.kt, .../ValidateNicknameErrorLegacy.kt, domain/.../repository/ProfileRepository.kt, .../ProfileRepositoryLegacy.kt, .../SpotRepository.kt, domain/.../usecase/ValidateBirthDateUseCase.kt, .../ValidateNicknameUseCase.kt
신규 도메인(Profile, Status 계열) 도입 및 SavedSpot 구조 변경. 에러 체계 분리(현행/레거시). 레포 인터페이스 개편 및 Legacy 인터페이스 신설. 입력 검증 유스케이스 추가.
Tests / Build
core/data/src/test/...ProfileLocalDataSourceTest.kt, ...ProfileRemoteDataSourceTest.kt, ...repository/ProfileRepositoryImplTest.kt, .../ProfileRepositoryTest.kt, .../SpotRepositoryImplTest.kt, .../UserRepositoryImplTest.kt, domain/build.gradle.kts, domain/src/test/.../ValidateBirthDateUseCaseTest.kt, .../ValidateNicknameUseCaseTest.kt
신규/레거시 흐름에 맞춘 테스트 추가·수정. 일부 테스트 비활성화(주석 처리). 도메인 모듈 JUnit5 플랫폼 활성화.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor VM as ViewModel/UseCase
  participant Repo as ProfileRepositoryImpl (신규)
  participant Local as ProfileLocalDataSource
  participant Remote as ProfileRemoteDataSource (ProfileApi)

  VM->>Repo: getProfile(): Flow<Result<Profile>>
  activate Repo
  Repo->>Local: getProfile(): Flow<Profile?>
  Note over Repo,Local: 로컬에 값이 없으면 원격 조회
  alt 로컬 값 존재
    Local-->>Repo: Profile?
    Repo-->>VM: Result.success(Profile)
  else 로컬 값 없음
    Repo->>Remote: getProfile()
    Remote-->>Repo: ProfileResponse
    Repo->>Repo: toProfile() 매핑
    Repo->>Local: cacheProfile(Profile)
    Local-->>Repo: (완료)
    Repo-->>VM: Result.success(Profile)
  end
  deactivate Repo
Loading
sequenceDiagram
  autonumber
  actor UI as Profile Editor (신규)
  participant Repo as ProfileRepositoryImpl
  participant Remote as ProfileRemoteDataSource
  participant Local as ProfileLocalDataSource

  UI->>Repo: updateProfile(newProfile)
  Repo->>Repo: newProfile.toUpdateProfileRequest()
  Repo->>Remote: updateProfile(request)
  Remote-->>Repo: Unit
  Repo->>Local: cacheProfile(newProfile)
  Local-->>Repo: (완료)
  Repo-->>UI: Result.success(Unit)
  %% 오류 시
  Note over Repo,UI: 실패 시 Result.failure(UpdateProfileError ...)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

(\_/) 토끼가 말했지:
레거시 길로 살짝 점프! 새 길도 함께 챙겼지.
캐시는 먼저, 서버는 다음—슥.
닉네임과 생일은 내가 본다—쓱.
그래프 바꾸고 DTO 다듬고, 테스트는 토닥토닥.
오늘도 깡총, 릴리즈로 향해 띠용! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed 제목 "[Refactor] 프로필 리팩토링 : 데이터, 도메인 레이어"는 PR의 주된 변경 사항인 프로필 기능의 데이터·도메인 레이어 리팩토링을 명확히 요약하고 있어 변경 내역 및 PR 목적과 일치합니다. 다만 접두사 '[Refactor]'와 '리팩토링' 단어가 중복되지만 의미 전달에는 문제가 없고 팀원이 이력을 훑을 때도 핵심을 바로 이해할 수 있습니다.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/profile-data-layer

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

Copy link

@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: 16

Caution

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

⚠️ Outside diff range comments (6)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenLegacy.kt (1)

151-167: Dialog onDismiss에서 닫기 콜백이 아니라 열기 토글을 호출합니다.

onDismissRequest 시에는 닫기 콜백(onDisMissExitDialog)을 호출해야 합니다. 현재 구현은 의도와 달라 오동작 가능성이 있습니다.

-    onDismissRequest = {
-        onRequestExitDialog()
-    },
+    onDismissRequest = {
+        onDisMissExitDialog()
+    },
core/data/src/test/java/com/acon/core/data/repository/SpotRepositoryImplTest.kt (1)

84-85: Kotlin 리터럴 문법 오류: .00.0. 컴파일이 불가합니다.

부동소수점 리터럴은 선행 0이 필요합니다.

-        val result = spotRepositoryImpl.fetchSpotList(.0, .0, mockk(relaxed = true))
+        val result = spotRepositoryImpl.fetchSpotList(0.0, 0.0, mockk(relaxed = true))
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/ProfileModViewModelLegacy.kt (2)

295-318: 이미지 처리 로직의 리소스 관리 문제

InputStream과 HTTP Response가 제대로 닫히지 않아 리소스 누수가 발생할 수 있습니다.

if (state.selectedPhotoUri.startsWith("content://")) {
-    val inputStream = context.contentResolver.openInputStream(imageUri)
-    byteArray = inputStream?.readBytes()
-        ?: throw IllegalArgumentException("이미지 읽기 실패")
+    byteArray = context.contentResolver.openInputStream(imageUri)?.use { 
+        it.readBytes() 
+    } ?: throw IllegalArgumentException("이미지 읽기 실패")
    mimeType = context.contentResolver.getType(imageUri) ?: "image/jpeg"

} else if (state.selectedPhotoUri.startsWith("http://") || state.selectedPhotoUri.startsWith("https://")) {
    Timber.tag(TAG).d("원격 URL에서 이미지 가져오기 시작")
    val getRequest = Request.Builder().url(imageUri.toString()).build()
-    val getResponse = client.newCall(getRequest).execute()
-
-    if (!getResponse.isSuccessful) {
-        Timber.tag(TAG).e("원격 이미지 가져오기 실패, code: %d", getResponse.code)
-        throw IllegalArgumentException("원격 이미지 가져오기 실패")
-    }
-
-    byteArray = getResponse.body?.bytes()
-        ?: throw IllegalArgumentException("원격 이미지 읽기 실패")
-    mimeType = getResponse.header("Content-Type") ?: "image/jpeg"
+    client.newCall(getRequest).execute().use { getResponse ->
+        if (!getResponse.isSuccessful) {
+            Timber.tag(TAG).e("원격 이미지 가져오기 실패, code: %d", getResponse.code)
+            throw IllegalArgumentException("원격 이미지 가져오기 실패")
+        }
+
+        byteArray = getResponse.body?.bytes()
+            ?: throw IllegalArgumentException("원격 이미지 읽기 실패")
+        mimeType = getResponse.header("Content-Type") ?: "image/jpeg"
+    }

173-174: 도메인 로직과 생년월일 검증 기준(1900년) 일치
ValidateBirthDateUseCase에서 최소 허용 연도를 1900년으로 설정(pastThreshold = LocalDate.of(1900, 1, 1))하므로, ViewModel에서도 하드코딩된 1940년 대신 1900년 기준을 사용해야 합니다.

-if (year > currentYear || year <= 1940) return false
+// 도메인 레이어의 ValidateBirthDateUseCase와 일관성 유지
+if (year > currentYear || year < 1900) return false
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenContainerLegacy.kt (1)

40-56: UpdateProfileType 처리 흐름 통합 필요 — Legacy 상태흐름과 Result 혼재

검증 결과: UpdateProfileType 기반 상태 흐름이 여전히 사용됩니다 (core/data/src/.../ProfileRepositoryLegacyImpl.kt, domain/src/.../ProfileRepositoryLegacy.kt, feature/profile/.../ProfileModViewModelLegacy.kt — updateProfile 후 updateProfileType emit, feature/profile/.../ProfileViewModelLegacy.kt — val updateProfileState = profileRepositoryLegacy.getProfileType(), feature/profile/.../ProfileScreenContainerLegacy.kt — collectLatest로 SUCCESS 처리).

조치(택1):

  • Result 기반으로 통일: ViewModel이 updateProfile 결과를 처리해 UI 알림/사이드이펙트만 담당하도록 하고 repository의 updateProfileType/getProfileType/resetProfileType API 제거(관련 파일: domain/src/.../ProfileRepositoryLegacy.kt, core/data/src/.../ProfileRepositoryLegacyImpl.kt, feature/profile/.../ProfileModViewModelLegacy.kt, ProfileViewModelLegacy.kt, ProfileScreenContainerLegacy.kt).
  • 또는 repository 중심 유지: updateProfile 내부에서 성공/실패 시 UpdateProfileType을 emit하도록 통합하고 ViewModel/UI는 상태흐름만 구독하도록 중복된 Result 처리 제거.
core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryImplTest.kt (1)

129-131: 잘못된 expectedErrorClass 타입 파라미터

deleteVerifiedAreaErrorScenarios 테스트에서 expectedErrorClass의 타입이 KClass<ReplaceVerifiedArea>로 되어 있지만, 실제로는 KClass<DeleteVerifiedAreaError>이어야 합니다.

 fun `인증 지역 삭제 API 실패 시 에러 객체를 반환한다`(
     errorCode: Int,
-    expectedErrorClass: KClass<ReplaceVerifiedArea>
+    expectedErrorClass: KClass<DeleteVerifiedAreaError>
 ) = runTest {
♻️ Duplicate comments (1)
app/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt (1)

15-15: Settings와의 프로필 네비게이션 정책 일치 여부 확인

여기는 ProfileRouteLegacy.Graph로 이동하고, Settings 쪽은 leaf로 이동합니다. 의도된 차별화인지 확인하고, 아니라면 동일 정책으로 정리해 주세요.

Also applies to: 38-39

🧹 Nitpick comments (56)
core/model/src/main/java/com/acon/acon/core/model/model/profile/SavedSpotLegacy.kt (1)

3-7: 필드 명확성(네이밍) 제안

image는 URL 의미로 보입니다. imageUrl로의 리네임을 고려하면 가독성이 좋아집니다. 파급범위가 크면 유지하되 KDoc에 의미를 명시해 주세요.

feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkItemLegacy.kt (3)

50-60: 수동 문자열 자르기 대신 Compose Ellipsis 사용 권장

문자 수 기준 수동 절단은 다국어/이모지에서 깨질 수 있습니다. maxLines=1 + overflow=TextOverflow.Ellipsis로 교체하세요.

다음과 같이 정리 가능합니다:

-            Text(
-                text = if (spot.name.length > 9) spot.name.take(8) + stringResource(R.string.ellipsis) else spot.name,
+            Text(
+                text = spot.name,
                 color = AconTheme.color.White,
                 style = AconTheme.typography.Title5,
                 fontWeight = FontWeight.SemiBold,
+                maxLines = 1,
+                overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
                 modifier = Modifier
                     .align(Alignment.TopCenter)
                     .fillMaxWidth()
                     .padding(top = 20.dp)
                     .padding(horizontal = 20.dp)
             )

같은 변경을 아래 else 분기(라인 71-81)에도 적용해 주세요.


62-69: 플레이스홀더 이미지 a11y 보강

contentDescription = null 대신 적절한 설명 문자열을 제공하면 스크린리더 접근성이 향상됩니다.

-            Image(
+            Image(
                 painter = painterResource(R.drawable.ic_bg_no_store_profile),
-                contentDescription = null,
+                contentDescription = stringResource(R.string.no_store_image),
                 contentScale = ContentScale.Crop,
                 modifier = Modifier
                     .fillMaxSize()
                     .imageGradientLayer()
             )

29-33: FQCN 대신 import 사용 권장

파라미터 타입을 완전수식명으로 표기하면 가독성이 떨어집니다. 파일 상단에 import 추가 후 간결하게 쓰세요.

-import com.acon.acon.core.model.model.profile.SavedSpotLegacy
+import com.acon.acon.core.model.model.profile.SavedSpotLegacy
 ...
-internal fun BookmarkItemLegacy(
-    spot: com.acon.acon.core.model.model.profile.SavedSpotLegacy,
+internal fun BookmarkItemLegacy(
+    spot: SavedSpotLegacy,
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkSkeletonItemLegacy.kt (1)

15-19: 공개 API 축소: internal로 한정 권장

UI 스켈레톤은 모듈 외부 노출 필요가 낮습니다. internal로 범위를 줄여 공개 API 표면을 최소화하세요.

-@Composable
-fun BookmarkSkeletonItemLegacy(
+@Composable
+internal fun BookmarkSkeletonItemLegacy(
     skeletonHeight: Dp,
     modifier: Modifier = Modifier
 ) {
core/data/src/main/kotlin/com/acon/core/data/dto/request/UpdateProfileRequestLegacy.kt (1)

8-13: @OptIn 위치 및 기본값 인코딩 정책 일관화

  • @OptIn은 클래스에 부착하는 형태가 더 일반적입니다(생성자 타겟보다 가독성↑).
  • birthDateEncodeDefault.NEVER인 반면 profileImage는 누락되어 정책이 불일치합니다. 서버가 null 필드 생략을 기대한다면 동일 정책으로 맞추세요.
-@Serializable
-data class UpdateProfileRequestLegacy @OptIn(ExperimentalSerializationApi::class) constructor(
-    @SerialName("profileImage") val profileImage: String? = null,
+@OptIn(ExperimentalSerializationApi::class)
+@Serializable
+data class UpdateProfileRequestLegacy(
+    @SerialName("profileImage") @EncodeDefault(EncodeDefault.Mode.NEVER) val profileImage: String? = null,
     @SerialName("nickname") val nickname: String,
     @SerialName("birthDate") @EncodeDefault(EncodeDefault.Mode.NEVER) val birthDate: String? = null
 )

서버가 null 필드 생략을 요구하는지 API 스펙을 다시 확인해 주세요. 필요 시 두 필드 모두 동일 정책으로 유지해야 합니다.

core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRouteLegacy.kt (1)

5-18: sealed interface로 변경해 외부 모듈 임의 구현 방지

ProfileRouteLegacy를 sealed interface로 선언해 외부에서 새로운 구현체 추가를 막습니다. Compose Navigation에서 composable<ProfileRouteLegacy.ProfileLegacy> 등으로 사용 중인 것도 확인되었습니다.

-interface ProfileRouteLegacy {
+sealed interface ProfileRouteLegacy {
core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotsResponseLegacy.kt (3)

8-10: nullable 리스트 대신 기본값 emptyList로 두는 것을 권장

API가 null을 반환하지 않는 계약이라면, nullable보다 기본값을 두는 편이 NPE 방지와 사용성에 유리합니다. 서버가 null을 줄 가능성이 있다면 계약을 확인해 주세요.

적용 예시:

- data class SavedSpotsResponseLegacy(
-     @SerialName("savedSpotList") val savedSpotResponseLegacyList: List<SavedSpotResponseLegacy>?
- )
+ data class SavedSpotsResponseLegacy(
+     @SerialName("savedSpotList") val savedSpotResponseLegacyList: List<SavedSpotResponseLegacy> = emptyList()
+ )

19-23: spotId를 0L로 대체하면 충돌/의미 손실 위험

null id를 0L로 치환하면 실제 id=0과 구분이 어려워집니다. null을 가진 항목은 매핑 단계에서 제외하거나, 상위 컨테이너에서 mapNotNull 처리하는 패턴을 고려해 주세요.

참고 예(컨테이너 -> 도메인 매핑 보강):

// 추가 함수(별도 파일/확장함수 권장)
fun SavedSpotsResponseLegacy.toDomain(): List<SavedSpotLegacy> =
    savedSpotResponseLegacyList.orEmpty()
        .mapNotNull { it.toDomainOrNull() }

// 반환 타입을 SavedSpotLegacy? 로 바꾸는 대안
fun SavedSpotResponseLegacy.toDomainOrNull(): SavedSpotLegacy? {
    val id = spotId ?: return null
    return SavedSpotLegacy(
        spotId = id,
        name = name.orEmpty(),
        image = image.orEmpty()
    )
}

19-19: 매핑 함수 명확성: toSavedSpot → toSavedSpotLegacy 또는 toDomain 권장

도메인 타입이 SavedSpotLegacy인 만큼 함수명을 더 구체화하면 가독성이 좋아집니다.

app/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt (1)

14-14: Profile 진입 지점 일관성 확보

여기는 ProfileRouteLegacy.ProfileLegacy(leaf)로, SpotNavigation은 ProfileRouteLegacy.Graph(graph root)로 이동합니다. 시작 지점이 달라지면 back stack 구성/동작이 달라질 수 있습니다. 의도가 아니라면 하나로 통일해 주세요.

예: 그래프 루트로 통일

- navController.navigate(ProfileRouteLegacy.ProfileLegacy) {
+ navController.navigate(ProfileRouteLegacy.Graph) {
     popUpTo(SettingsRoute.Graph) {
         inclusive = true
     }
 }

Also applies to: 34-38

domain/src/main/java/com/acon/acon/domain/repository/SpotRepository.kt (1)

3-3: 확인: fetchSavedSpotList 반환 타입이 SavedSpotLegacy로 일관 적용됨

domain/src/main/java/com/acon/acon/domain/repository/SpotRepository.kt, core/data/src/main/kotlin/com/acon/core/data/repository/SpotRepositoryImpl.kt, feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/BookmarkViewModel.kt에서 Result<List> 사용을 확인했습니다 — 호출부 영향 없음. 권장: 레거시임을 명시하는 KDoc 또는 typealias 도입 고려.

feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailViewModel.kt (1)

104-114: 지역 변수 이름 섀도잉 제거(가독성 개선)

동일 스코프 내 isDeepLink 이름 재사용이 있습니다. 혼동 방지를 위해 내부 변수명을 분리하세요.

-                    val isDeepLink = spotNavData.isFromDeepLink == true
+                    val isDeepLinkNav = spotNavData.isFromDeepLink == true

                     SpotDetailUiState.Success(
-                        tags = spotNavData.tags.takeUnless { isDeepLink },
+                        tags = spotNavData.tags.takeUnless { isDeepLinkNav },
                         transportMode = spotNavData.transportMode,
                         eta = spotNavData.eta,
                         spotDetail = spotDetail,
                         isAreaVerified = isAreaVerified,
-                        isFromDeepLink = isDeepLink,
+                        isFromDeepLink = isDeepLinkNav,
                         navFromProfile = spotNavData.navFromProfile,
                     )
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreen.kt (2)

108-114: 로딩 스켈레톤이 mock 데이터 크기에 종속 — 고정 카운트로 분리 권장

mockSpotList 길이에 의존하면 추후 변경 시 UI 흔들립니다. 스켈레톤 개수를 상수로 두고 생성하세요.

-                    mockSpotList.chunked(2).forEach { rowItems ->
+                    val skeletonCount = 6
+                    List(skeletonCount) { Unit }.chunked(2).forEach { rowItems ->
                         Row(
@@
-                            rowItems.forEach { spot ->
+                            rowItems.forEach {
                                 BookmarkSkeletonItemLegacy(
                                     skeletonHeight = skeletonHeight,
                                     modifier = Modifier
                                         .weight(1f)
                                         .aspectRatio(160f / 231f)
                                 )
                             }

169-184: 내부 루프도 fastForEach로 통일(미세 최적화)

chunked 후 외부는 fastForEach를 쓰지만 내부는 forEach입니다. 일관성 및 약간의 성능 향상을 위해 fastForEach로 변경을 제안합니다.

-                            rowItems.forEach { spot ->
+                            rowItems.fastForEach { spot ->
                                 BookmarkItemLegacy(
                                     spot = spot,
                                     onClickSpotItem = { onSpotClick(spot.spotId) },
                                     modifier = Modifier
                                         .weight(1f)
                                         .aspectRatio(160f / 231f)
                                 )
                             }
core/data/src/main/kotlin/com/acon/core/data/repository/UserRepositoryImpl.kt (1)

11-11: ProfileInfoCacheLegacy로 전환됨 — @singleton 바인딩 확인, 구 타입 없음

  • 확인: core/data/src/main/kotlin/com/acon/core/data/di/CacheModule.kt (lines 16–22)에서 providesProfileInfoCache가 @singleton으로 ProfileInfoCacheLegacy를 제공합니다.
  • 권고: UserRepositoryImpl, SpotRepositoryImpl, ProfileRepositoryLegacyImpl 및 테스트들에 ProfileInfoCacheLegacy 사용처가 남아 있으므로, 전환 기간에 양쪽 캐시가 공존할 경우 세션 정리(clearSession) 시 두 캐시를 모두 비우는 전략을 적용하세요.
domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt (2)

6-6: 생성자에서 불필요한 괄호를 제거하세요.

빈 생성자에 괄호가 불필요합니다.

-class ValidateBirthDateUseCase() {
+class ValidateBirthDateUseCase {

8-8: 하드코딩된 임계값을 설정으로 분리하는 것을 고려하세요.

1900년 임계값이 하드코딩되어 있습니다. 향후 요구사항 변경 시 유연성을 위해 설정 파일이나 상수 클래스로 분리하는 것을 고려해보세요.

feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/BookmarkViewModel.kt (2)

47-47: 성공 상태에서 List를 nullable로 둘 필요가 없습니다

기본값이 emptyList()인데 타입을 List<…>?로 두면 사용처에서 불필요한 null 처리가 생깁니다. 비‑null List로 변경하는 것이 안전하고 일관됩니다.

아래와 같이 수정 제안드립니다:

-sealed interface BookmarkUiState {
-    data class Success(val savedSpotLegacies: List<com.acon.acon.core.model.model.profile.SavedSpotLegacy>? = emptyList()) : BookmarkUiState
+sealed interface BookmarkUiState {
+    data class Success(
+        val savedSpotLegacies: List<com.acon.acon.core.model.model.profile.SavedSpotLegacy> = emptyList()
+    ) : BookmarkUiState

추가로 가독성을 위해 타입을 import하여 FQCN 사용을 피하는 것도 권장합니다.

// 파일 상단 import 제안
import com.acon.acon.core.model.model.profile.SavedSpotLegacy

22-22: 하드코딩된 지연(delay) 제거 또는 디버그 게이트 필요

의도적 스켈레톤 연출이 아니라면 800ms 지연은 사용자 체감 성능을 저하시킬 수 있습니다. 디버그 빌드에서만 동작하도록 게이트하거나 제거를 권장합니다.

-        delay(800)
+        // if (BuildConfig.DEBUG) delay(800)
core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotResponse.kt (1)

12-19: 썸네일 공백/트리밍 처리 누락

"공백 포함 URL"이 들어오면 그대로 Exist(url)로 전달됩니다. 앞뒤 공백을 제거해 저장/표시 이슈를 예방하세요.

-    fun toSavedSpot() : SavedSpot {
-        val spotThumbnailStatus = when {
-            spotThumbnail.isNullOrBlank() -> SpotThumbnailStatus.Empty
-            else -> SpotThumbnailStatus.Exist(spotThumbnail)
-        }
+    fun toSavedSpot(): SavedSpot {
+        val normalized = spotThumbnail?.trim()
+        val spotThumbnailStatus = when {
+            normalized.isNullOrBlank() -> SpotThumbnailStatus.Empty
+            else -> SpotThumbnailStatus.Exist(normalized)
+        }
         return SavedSpot(spotId, spotName, spotThumbnailStatus)
     }
core/model/src/main/java/com/acon/acon/core/model/model/profile/SpotThumbnailStatus.kt (1)

5-6: 불변식 보강: 빈 URL 금지

Exist.url에 대한 간단한 불변식 체크를 추가하면 잘못된 상태 전파를 차단할 수 있습니다.

-    data class Exist(val url: String) : SpotThumbnailStatus
+    data class Exist(val url: String) : SpotThumbnailStatus {
+        init { require(url.isNotBlank()) { "Spot thumbnail url must not be blank." } }
+    }
domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt (1)

8-12: 상태 없는 에러 타입은 object로 정의하여 할당/비교 비용 절감

인스턴스 상태가 없으므로 object로 전환하면 불필요한 객체 생성을 줄이고 비교도 용이합니다.

-    class InputIsFuture : ValidateBirthDateError()
-    class InputIsTooPast : ValidateBirthDateError()
-    class InvalidFormat : ValidateBirthDateError() {
+    object InputIsFuture : ValidateBirthDateError()
+    object InputIsTooPast : ValidateBirthDateError()
+    object InvalidFormat : ValidateBirthDateError() {
         override val code = UNSPECIFIED_SERVER_ERROR_CODE
     }
domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt (1)

16-21: 공백만 입력 처리 및 트리밍 권장

isEmpty 대신 isBlank 사용을 권장합니다. (스페이스만 있는 입력을 Empty와 동일하게 처리)

-            nickname.isEmpty() -> Result.failure(ValidateNicknameError.EmptyInput())
+            nickname.isBlank() -> Result.failure(ValidateNicknameError.EmptyInput())

추가로, 서버에 전달하는 문자열은 trim() 적용을 고려해주세요.

-            else -> profileRepository.validateNickname(nickname)
+            else -> profileRepository.validateNickname(nickname.trim())
core/data/src/main/kotlin/com/acon/core/data/di/CacheModule.kt (1)

19-23: @provides 반환 타입 명시 및 @iodispatcher 바인딩 검토(검증됨)

  • providesProfileInfoCache에 명시적 반환 타입을 추가하세요.
  • @iodispatcher는 core/common/src/main/java/com/acon/acon/core/common/DispatcherQualifiers.kt에 정의되어 있고, IO용 CoroutineScope 제공자는 app/src/main/java/com/acon/acon/di/CoroutineScopesModule.kt에 존재하므로 현재 주입은 유효합니다. 다만 관례적으로는 Dispatcher에 qualifier를 붙이고 Scope에는 별도 qualifier(@ApplicationScope 등)를 사용하는 것이 더 명확하므로 선택적 리팩터를 권장합니다.
-    fun providesProfileInfoCache(
-        @IODispatcher scope: CoroutineScope,
-        profileRemoteDataSourceLegacy: ProfileRemoteDataSourceLegacy
-    ) = ProfileInfoCacheLegacy(scope, profileRemoteDataSourceLegacy)
+    fun providesProfileInfoCache(
+        @IODispatcher scope: CoroutineScope,
+        profileRemoteDataSourceLegacy: ProfileRemoteDataSourceLegacy
+    ): ProfileInfoCacheLegacy = ProfileInfoCacheLegacy(scope, profileRemoteDataSourceLegacy)
core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/ProfileAuthApiLegacy.kt (3)

23-24: 쿼리 문자열을 경로에 하드코딩하지 말고 @query 파라미터로 분리하세요.

테스트/유지보수성 및 인코딩 안전성 측면에서 ?imageType=PROFILE을 경로에서 제거하고 @Query("imageType")로 분리하는 편이 안전합니다.

-@GET("/api/v1/images/presigned-url?imageType=PROFILE")
-suspend fun getPreSignedUrl() : PreSignedUrlResponse
+@GET("/api/v1/images/presigned-url")
+suspend fun getPreSignedUrl(
+    @Query("imageType") imageType: String = "PROFILE"
+): PreSignedUrlResponse

26-29: encoded=true 재확인 필요 (닉네임 특수문자/공백 인코딩 이슈).

encoded = true는 호출부가 직접 인코딩한 값을 전달한다는 뜻입니다. 서버가 raw 값을 기대한다면 기본값(false)로 두어 Retrofit이 안전하게 인코딩하도록 해야 합니다. 현재 계약을 재확인해 주세요. 문제가 없다면 주석으로 의도를 남겨주세요.

-@Query("nickname", encoded = true) nickname: String
+@Query("nickname") nickname: String

31-35: 쓰기 계열 API의 반환 타입 일관화 제안(Unit vs Response).

동일 레벨의 호출인 updateProfile, saveSpot가 서로 다른 반환 타입을 사용 중입니다. 팀 컨벤션에 맞춰 일관화해 주세요(권장: 모두 Unit, 비-2xx는 예외로 처리).

예시(모두 Unit로 통일):

 @PATCH("/api/v1/members/me")
 suspend fun updateProfile(
-    @Body request: UpdateProfileRequestLegacy
-): Response<Unit>
+    @Body request: UpdateProfileRequestLegacy
+)

Also applies to: 39-42

domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameErrorLegacy.kt (1)

7-12: 상태가 없는 서브타입은 object로 선언해 할당/비교 비용을 줄이세요.

동일 의미의 인스턴스를 매번 생성할 필요가 없습니다. object로 바꾸면 참조 동일성 비교와 메모리 절약에 유리합니다. 패턴이 프로젝트의 RootError 설계와 충돌하지 않는지 확인 바랍니다.

-    class UnsatisfiedCondition : ValidateNicknameErrorLegacy() {
-        override val code: Int = 40051
-    }
-    class AlreadyUsedNickname : ValidateNicknameErrorLegacy() {
-        override val code: Int = 40901
-    }
+    object UnsatisfiedCondition : ValidateNicknameErrorLegacy() {
+        override val code: Int = 40051
+    }
+    object AlreadyUsedNickname : ValidateNicknameErrorLegacy() {
+        override val code: Int = 40901
+    }
@@
-        return arrayOf(
-            UnsatisfiedCondition(),
-            AlreadyUsedNickname()
-        )
+        return arrayOf(
+            UnsatisfiedCondition,
+            AlreadyUsedNickname
+        )

Also applies to: 14-19

core/data/src/main/kotlin/com/acon/core/data/datasource/local/ProfileLocalDataSource.kt (2)

19-21: 식별자 섀도잉 방지: Flow 프로퍼티명을 명확히.

파라미터 profile와 프로퍼티 profile가 섀도잉되어 가독성이 떨어집니다. 프로퍼티명을 profileFlow 등으로 바꾸는 것을 권장합니다.

-    private val profile = _profile.asStateFlow()
+    private val profileFlow = _profile.asStateFlow()
@@
-    override fun getProfile(): Flow<Profile?> {
-        return profile
-    }
+    override fun getProfile(): Flow<Profile?> {
+        return profileFlow
+    }

Also applies to: 26-28


10-13: suspend 시그니처와 구현 불일치: emit 사용 또는 suspend 제거 중 하나로 통일하세요.

현재 suspend fun인데 내부에서 단순 대입(value =)만 합니다. 두 가지 중 하나로 맞추시길 권장합니다.

옵션 A: suspend 유지 + emit 사용

     override suspend fun cacheProfile(profile: Profile) {
-        _profile.value = profile
+        _profile.emit(profile)
     }
@@
     override suspend fun clearCache() {
-        _profile.value = null
+        _profile.emit(null)
     }

옵션 B: suspend 제거(인터페이스/구현 모두)

-interface ProfileLocalDataSource {
-
-    suspend fun cacheProfile(profile: Profile)
+interface ProfileLocalDataSource {
+    fun cacheProfile(profile: Profile)
     fun getProfile() : Flow<Profile?>
-    suspend fun clearCache()
+    fun clearCache()
 }
@@
-    override suspend fun cacheProfile(profile: Profile) {
+    override fun cacheProfile(profile: Profile) {
         _profile.value = profile
     }
@@
-    override suspend fun clearCache() {
+    override fun clearCache() {
         _profile.value = null
     }

Also applies to: 22-24, 30-32

core/data/src/test/java/com/acon/core/data/datasource/remote/ProfileRemoteDataSourceTest.kt (1)

177-186: coVerifyOnce 헬퍼 시그니처 단순화 제안.

exactly=1을 강제하므로 atLeast/atMost 인자는 무의미합니다. 혼동을 줄이기 위해 제거해도 좋습니다.

-private fun coVerifyOnce(
-    ordering: Ordering = Ordering.UNORDERED,
-    inverse: Boolean = false,
-    atLeast: Int = 1,
-    atMost: Int = Int.MAX_VALUE,
-    timeout: Long = 0,
-    verifyBlock: suspend MockKVerificationScope.() -> Unit
-) {
-    coVerify(ordering, inverse, atLeast, atMost, 1, timeout, verifyBlock)
-}
+private fun coVerifyOnce(
+    ordering: Ordering = Ordering.UNORDERED,
+    inverse: Boolean = false,
+    timeout: Long = 0,
+    verifyBlock: suspend MockKVerificationScope.() -> Unit
+) {
+    coVerify(ordering = ordering, inverse = inverse, exactly = 1, timeout = timeout, verifyBlock = verifyBlock)
+}
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenLegacy.kt (2)

273-279: 로케일 의존 소문자 변환을 Locale.ROOT로 고정하세요. (터키어 I 이슈 등)

사용자 입력 정규화는 기본 로케일에 의존하지 않도록 처리하는 것이 안전합니다.

-    val lowerCaseText = fieldValue.text.lowercase()
+    val lowerCaseText = fieldValue.text.lowercase(java.util.Locale.ROOT)

추가: 상단 import가 필요합니다.

import java.util.Locale

99-101: 네이밍 일관성(nickname/birthday) 정리 제안.

nickNameFocusRequesternicknameFocusRequester, birthDayFocusRequesterbirthdayFocusRequester로 통일하면 가독성이 좋아집니다.

-    val nickNameFocusRequester = remember { FocusRequester() }
-    val birthDayFocusRequester = remember { FocusRequester() }
+    val nicknameFocusRequester = remember { FocusRequester() }
+    val birthdayFocusRequester = remember { FocusRequester() }
@@
-        focusRequester = nickNameFocusRequester,
+        focusRequester = nicknameFocusRequester,
@@
-            nickNameFocusRequester.requestFocus()
+            nicknameFocusRequester.requestFocus()
@@
-        focusRequester = birthDayFocusRequester,
+        focusRequester = birthdayFocusRequester,
@@
-            birthDayFocusRequester.requestFocus()
+            birthdayFocusRequester.requestFocus()

Also applies to: 270-271, 283-284, 373-374, 384-385

core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileInfoLegacy.kt (1)

10-10: Fully-qualified 패키지 경로 사용 재검토 필요

companion objectEmpty 프로퍼티에서 fully-qualified 패키지 경로를 사용하고 있습니다. 동일한 파일 내의 클래스이므로 패키지 경로 없이 사용 가능합니다.

-        val Empty = com.acon.acon.core.model.model.profile.ProfileInfoLegacy("", "", null, emptyList())
+        val Empty = ProfileInfoLegacy("", "", null, emptyList())
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenContainerLegacy.kt (1)

35-39: 불필요한 side effect 처리 로직 확인 필요

UpdateProfileImageLegacy 처리 로직에서 selectedPhotoId를 다시 사용하고 있는데, 이미 LaunchedEffect에서 처리하고 있습니다. 또한 selectedPhotoId ?: ""로 빈 문자열을 전달하는 것이 의도된 동작인지 확인이 필요합니다.

현재 로직:

  1. Line 24-27에서 selectedPhotoId가 비어있지 않으면 updateProfileImage 호출
  2. Line 35-39에서 UpdateProfileImageLegacy side effect 발생 시 다시 updateProfileImage 호출

이는 중복 호출이 발생할 수 있습니다. Side effect가 언제 발생하는지 확인하여 불필요한 중복을 제거하는 것이 좋습니다.

feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenLegacy.kt (2)

161-162: TODO 주석 처리 필요

저장한 장소가 없을 때의 처리에 대한 TODO 주석이 있습니다. 이미 Line 211-218에서 처리하고 있는 것으로 보입니다.

TODO를 제거하거나 추가 구현이 필요한지 확인이 필요합니다. 추가 구현이 필요하다면 이슈를 생성하여 추적하는 것이 좋겠습니다.


164-164: Empty 객체 비교 로직 개선 필요

ProfileInfoLegacy 전체 객체를 Empty 싱글톤과 비교하고 있습니다. 이는 깨지기 쉬운 로직이며, 실제로 프로필 정보가 있는지 확인하는 더 명확한 방법을 사용하는 것이 좋습니다.

-                        if (state.profileInfoLegacy != com.acon.acon.core.model.model.profile.ProfileInfoLegacy.Empty) {
+                        if (state.profileInfoLegacy.nickname.isNotEmpty()) {

또는 ProfileInfoLegacyisEmpty() 메서드를 추가하는 것도 고려해보세요:

fun isEmpty(): Boolean = nickname.isEmpty() && image.isEmpty()
domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateNicknameUseCaseTest.kt (3)

30-39: 테스트 메서드명을 영어로 변경 고려

현재 테스트 메서드명이 한글로 작성되어 있습니다. 한글 사용이 팀 컨벤션이 아니라면, CI/CD 환경이나 다양한 도구와의 호환성을 위해 영어 사용을 고려해보세요.

-    fun `입력이 없을 경우 입력 없음 예외 객체를 Result Wrapping하여 반환한다`() = runTest {
+    fun `should return EmptyInput error when input is empty`() = runTest {

54-69: 테스트 케이스 분리 고려

하나의 테스트 메서드에서 여러 시나리오(한글, 대문자, 공백)를 검증하고 있습니다. 각각을 별도의 테스트로 분리하거나 @ParameterizedTest를 사용하면 실패 시 어떤 케이스가 실패했는지 더 명확하게 알 수 있습니다.

@ParameterizedTest
@ValueSource(strings = ["한글닉네임", "Capital", "very short"])
fun `should return InvalidFormat error when input contains invalid characters`(input: String) = runTest {
    // When
    val actualException = validateNicknameUseCase(input).exceptionOrNull()
    
    // Then
    assertIs<ValidateNicknameError.InvalidFormat>(actualException)
}

71-85: 실제 Repository 호출 시나리오 추가 검증 필요

유효한 닉네임에 대한 성공 케이스만 테스트하고 있습니다. Repository가 실패를 반환하는 경우(예: 이미 존재하는 닉네임)도 테스트하면 좋겠습니다.

추가 테스트 케이스 예시:

@Test
fun `should return error when repository returns failure`() = runTest {
    // Given
    val sampleValidNickname = "validnickname"
    val expectedError = ValidateNicknameError.AlreadyExists()
    
    coEvery { profileRepository.validateNickname(sampleValidNickname) } returns Result.failure(expectedError)
    
    // When
    val actualResult = validateNicknameUseCase(sampleValidNickname)
    
    // Then
    assertEquals(expectedError, actualResult.exceptionOrNull())
}
domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt (1)

8-16: 프로필 도메인 에러에 고유 코드 부여 시 Constants, 에러 클래스, 테스트 모두 업데이트 필요
프로필 관련 UpdateProfileErrorValidate*Error 클래스가 모두 UNSPECIFIED_SERVER_ERROR_CODE(-1)을 사용하고 있으며, ProfileRepositoryTest도 이를 전제로 매핑되어 있습니다. 각 에러별로 고유 코드를 정의하려면 Constants.kt에 신규 코드 추가 후 해당 에러 클래스 및 테스트 내 매핑을 함께 수정하세요.

core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt (1)

123-132: 테스트 시나리오 개선 필요

테스트 케이스에서 profileRemoteDataSource.getProfile()을 verify하고 있지만, collect 블록이 비어있어 실제로 데이터가 수집되는지 확인하지 않습니다.

// When
-profileRepository.getProfile().collect { }
+val results = profileRepository.getProfile().toList()

// Then
coVerify(exactly = 1) { profileRemoteDataSource.getProfile() }
+assertTrue(results.isNotEmpty(), "프로필 데이터가 수집되어야 합니다")
domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateBirthDateUseCaseTest.kt (3)

27-27: 날짜 포맷 일관성 개선

날짜 생성 시 공백 없이 작성되어 가독성이 떨어집니다.

-val sampleLocalDate = LocalDate.of(1899,12,31)
+val sampleLocalDate = LocalDate.of(1899, 12, 31)

48-56: 테스트 구조 개선 필요

Given/When/Then 주석이 있지만 When/Then 섹션에 주석이 누락되어 있습니다.

// Given
val sampleLocalDate = LocalDate.now().plusDays(1)

+// When
val actualException = validateBirthDateUseCase(sampleLocalDate).exceptionOrNull()

+// Then
assertIs<ValidateBirthDateError.InputIsFuture>(actualException)

59-66: 테스트 구조 일관성 개선

Given/When/Then 주석이 누락되어 있습니다.

// Given
val sampleLocalDate = LocalDate.now()

+// When
val actualException = validateBirthDateUseCase(sampleLocalDate).exceptionOrNull()

+// Then
assertIsNot<ValidateBirthDateError.InputIsFuture>(actualException)
core/data/src/main/kotlin/com/acon/core/data/datasource/remote/ProfileRemoteDataSourceLegacy.kt (1)

34-35: 일관성 없는 코드 스타일

다른 메서드들과 달리 한 줄로 작성되어 있습니다.

-suspend fun fetchSavedSpots() = profileAuthApiLegacy.fetchSavedSpots()
-suspend fun saveSpot(saveSpotRequest: SaveSpotRequest) = profileAuthApiLegacy.saveSpot(saveSpotRequest)
+suspend fun fetchSavedSpots(): SavedSpotsResponseLegacy {
+    return profileAuthApiLegacy.fetchSavedSpots()
+}
+
+suspend fun saveSpot(saveSpotRequest: SaveSpotRequest): Response<Unit> {
+    return profileAuthApiLegacy.saveSpot(saveSpotRequest)
+}
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/ProfileModViewModelLegacy.kt (1)

407-408: 복잡한 조건식 개선 필요

isEditButtonEnabled getter의 조건식이 너무 복잡하여 가독성과 유지보수성이 떨어집니다.

val isEditButtonEnabled: Boolean
    get() {
-        val isProfileImageChanged = when {
-            selectedPhotoUri.contains("basic_profile_image") &&
-                    fetchedPhotoUri.contains("basic_profile_image") -> false
-
-            selectedPhotoUri.isNotEmpty() && selectedPhotoUri != fetchedPhotoUri -> true
-            else -> false
-        }
-        val isBirthValid = fetchedBirthday.isNotEmpty() && birthday.isEmpty()
-        val isContentValid = nicknameValidationStatus == NicknameValidationStatus.Valid &&
-                (birthday.isEmpty() || birthdayValidationStatus == BirthdayValidationStatus.Valid)
-
-        return (isProfileImageChanged && isContentValid) || isBirthValid || (isEdited && isContentValid)
+        val isProfileImageChanged = isProfileImageChanged()
+        val isBirthValid = isBirthDateCleared()
+        val isContentValid = isContentValid()
+
+        return (isProfileImageChanged && isContentValid) || 
+               isBirthValid || 
+               (isEdited && isContentValid)
    }
    
+private fun isProfileImageChanged(): Boolean {
+    return when {
+        selectedPhotoUri.contains("basic_profile_image") &&
+            fetchedPhotoUri.contains("basic_profile_image") -> false
+        selectedPhotoUri.isNotEmpty() && selectedPhotoUri != fetchedPhotoUri -> true
+        else -> false
+    }
+}
+
+private fun isBirthDateCleared(): Boolean {
+    return fetchedBirthday.isNotEmpty() && birthday.isEmpty()
+}
+
+private fun isContentValid(): Boolean {
+    return nicknameValidationStatus == NicknameValidationStatus.Valid &&
+           (birthday.isEmpty() || birthdayValidationStatus == BirthdayValidationStatus.Valid)
+}
core/data/src/main/kotlin/com/acon/core/data/repository/SpotRepositoryImpl.kt (1)

104-109: 북마크 업데이트 시 에러 처리 개선 필요

addBookmarkdeleteBookmark 메서드에서 fetchSavedSpots() 실패 시 조용히 무시되고 있습니다. 사용자에게 북마크 작업은 성공했지만 로컬 캐시 업데이트는 실패했다는 것을 알려야 할 수 있습니다.

 override suspend fun addBookmark(spotId: Long): Result<Unit> {
     return runCatchingWith(AddBookmarkError()) {
         spotRemoteDataSource.addBookmark(AddBookmarkRequest(spotId))
 
         profileRepositoryLegacy.fetchSavedSpots().onSuccess { fetched ->
             (profileInfoCacheLegacy.data.value.getOrNull()
                 ?: return@onSuccess).let { profileInfo ->
                 profileInfoCacheLegacy.updateData(profileInfo.copy(savedSpotLegacies = fetched))
             }
+        }.onFailure { error ->
+            // 로그를 남기거나 모니터링에 보고
+            // 북마크는 성공했지만 캐시 업데이트 실패
         }
     }
 }

Also applies to: 117-122

app/src/main/java/com/acon/acon/navigation/nested/ProfileNavigationLegacy.kt (1)

17-17: 레거시 명명 패턴이 일관되지 않음

ProfileRouteLegacy는 사용하면서 ProfileScreenContainerLegacyProfileModScreenContainerLegacy를 import하고 있습니다. 그러나 BookmarkScreenContainer는 Legacy 접미사가 없습니다. 북마크 화면도 레거시 플로우의 일부라면 일관성을 위해 BookmarkScreenContainerLegacy로 명명해야 합니다.

Also applies to: 22-23

feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/ProfileViewModelLegacy.kt (2)

21-21: 긴 패키지 경로 대신 import 문 사용 권장

com.acon.acon.core.model.model.profile.ProfileInfoLegacy.Empty를 전체 경로로 사용하고 있습니다. 가독성 향상을 위해 import 문을 추가하는 것이 좋습니다.

+import com.acon.acon.core.model.model.profile.ProfileInfoLegacy
 
 override val container =
-    container<ProfileUiStateLegacy, ProfileUiSideEffectLegacy>(ProfileUiStateLegacy.Success(com.acon.acon.core.model.model.profile.ProfileInfoLegacy.Empty)) {
+    container<ProfileUiStateLegacy, ProfileUiSideEffectLegacy>(ProfileUiStateLegacy.Success(ProfileInfoLegacy.Empty)) {

38-42: 코루틴 실행 시 에러 처리 누락

resetUpdateProfileType() 메서드에서 코루틴을 실행할 때 에러 처리가 없습니다. Repository 호출 중 예외가 발생하면 처리되지 않은 예외가 발생할 수 있습니다.

 fun resetUpdateProfileType() {
     viewModelScope.launch {
-        profileRepositoryLegacy.resetProfileType()
+        try {
+            profileRepositoryLegacy.resetProfileType()
+        } catch (e: Exception) {
+            // 로그 남기거나 적절한 에러 처리
+        }
     }
 }
domain/src/main/java/com/acon/acon/domain/repository/ProfileRepositoryLegacy.kt (1)

17-17: birthday 파라미터 타입을 더 명확하게 정의 필요

updateProfile 메서드의 birthday 파라미터가 String? 타입입니다. 날짜 형식에 대한 검증이나 도메인 모델 사용을 고려해보세요.

BirthDateStatus 같은 도메인 타입을 파라미터로 받거나, 최소한 날짜 형식을 문서화하는 것이 좋습니다:

-suspend fun updateProfile(fileName: String, nickname: String, birthday: String?, uri: String): Result<Unit>
+/**
+ * @param birthday 생년월일 (형식: "YYYY.MM.DD", null인 경우 미지정)
+ */
+suspend fun updateProfile(fileName: String, nickname: String, birthday: String?, uri: String): Result<Unit>
core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt (2)

33-44: 로컬 캐시 실패 처리 필요

getProfileFromRemote() 메서드에서 원격 데이터를 성공적으로 가져온 후 profileLocalDataSource.cacheProfile(profile)을 호출하는데, 이 캐싱 작업이 실패할 경우에 대한 처리가 없습니다. 캐싱 실패가 전체 작업을 실패로 만들어서는 안 되므로, 캐싱 예외를 별도로 처리하는 것이 좋습니다.

 private fun getProfileFromRemote(): Flow<Result<Profile>> {
     return flow {
         emit(runCatchingWith {
             val profileResponse = profileRemoteDataSource.getProfile()
             val profile = profileResponse.toProfile()
 
-            profileLocalDataSource.cacheProfile(profile)
+            try {
+                profileLocalDataSource.cacheProfile(profile)
+            } catch (e: Exception) {
+                Timber.w(e, "Failed to cache profile locally")
+            }
 
             profile
         })
     }
 }

62-66: 저장된 장소 목록 캐싱 고려

getSavedSpots() 메서드는 매번 원격 데이터소스에서 직접 데이터를 가져옵니다. 프로필 데이터와 유사하게 저장된 장소 목록도 캐싱을 고려해보면 네트워크 요청을 줄이고 성능을 개선할 수 있을 것입니다.

저장된 장소 목록에 대한 캐싱 로직을 구현하시겠습니까? 프로필과 유사한 방식으로 로컬 캐시를 활용할 수 있습니다.

core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryImplTest.kt (1)

36-36: 테스트 클래스명과 실제 테스트 대상 불일치

테스트 클래스명은 ProfileRepositoryImplTest이지만 실제로는 ProfileRepositoryLegacyImpl을 테스트하고 있습니다. 테스트 대상을 명확히 하기 위해 클래스명을 ProfileRepositoryLegacyImplTest로 변경하는 것이 좋습니다.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 89225f1 and f82de16.

📒 Files selected for processing (71)
  • app/src/main/java/com/acon/acon/navigation/AconNavigation.kt (2 hunks)
  • app/src/main/java/com/acon/acon/navigation/nested/ProfileNavigationLegacy.kt (5 hunks)
  • app/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt (2 hunks)
  • app/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt (2 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/ProfileAuthApiLegacy.kt (3 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/SpotAuthApi.kt (2 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/cache/ProfileInfoCache.kt (0 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/cache/ProfileInfoCacheLegacy.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/datasource/local/ProfileLocalDataSource.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/datasource/remote/ProfileRemoteDataSource.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/datasource/remote/ProfileRemoteDataSourceLegacy.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/datasource/remote/SpotRemoteDataSource.kt (2 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt (2 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/di/CacheModule.kt (2 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/di/LocalDataSourceModule.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/di/RemoteDataSourceModule.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/di/RepositoryModule.kt (4 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/dto/request/UpdateProfileRequestLegacy.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/ProfileResponse.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/ProfileResponseLegacy.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotResponse.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotsResponseLegacy.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryLegacyImpl.kt (1 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/repository/SpotRepositoryImpl.kt (5 hunks)
  • core/data/src/main/kotlin/com/acon/core/data/repository/UserRepositoryImpl.kt (3 hunks)
  • core/data/src/test/java/com/acon/core/data/datasource/local/ProfileLocalDataSourceTest.kt (1 hunks)
  • core/data/src/test/java/com/acon/core/data/datasource/remote/ProfileRemoteDataSourceTest.kt (1 hunks)
  • core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryImplTest.kt (7 hunks)
  • core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt (1 hunks)
  • core/data/src/test/java/com/acon/core/data/repository/SpotRepositoryImplTest.kt (3 hunks)
  • core/data/src/test/java/com/acon/core/data/repository/UserRepositoryImplTest.kt (1 hunks)
  • core/model/src/main/java/com/acon/acon/core/model/model/profile/BirthDateStatus.kt (1 hunks)
  • core/model/src/main/java/com/acon/acon/core/model/model/profile/Profile.kt (1 hunks)
  • core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileImageStatus.kt (1 hunks)
  • core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileInfoLegacy.kt (1 hunks)
  • core/model/src/main/java/com/acon/acon/core/model/model/profile/SavedSpot.kt (1 hunks)
  • core/model/src/main/java/com/acon/acon/core/model/model/profile/SavedSpotLegacy.kt (1 hunks)
  • core/model/src/main/java/com/acon/acon/core/model/model/profile/SpotThumbnailStatus.kt (1 hunks)
  • core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRoute.kt (0 hunks)
  • core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRouteLegacy.kt (1 hunks)
  • domain/build.gradle.kts (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/error/Constants.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameErrorLegacy.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/repository/ProfileRepository.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/repository/ProfileRepositoryLegacy.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/repository/SpotRepository.kt (2 hunks)
  • domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt (1 hunks)
  • domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt (1 hunks)
  • domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateBirthDateUseCaseTest.kt (1 hunks)
  • domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateNicknameUseCaseTest.kt (1 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/MockSavedSpotList.kt (1 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/BookmarkViewModel.kt (2 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreen.kt (3 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/ProfileViewModel.kt (0 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/ProfileViewModelLegacy.kt (1 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkItemLegacy.kt (2 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkSkeletonItemLegacy.kt (1 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenContainerLegacy.kt (3 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenLegacy.kt (11 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/ProfileModViewModelLegacy.kt (12 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenContainerLegacy.kt (2 hunks)
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenLegacy.kt (4 hunks)
  • feature/settings/src/main/java/com/acon/acon/feature/verification/screen/UserVerifiedAreasViewModel.kt (4 hunks)
  • feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailViewModel.kt (3 hunks)
  • feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/SpotListViewModel.kt (0 hunks)
💤 Files with no reviewable changes (4)
  • feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/SpotListViewModel.kt
  • core/data/src/main/kotlin/com/acon/core/data/cache/ProfileInfoCache.kt
  • feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/ProfileViewModel.kt
  • core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRoute.kt
🧰 Additional context used
🧬 Code graph analysis (9)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreen.kt (2)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkSkeletonItemLegacy.kt (1)
  • BookmarkSkeletonItemLegacy (15-36)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkItemLegacy.kt (1)
  • BookmarkItemLegacy (28-92)
core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt (1)
core/data/src/test/java/com/acon/core/data/TestUtils.kt (3)
  • createFakeRemoteError (18-22)
  • assertValidErrorMapping (24-28)
  • createErrorStream (12-16)
app/src/main/java/com/acon/acon/navigation/AconNavigation.kt (1)
app/src/main/java/com/acon/acon/navigation/nested/ProfileNavigationLegacy.kt (1)
  • profileNavigationLegacy (25-119)
app/src/main/java/com/acon/acon/navigation/nested/ProfileNavigationLegacy.kt (2)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenContainerLegacy.kt (1)
  • ProfileScreenContainerLegacy (22-80)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenContainerLegacy.kt (1)
  • ProfileModScreenContainerLegacy (13-61)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenContainerLegacy.kt (1)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenLegacy.kt (1)
  • ProfileModScreenLegacy (67-428)
core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryImplTest.kt (1)
core/data/src/test/java/com/acon/core/data/TestUtils.kt (2)
  • createErrorStream (12-16)
  • createFakeRemoteError (18-22)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenContainerLegacy.kt (1)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenLegacy.kt (1)
  • ProfileScreenLegacy (50-336)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenLegacy.kt (1)
feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkItemLegacy.kt (1)
  • BookmarkItemLegacy (28-92)
core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt (1)
core/data/src/main/kotlin/com/acon/core/data/error/ErrorUtils.kt (2)
  • runCatchingWith (7-24)
  • runCatchingWith (26-40)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build

@ThirFir ThirFir self-assigned this Sep 19, 2025
@ThirFir ThirFir merged commit 081db49 into develop Sep 19, 2025
1 of 2 checks passed
@ThirFir ThirFir deleted the refactor/profile-data-layer branch September 19, 2025 04:25
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.

2 participants