Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

🔍 프로젝트 진단 | Health501 아키텍처 & 코드 품질 개선 로드맵

Billcorea 2025. 12. 15. 15:32
반응형

🔍 프로젝트 진단 | Health501 아키텍처 & 코드 품질 개선 로드맵

샘플이미지

 

📊 개요 (Executive Summary)

  • 작업 일자: 2025-12-15
  • 작업 유형: 프로젝트 전반 분석 및 개선 방안 도출
  • 목적: Health501 프로젝트의 현재 상태를 진단하고, 유지보수성·확장성·안정성 향상을 위한 구체적 개선 로드맵 수립
  • 핵심 발견: 아키텍처 문서(ARCHITECTURE.md)와 실제 코드 구조 간 불일치, 테스트 커버리지 부족, 보안 취약점 존재

🎯 핵심 목표: 문서화된 원칙을 실제 코드에 반영하여 장기적으로 확장 가능한 프로젝트 기반 마련

🏗️ 1. 아키텍처 레이어 분리 (최우선 과제)

현재 상태 분석

문제점:

  • ARCHITECTURE.md에는 명확한 3계층 구조(UI → Domain → Data)가 정의되어 있으나, 실제 코드에는 domain 레이어가 존재하지 않음
  • ViewModel이 직접 Manager 클래스를 호출하여 비즈니스 로직이 UI 레이어에 혼재
  • 데이터 레이어와 UI 레이어가 강결합되어 테스트 및 변경이 어려움

현재 구조:

app/src/main/java/com/billcoreatech/health501/
├── viewmodels/
│   ├── HealthConnectViewModel.kt  (← HealthConnectManager 직접 호출)
│   └── CoupangViewModel.kt
├── data/
│   ├── HealthConnectManager.kt
│   └── HealthConnectUtil.kt
└── (domain 레이어 부재)

개선 방안

목표 구조:

app/src/main/java/com/billcoreatech/health501/
├── presentation/  (기존 presentaion 오타도 수정)
│   ├── viewmodels/
│   └── screens/
├── domain/  ← 새로 생성
│   ├── model/
│   │   ├── Result.kt (sealed interface)
│   │   ├── StepData.kt
│   │   └── ExerciseSessionData.kt
│   └── usecase/
│       ├── GetTodayStepsUseCase.kt
│       ├── GetExerciseSessionsUseCase.kt
│       ├── SyncWearDataUseCase.kt
│       └── GetWeeklyStatsUseCase.kt
└── data/
    ├── repository/  ← 새로 생성
    │   ├── HealthDataRepository.kt
    │   ├── HealthDataRepositoryImpl.kt
    │   └── WearSyncRepository.kt
    └── datasource/
        ├── HealthConnectDataSource.kt (기존 Manager 래핑)
        └── WearDataSource.kt

구현 예시 (UseCase 패턴)

// domain/usecase/GetTodayStepsUseCase.kt
class GetTodayStepsUseCase @Inject constructor(
    private val healthRepository: HealthDataRepository
) {
    suspend operator fun invoke(): Result<Long> = withContext(Dispatchers.IO) {
        try {
            val steps = healthRepository.getTodaySteps()
            Result.Success(steps)
        } catch (e: Exception) {
            Result.Error(e.message ?: "Unknown error")
        }
    }
}

// domain/model/Result.kt
sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Error(val message: String) : Result<Nothing>
    object Loading : Result<Nothing>
}

// data/repository/HealthDataRepositoryImpl.kt
class HealthDataRepositoryImpl @Inject constructor(
    private val healthConnectDataSource: HealthConnectDataSource
) : HealthDataRepository {
    override suspend fun getTodaySteps(): Long {
        return healthConnectDataSource.readTodaySteps()
    }
}

// ViewModel에서 사용
@HiltViewModel
class HealthConnectViewModel @Inject constructor(
    private val getTodayStepsUseCase: GetTodayStepsUseCase
) : ViewModel() {

    val stepsState: StateFlow<Result<Long>> = flow {
        emit(Result.Loading)
        emit(getTodayStepsUseCase())
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Result.Loading)
}

우선순위: 긴급 (Phase 1)

예상 소요 시간: 1-2주

기대 효과:

  • 비즈니스 로직과 UI 로직의 명확한 분리
  • 테스트 용이성 대폭 향상 (UseCase 단위 테스트 가능)
  • 코드 재사용성 증가 (Wear 앱과 공유 가능)

🧪 2. 테스트 커버리지 강화

현재 상태

문제점:

  • 전체 테스트 파일: 3개 (AiAutoSelectorTest.kt, AiWeightsLoadTest.kt, ExampleUnitTest.kt)
  • 핵심 비즈니스 로직(ViewModel, Manager, Sync)에 대한 테스트 전무
  • TESTING.md에 domain ≥80%, data ≥70% 목표가 명시되어 있으나 실제 커버리지는 추정 10% 미만

개선 계획

레이어 테스트 대상 테스트 유형 목표 커버리지
Domain UseCase 클래스들 Unit Test (MockK) 80%+
Data Repository, DataSource Unit Test + Integration Test 70%+
ViewModel 상태 관리, 비즈니스 흐름 Unit Test (Turbine for Flow) 60%+
UI 핵심 화면 플로우 Compose UI Test 50%+

필수 테스트 파일 목록

app/src/test/java/com/billcoreatech/health501/
├── domain/
│   └── usecase/
│       ├── GetTodayStepsUseCaseTest.kt
│       ├── GetExerciseSessionsUseCaseTest.kt
│       └── SyncWearDataUseCaseTest.kt
├── data/
│   ├── repository/
│   │   ├── HealthDataRepositoryTest.kt
│   │   └── WearSyncRepositoryTest.kt
│   └── datasource/
│       └── HealthConnectDataSourceTest.kt
├── viewmodels/
│   ├── HealthConnectViewModelTest.kt
│   └── CoupangViewModelTest.kt
└── testutil/
    ├── FakeHealthConnectClient.kt
    ├── FakeWearDataSyncManager.kt
    └── TestDispatchers.kt

테스트 도구 추가 (build.gradle.kts)

dependencies {
    // 기존 의존성...

    // 테스트 라이브러리 추가
    testImplementation("io.mockk:mockk:1.13.9")
    testImplementation("app.cash.turbine:turbine:1.0.0")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
    testImplementation("androidx.arch.core:core-testing:2.2.0")

    // Compose UI 테스트
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

우선순위: 긴급 (Phase 2)

예상 소요 시간: 2-3주

🔒 3. 보안 강화 (API 키 관리)

취약점 분석

현재 코드 (app/build.gradle.kts):

// 48-49행: 심각한 보안 문제
resValue("string", "cupang_access_key", cupangAccessKey)
resValue("string", "cupang_secret_key", cupangSecretKey)

// 53행: 디버그 로그로 키 길이 노출
println("[CoupangKeys@Gradle] access.len=${cupangAccessKey.length}, secret.len=${cupangSecretKey.length}")

문제점:

  • API 키가 res/values/strings.xml에 평문으로 포함되어 APK 디컴파일 시 즉시 노출
  • ProGuard/R8 난독화로도 리소스 파일은 보호 불가
  • SECURITY.md에는 Keystore 기반 암호화 권장하나 미구현

개선 방안 (3단계)

Step 1: BuildConfig로 이동 (즉시 적용 가능)

// app/build.gradle.kts
android {
    defaultConfig {
        // resValue 삭제하고 BuildConfig로 변경
        buildConfigField("String", "CUPANG_ACCESS_KEY", "\"${cupangAccessKey}\"")
        buildConfigField("String", "CUPANG_SECRET_KEY", "\"${cupangSecretKey}\"")
    }

    buildFeatures {
        buildConfig = true  // BuildConfig 활성화
    }
}

// 사용 방법
// Before: context.getString(R.string.cupang_access_key)
// After:  BuildConfig.CUPANG_ACCESS_KEY

Step 2: ProGuard 규칙 강화

# proguard-rules.pro
-keepclassmembers class com.billcoreatech.health501.BuildConfig {
    !public <fields>;
}

# 난독화 강화
-repackageclasses 'o'
-allowaccessmodification

Step 3: NDK 또는 서버 프록시 (장기 과제)

  • NDK 방식: C++ 네이티브 레이어에서 키 관리 (역공학 난이도 ↑)
  • 서버 프록시 방식 (권장): 앱은 자체 서버를 호출하고, 서버가 Coupang API를 호출하여 키 노출 완전 차단

즉시 적용 사항

// 디버그 로그 제거
// println("[CoupangKeys@Gradle] access.len=${cupangAccessKey.length}...") ← 삭제

// 또는 릴리스 빌드에서만 제거
if (gradle.startParameter.taskNames.any { it.contains("Debug", ignoreCase = true) }) {
    println("[Dev] Coupang keys loaded (debug only)")
}

우선순위: 긴급 (Phase 1)

예상 소요 시간: 1-2일

🚀 4. 상태 관리 개선

현재 문제점

HealthConnectViewModel.kt 분석:

// 일관성 없는 상태 관리 패턴 혼용
var uiState: UiState by mutableStateOf(UiState.Uninitialized)  // Compose State
val _stepsTotal = MutableStateFlow(0L)  // StateFlow
val stepsTotal: StateFlow<Long> = _stepsTotal
var hasPermission = mutableStateOf(false)  // 또 다른 mutableStateOf

// 총 20개 이상의 개별 상태 프로퍼티가 산재

개선 방안: 단일 UiState 패턴

// 통합된 상태 클래스
data class HealthUiState(
    val isLoading: Boolean = false,
    val stepsToday: Long = 0L,
    val sessions: List<ExerciseSession> = emptyList(),
    val sessionMetrics: ExerciseSessionData = ExerciseSessionData(""),
    val bucketData: List<BucketData> = emptyList(),
    val permissionsGranted: Boolean = false,
    val backgroundReadAvailable: Boolean = false,
    val error: String? = null,
    val currentDateTime: ZonedDateTime = ZonedDateTime.now()
)

// ViewModel
@HiltViewModel
class HealthConnectViewModel @Inject constructor(
    private val getTodayStepsUseCase: GetTodayStepsUseCase,
    private val getExerciseSessionsUseCase: GetExerciseSessionsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow(HealthUiState())
    val uiState: StateFlow<HealthUiState> = _uiState.asStateFlow()

    fun loadTodayData() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }

            when (val result = getTodayStepsUseCase()) {
                is Result.Success -> {
                    _uiState.update {
                        it.copy(
                            stepsToday = result.data,
                            isLoading = false
                        )
                    }
                }
                is Result.Error -> {
                    _uiState.update {
                        it.copy(
                            error = result.message,
                            isLoading = false
                        )
                    }
                }
            }
        }
    }
}

// Compose UI에서 사용
@Composable
fun HealthScreen(viewModel: HealthConnectViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when {
        uiState.isLoading -> LoadingIndicator()
        uiState.error != null -> ErrorMessage(uiState.error!!)
        else -> StepsDisplay(uiState.stepsToday)
    }
}

장점:

  • 단일 진실 공급원(Single Source of Truth)
  • Compose recomposition 최적화 (불필요한 재구성 최소화)
  • 상태 변경 추적 용이 (디버깅 개선)
  • 테스트 단순화

우선순위: 중요 (Phase 2)

예상 소요 시간: 3-5일

📦 5. 빌드 & 의존성 최적화

개선 사항

5.1 gradle.properties 최적화

# 빌드 속도 개선 설정 추가
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError
kotlin.incremental=true
kotlin.incremental.usePreciseJavaTracking=true

# Configuration cache (Gradle 8.x)
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn

5.2 libs.versions.toml 번들링

[bundles]
lifecycle = [
    "androidx-lifecycle-runtime",
    "androidx-lifecycle-viewmodel-compose",
    "androidx-lifecycle-runtime-compose"
]

compose = [
    "androidx-compose-ui",
    "androidx-compose-ui-graphics",
    "androidx-compose-ui-tooling-preview",
    "androidx-compose-material3"
]

ktor = [
    "ktor-client-core",
    "ktor-client-okhttp",
    "ktor-client-logging",
    "ktor-client-content-negotiation",
    "ktor-serialization-kotlinx-json"
]

# 사용
dependencies {
    implementation(libs.bundles.lifecycle)
    implementation(libs.bundles.compose)
    implementation(libs.bundles.ktor)
}

5.3 불필요한 의존성 검토

// app/build.gradle.kts
// ❓ 검토 필요
implementation(libs.services.fitness)  // Health Connect 사용 시 필요성 재평가
implementation(libs.dialog.core)       // Material3 Dialog로 대체 가능
implementation(libs.dialog.lifecycle)  // 상동

우선순위: 일반 (Phase 3)

🌐 6. 네트워크 레이어 개선

현재 상태

Ktor Client가 NetworkModule.kt에만 정의되어 있으며, 에러 핸들링·재시도·타임아웃 정책이 미흡합니다.

개선 구조

data/network/
├── KtorClientFactory.kt
├── NetworkErrorHandler.kt
├── ApiResponse.kt (sealed class)
├── interceptor/
│   ├── AuthInterceptor.kt
│   └── LoggingInterceptor.kt
└── service/
    └── CoupangApiService.kt

구현 예시

// data/network/ApiResponse.kt
sealed interface ApiResponse<out T> {
    data class Success<T>(val data: T) : ApiResponse<T>
    data class Error(val code: Int, val message: String) : ApiResponse<Nothing>
    object NetworkError : ApiResponse<Nothing>
    object Timeout : ApiResponse<Nothing>
}

// di/NetworkModule.kt (개선)
@Provides
@Singleton
fun provideHttpClient(): HttpClient = HttpClient(OkHttp) {
    install(ContentNegotiation) {
        json(Json {
            ignoreUnknownKeys = true
            isLenient = true
        })
    }

    install(Logging) {
        logger = Logger.ANDROID
        level = if (BuildConfig.DEBUG) LogLevel.BODY else LogLevel.NONE
    }

    install(HttpTimeout) {
        requestTimeoutMillis = 30_000
        connectTimeoutMillis = 15_000
        socketTimeoutMillis = 30_000
    }

    install(HttpRequestRetry) {
        retryOnServerErrors(maxRetries = 3)
        exponentialDelay()
    }

    defaultRequest {
        header("User-Agent", "Health501/${BuildConfig.VERSION_NAME}")
        header("Accept", "application/json")
    }
}

우선순위: 중요 (Phase 2)

🔧 7. 코드 품질 개선

즉시 수정 사항

7.1 패키지 오타 수정

현재: app/src/main/java/com/billcoreatech/health501/presentaion/
수정: app/src/main/java/com/billcoreatech/health501/presentation/

7.2 TODO 해결

// StepsStateListenerService.kt:26
// TODO: forward to a repository / shared flow if needed.

// 개선 방안: Repository 패턴 도입 시 함께 해결
class StepsStateListenerService : Service() {
    @Inject lateinit var stepsRepository: StepsRepository

    override fun onStepsChanged(steps: Long) {
        viewModelScope.launch {
            stepsRepository.updateSteps(steps)  // Repository로 전달
        }
    }
}

7.3 Extension Functions 정리

// util/Extensions.kt (새로 생성)
fun ZonedDateTime.toFormattedString(pattern: String = "yyyy-MM-dd HH:mm"): String =
    this.format(DateTimeFormatter.ofPattern(pattern))

fun Long.toStepString(): String = DecimalFormat("#,###").format(this)

fun Context.hasPermission(permission: String): Boolean =
    ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED

우선순위: 긴급 (Phase 1 - 오타 수정만)

🤖 8. CI/CD 파이프라인 구축

GitHub Actions 워크플로우

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Cache Gradle packages
        uses: actions/cache@v3
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Run unit tests
        run: ./gradlew testDebugUnitTest --stacktrace

      - name: Run lint
        run: ./gradlew lintDebug

      - name: Upload test results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: test-results
          path: app/build/reports/tests/

      - name: Upload lint results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: lint-results
          path: app/build/reports/lint-results-debug.html

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Build debug APK
        run: ./gradlew assembleDebug

      - name: Upload APK
        uses: actions/upload-artifact@v3
        with:
          name: app-debug
          path: app/build/outputs/apk/debug/app-debug.apk

우선순위: 중요 (Phase 3)

📊 9. 우선순위별 로드맵

Phase 기간 작업 항목 우선순위 기대 효과
Phase 1
(즉시)
1주 ✅ presentaion → presentation 수정
✅ API 키 BuildConfig로 이동
✅ 디버그 로그 민감정보 제거
✅ gradle.properties 최적화
긴급 보안 취약점 해결
빌드 속도 향상
Phase 2
(단기)
2-3주 🏗️ Domain 레이어 구축
🏗️ Repository 패턴 도입
🏗️ UseCase 클래스 작성
🏗️ 상태 관리 통합 (단일 UiState)
긴급 아키텍처 정립
테스트 가능성 향상
Phase 3
(중기)
1개월 🧪 테스트 커버리지 70%+ 달성
🧪 Mock/Fake 구현
🌐 네트워크 에러 핸들링 강화
🤖 CI/CD 파이프라인 구축
중요 안정성 향상
자동화 구축
Phase 4
(장기)
2-3개월 📊 Firebase Analytics 통합
📊 Crashlytics 추가
🚀 성능 프로파일링
🚀 멀티모듈화 검토
일반 모니터링
확장성 확보

📈 10. 성과 측정 지표 (KPI)

측정 가능한 개선 목표

지표 현재 목표 (3개월 후) 측정 방법
테스트 커버리지 ~10% 70%+ JaCoCo 리포트
빌드 시간 (Clean Build) 측정 필요 -30% 개선 Gradle Build Scan
Lint 경고 측정 필요 0건 (Critical) ./gradlew lint
코드 중복도 측정 필요 <5% Detekt 정적 분석
평균 메서드 길이 측정 필요 <30 LOC SonarQube

💭 회고 및 고찰

핵심 인사이트

  • 문서 vs 실제 코드의 괴리: ARCHITECTURE.md, TESTING.md에 명시된 원칙들이 실제 구현되지 않은 상태. 이는 프로젝트 초기에 이상적 구조를 설계했으나, 실제 개발 과정에서 우선순위나 시간 제약으로 인해 단계적 구현이 이뤄지지 않았음을 시사
  • 기술 부채의 누적: 초기에는 빠른 프로토타이핑을 위해 ViewModel에서 직접 Manager를 호출하는 방식을 택했으나, 이제 프로젝트가 성숙 단계에 접어들면서 리팩터링 필요성이 대두
  • 보안에 대한 인식 부족: API 키를 string resource로 노출하는 것은 초보적 실수. 민감정보 관리에 대한 체계적 접근 필요
  • 테스트 문화 부재: AI 관련 유틸만 테스트되고 핵심 비즈니스 로직은 테스트가 없다는 것은 개발 과정에서 테스트 우선 접근(TDD)이 적용되지 않았음을 의미

프로젝트의 강점

  • ✅ 현대적 기술 스택 채택 (Compose, Hilt, Kotlin Coroutines, Flow)
  • ✅ Version Catalog로 의존성 중앙 관리
  • ✅ 명확한 문서화 (ARCHITECTURE.md, TESTING.md, SECURITY.md)
  • ✅ AI 모델 자동 선택 유틸 등 독창적 기능 구현
  • ✅ Phone + Wear OS 통합 프로젝트로 복잡도 관리

장기적 비전

이번 개선 로드맵을 통해 Health501은 다음과 같은 장기적 이점을 얻을 수 있습니다:

  1. 확장성: 새로운 기능 추가 시 명확한 레이어 구조로 인해 변경 영향 범위를 최소화
  2. 협업 효율성: 팀원이 추가되어도 일관된 패턴으로 인해 온보딩 시간 단축
  3. 유지보수성: 높은 테스트 커버리지로 리그레션 방지 및 안전한 리팩터링 가능
  4. 품질 보증: CI/CD를 통한 자동화된 검증으로 버그 조기 발견

📋 다음 단계 (Next Actions)

즉시 착수 가능한 작업 (오늘~이번 주)

  1. presentaion → presentation 패키지 리네임
    git mv app/src/main/java/com/billcoreatech/health501/presentaion \
           app/src/main/java/com/billcoreatech/health501/presentation
  2. API 키 보안 강화
    • app/build.gradle.kts 수정 (resValue → buildConfigField)
    • CoupangViewModel.kt에서 사용 방식 변경
    • 디버그 로그 제거
  3. gradle.properties 최적화
    • 캐싱, 병렬 빌드 활성화
    • JVM 힙 메모리 증가
  4. domain 패키지 구조 생성
    mkdir -p app/src/main/java/com/billcoreatech/health501/domain/{model,usecase}
    mkdir -p app/src/main/java/com/billcoreatech/health501/data/repository
  5. Result.kt sealed interface 작성
    • Success, Error, Loading 상태 정의
    • 전체 프로젝트에서 일관되게 사용

주간 목표 설정 (Week 1-2)

  • [ ] Phase 1 모든 작업 완료
  • [ ] GetTodayStepsUseCase 구현 및 테스트 작성
  • [ ] HealthDataRepository 인터페이스 및 구현체 작성
  • [ ] HealthConnectViewModel 리팩터링 (UseCase 통합)
  • [ ] CI/CD 워크플로우 초안 작성

🎓 학습 자료 및 참고 문서


 

 

반응형