
안드로이드 클린 아키텍처가 늘 정답은 아닙니다. 작은 앱에서는 도움이 되기보다 ceremony가 될 때도 많고, 반대로 팀이 커지거나 비즈니스 규칙이 복잡해지면 뒤늦게 분리 비용을 더 크게 치르기도 합니다. 이번 글에서는 앱 규모, 팀 규모, UseCase, Repository, Mapper의 trade-off를 기준으로 어디까지 분리하는 것이 현실적인지 정리해보겠습니다.
안드로이드 클린 아키텍처가 해결하려는 문제
클린 아키텍처를 이야기할 때 먼저 봐야 할 것은 폴더 구조가 아닙니다. 화면 코드가 네트워크 호출, 로딩 상태, 에러 문구, 데이터 조합까지 다 떠안고 있는지, 같은 비즈니스 규칙이 여러 ViewModel에 복사되는지, API 응답 구조가 UI까지 그대로 새어 들어오는지부터 봐야 합니다. 이런 문제가 있다면 구조 분리는 실제 도움이 됩니다. 반대로 아직 화면 수가 적고 상태 흐름도 단순하다면 레이어를 늘리는 것만으로는 얻는 것이 많지 않을 수 있습니다.
클린 아키텍처의 핵심은 클래스 수가 아니라 변경 비용을 줄이는 데 있습니다. Android 공식 문서도 separation of concerns, single source of truth, unidirectional data flow를 핵심 원칙으로 제시합니다. 즉 이름보다 책임과 흐름이 먼저입니다.
작은 앱의 ceremony
작은 앱에서 가장 흔한 문제는 아직 필요하지 않은 레이어를 한꺼번에 만드는 것입니다. 화면마다 ViewModel, 기능마다 UseCase, Repository interface와 impl, DTO와 Entity와 DomainModel과 UiModel, mapper 파일까지 모두 만드는 식입니다. 실제 규칙이 단순하면 `repository.getTodos()`를 한 줄 감싸는 UseCase, 필드 이름만 그대로 복사하는 mapper, 새 기능 하나 추가할 때 파일 6개 이상이 같이 늘어나는 구조가 되기 쉽습니다.
- GetTodoListUseCase가 사실상 repository 메서드 한 줄만 감싼다
- RepositoryImpl이 데이터 소스 하나를 그대로 전달만 한다
- mapper는 필드 이름만 똑같이 복사한다
- 새 기능 하나 추가할 때 파일 수만 과하게 늘어난다
이때는 구조가 앱을 지탱하는 것이 아니라, 앱이 구조를 유지하기 위해 일하는 쪽에 가깝습니다. 작은 앱에서는 완벽한 분리보다 읽기 쉬운 흐름이 더 중요합니다. 보통은 UI, ViewModel, Repository 정도의 기본 경계와 필요한 곳에만 mapper나 use case를 두는 편이 더 낫습니다.
큰 앱에서 필요한 이유
앱이 커지면 상황이 달라집니다. 화면 수가 늘고, 기능 팀이 나뉘고, API와 로컬 캐시가 함께 움직이고, 권한이나 결제 같은 규칙이 붙기 시작하면 단순한 흐름만으로는 버티기 어려워집니다. 이때는 여러 화면에서 같은 규칙을 재사용하고, UI 변경과 데이터 변경의 충돌을 줄이고, 테스트 대상을 더 작게 나누고, 팀원이 서로 다른 계층에서 병렬로 작업하는 데 구조 분리가 실제 이점을 줍니다.
즉 큰 앱에서 클린 아키텍처가 필요한 이유는 이론이 더 중요해서가 아닙니다. 조율 비용이 올라가기 때문입니다. 혼자 만드는 작은 앱에서는 머릿속 정리가 더 중요할 수 있지만, 여러 명이 오래 만지는 앱에서는 코드베이스 안의 합의 장치가 더 중요해집니다.
더 중요한 기준
작은 앱이면 불필요하고 큰 앱이면 필요하다고 잘라 말하면 현실과 조금 다릅니다. 실제로는 앱 크기보다 비즈니스 규칙의 복잡도, 같은 규칙의 반복 여부, 데이터 소스 개수, 팀 협업 경계, 테스트해야 할 핵심 규칙 유무가 더 잘 맞는 기준입니다.
- 비즈니스 규칙이 복잡한가
- 같은 규칙이 여러 화면에서 반복되는가
- 데이터 소스가 하나인가 여러 개인가
- 팀이 커져서 책임 경계가 필요한가
- UI 없이 테스트해야 할 핵심 규칙이 있는가
예를 들어 화면은 하나여도 결제 검증, 쿠폰 조합, 오프라인 동기화, 여러 데이터 소스 병합 같은 작업은 구조 분리 이점이 큽니다. 반대로 화면 수가 좀 많아도 규칙이 단순하면 굳이 모든 레이어를 세분화할 필요는 없습니다.
UseCase
UseCase는 안드로이드에서 가장 쉽게 과해지는 레이어입니다. Android 공식 문서도 domain layer를 optional이라고 설명하며, 복잡한 비즈니스 로직이 있거나 여러 ViewModel에서 재사용될 때만 쓰라고 안내합니다. 즉 화면 이벤트마다 무조건 UseCase 클래스를 하나씩 만드는 패턴은 공식 권장사항의 핵심과는 거리가 있습니다.
- 비즈니스 규칙이 두세 단계 이상 섞여 있다
- 여러 Repository 결과를 조합해야 한다
- 같은 규칙을 여러 ViewModel에서 쓴다
- UI와 무관하게 테스트하고 싶은 계산 로직이 있다
// 이 경우는 UseCase가 거의 의미를 더하지 못한다.
class GetProfileUseCase(
private val repository: ProfileRepository
) {
suspend operator fun invoke(userId: Long): Profile {
return repository.getProfile(userId)
}
}// 이 경우는 규칙을 모으는 자리가 생긴다.
class GetCheckoutSummaryUseCase(
private val cartRepository: CartRepository,
private val couponRepository: CouponRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke(): CheckoutSummary {
val cart = cartRepository.getCart()
val coupons = couponRepository.getAvailableCoupons()
val user = userRepository.getUser()
val bestCoupon = coupons
.filter { it.isApplicableTo(cart, user) }
.maxByOrNull { it.discountAmount(cart) }
return CheckoutSummary.from(cart, user, bestCoupon)
}
}UseCase는 호출 경로를 늘리는 장식이 아니라 복잡한 규칙을 모으는 자리일 때 값이 생깁니다.
Repository
Repository는 UseCase보다 훨씬 실용적인 경우가 많습니다. Android 공식 권장사항도 UI 계층이 데이터 소스와 직접 상호작용하지 말고 repository를 통해 애플리케이션 데이터를 노출하라고 설명합니다. 작은 앱에서도 data layer 타입을 꼭 거창한 모듈로 뺄 필요는 없지만, 데이터 접근 경계 자체는 분명히 두는 편을 권장합니다.
- API, Room, DataStore 같은 데이터 소스가 섞여 있다
- 캐시 우선, 네트워크 우선 같은 정책이 있다
- 나중에 데이터 소스가 바뀔 가능성이 있다
- 여러 화면이 같은 데이터 집합을 바라본다
class UserRepositoryImpl(
private val api: UserApi
) : UserRepository {
override suspend fun getUser(id: Long): UserResponse {
return api.getUser(id)
}
}이 정도라면 아직 Repository의 이점이 아주 크다고 보긴 어렵지만, 그래도 데이터 접근의 주인을 한 곳에 모으는 효과는 있습니다. 실무에서는 UI에서 데이터 소스를 직접 부르지 않고, Repository에서 데이터 접근을 모은 뒤, 복잡도가 생기면 그 위에 UseCase를 더하는 순서가 무난합니다.
Mapper
DTO, Entity, DomainModel, UiModel을 모두 분리하면 구조가 단단해 보일 수 있습니다. 하지만 값만 복사하는 파일이 계속 늘어나면 mapper는 금방 ceremony가 됩니다. Mapper가 진짜 필요한 경우는 API 응답 구조를 UI에 그대로 노출하고 싶지 않거나, 로컬 저장 모델과 화면 모델의 관심사가 다르거나, null 처리와 기본값 보정, 표시용 가공이 필요한 때입니다.
data class UserDto(
val id: Long?,
val nickname: String?,
val imageUrl: String?
)
data class UserUiModel(
val id: Long,
val displayName: String,
val avatarUrl: String,
val isAnonymous: Boolean
)
fun UserDto.toUiModel(): UserUiModel {
val safeName = nickname?.takeIf { it.isNotBlank() } ?: "이름 없음"
return UserUiModel(
id = id ?: -1L,
displayName = safeName,
avatarUrl = imageUrl.orEmpty(),
isAnonymous = id == null
)
}반면 필드 이름과 타입이 거의 같고 변환 규칙도 없다면 굳이 별도 모델을 매번 늘릴 필요는 없습니다. 작은 앱에서는 경계를 보호해야 할 때만 매핑해도 충분한 경우가 많습니다.
현실적인 최소 구조
실무에서는 처음부터 풀세트를 넣기보다 최소 구조로 시작하고 필요할 때 키우는 편이 더 안정적입니다. 작은 앱이나 초기 기능이라면 UI, ViewModel, Repository, 필요한 경우에만 mapper 정도가 현실적입니다. 이 단계에서 중요한 것은 UI가 데이터 소스를 직접 건드리지 않는 것, 상태와 데이터 흐름의 주인이 보이는 것입니다.
- ViewModel 하나가 너무 많은 규칙을 품기 시작한다
- 같은 로직이 다른 화면으로 퍼지기 시작한다
- 여러 Repository를 조합하는 계산이 생긴다
- 테스트해야 할 핵심 규칙이 커진다
이런 신호가 보이면 그때 UseCase를 도입해도 늦지 않습니다. 구조는 신앙처럼 선행 도입하는 것이 아니라 복잡도에 반응하면서 자라게 하는 편이 좋습니다. 구조 판단의 큰 그림은 안드로이드 앱 구조를 볼 때 무엇부터 판단해야 할까 글과도 자연스럽게 이어집니다.
체크리스트
- 화면 코드에 비즈니스 규칙이 많이 섞여 있는가
- 같은 규칙이 두 곳 이상에서 반복되는가
- 데이터 소스가 둘 이상인가
- 외부 응답 모델이 UI까지 그대로 새고 있는가
- 바뀌면 치명적인 규칙을 UI 없이 테스트하고 싶은가
- 팀이 커지면서 책임 경계가 필요해졌는가
이 질문에 대부분 ‘아직 아니다’라면 작은 앱에 과한 구조를 미리 넣을 필요는 없을 가능성이 큽니다. 반대로 절반 이상이 ‘그렇다’라면 지금부터라도 레이어를 조금씩 분리하는 편이 이후 비용을 줄일 수 있습니다. 상태 경계가 헷갈린다면 ViewModel은 왜 필요할까, 화면 상태는 어디에 두는 게 맞을까, StateFlow와 SharedFlow 차이도 함께 읽어보면 좋습니다.
결론
안드로이드 클린 아키텍처는 꼭 필요할 때가 있습니다. 하지만 모든 앱에 같은 강도로 필요하지는 않습니다. 작은 앱에서는 Repository 하나와 명확한 상태 흐름만으로도 충분할 수 있고, 큰 앱이나 복잡한 도메인에서는 UseCase와 Mapper까지 분리해야 유지보수가 쉬워질 수 있습니다. 결국 기준은 앱 규모, 규칙 복잡도, 팀 협업 정도, 변경 비용을 같이 보는 것입니다.
공식 기준은 Guide to app architecture, Recommendations for Android architecture, Domain layer 문서를 같이 보면 더 분명해집니다.