반응형
🔍 프로젝트 진단 | 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은 다음과 같은 장기적 이점을 얻을 수 있습니다:
- 확장성: 새로운 기능 추가 시 명확한 레이어 구조로 인해 변경 영향 범위를 최소화
- 협업 효율성: 팀원이 추가되어도 일관된 패턴으로 인해 온보딩 시간 단축
- 유지보수성: 높은 테스트 커버리지로 리그레션 방지 및 안전한 리팩터링 가능
- 품질 보증: CI/CD를 통한 자동화된 검증으로 버그 조기 발견
📋 다음 단계 (Next Actions)
즉시 착수 가능한 작업 (오늘~이번 주)
- presentaion → presentation 패키지 리네임
git mv app/src/main/java/com/billcoreatech/health501/presentaion \ app/src/main/java/com/billcoreatech/health501/presentation - API 키 보안 강화
- app/build.gradle.kts 수정 (resValue → buildConfigField)
- CoupangViewModel.kt에서 사용 방식 변경
- 디버그 로그 제거
- gradle.properties 최적화
- 캐싱, 병렬 빌드 활성화
- JVM 힙 메모리 증가
- domain 패키지 구조 생성
mkdir -p app/src/main/java/com/billcoreatech/health501/domain/{model,usecase} mkdir -p app/src/main/java/com/billcoreatech/health501/data/repository - Result.kt sealed interface 작성
- Success, Error, Loading 상태 정의
- 전체 프로젝트에서 일관되게 사용
주간 목표 설정 (Week 1-2)
- [ ] Phase 1 모든 작업 완료
- [ ] GetTodayStepsUseCase 구현 및 테스트 작성
- [ ] HealthDataRepository 인터페이스 및 구현체 작성
- [ ] HealthConnectViewModel 리팩터링 (UseCase 통합)
- [ ] CI/CD 워크플로우 초안 작성
🎓 학습 자료 및 참고 문서
반응형
'모바일 앱(안드로이드)' 카테고리의 다른 글
| 🦾 Android | 메인 화면 뒤로가기 UX 개선, 워치/폰 걸음수 분리 표시, 설정 화면 카드화 (0) | 2025.12.21 |
|---|---|
| 🕹️ Android | Wear Compose UI 레이아웃 정리와 중앙정렬, 문자열 리소스화 (0) | 2025.12.17 |
| 🧪 테스트 시나리오 | AiAutoSelector 단위 테스트 실패 → 가중치 조정 및 외부 설정 리팩터링 (1) | 2025.12.13 |
| 🦾 Android | 워치앱 빌드 오류 수정과 UI/국제화 개선 정리 (1) | 2025.12.11 |
| 🐾 Android | Kalman vs EMA — 고도/센서 데이터 필터링 비교와 적용기 (2) | 2025.12.09 |