
안드로이드 Repository 패턴은 꽤 자주 오해됩니다. Repository만 있어도 충분한데 UseCase까지 일괄로 붙이는 팀도 있고, 반대로 ViewModel에서 API와 Room을 직접 다루면서도 별문제를 못 느끼는 팀도 있습니다. 이번 글에서는 Repository의 책임 범위, UseCase가 실제로 도움이 되는 경우와 과한 경우, 앱 규모별 현실적인 판단 기준을 실무 관점으로 정리해보겠습니다.
Repository가 맡는 일
Repository는 API를 한 번 감싸는 얇은 래퍼 이름이 아닙니다. Android 공식 권장사항도 UI layer가 데이터 소스와 직접 상호작용하지 말고 repository를 통해 데이터를 다루라고 설명합니다. 즉 Repository의 핵심은 파일 개수가 아니라 데이터 접근 경계를 세우는 데 있습니다.
- 어떤 데이터 소스를 읽을지 결정하기
- 네트워크와 로컬 데이터를 조합하기
- 캐시 우선인지 네트워크 우선인지 정책을 정하기
- 외부 응답을 앱이 쓰기 쉬운 형태로 정리하기
- 같은 데이터를 여러 화면에서 일관되게 바라보게 만들기
class UserRepository(
private val api: UserApi
) {
suspend fun getUser(id: Long): UserDto {
return api.getUser(id)
}
}이 코드가 무조건 나쁜 것은 아닙니다. 다만 이 상태만으로는 아직 Repository의 이점이 크게 드러나지 않습니다. 지금은 경계가 커질 수 있는 자리를 하나 만든 정도에 가깝습니다.
Repository의 진짜 가치는 API를 감싸는 데 있지 않고 데이터 접근의 주인을 한 곳에 모으는 데 있습니다.
어디까지 Repository인가
실무에서 더 자주 헷갈리는 부분은 Repository의 범위입니다. 서버 검색 결과를 받아오고, 최근 검색어를 저장하고, 네트워크가 없으면 캐시를 보여주고, 응답 DTO를 화면이 쓰기 쉬운 형태로 정리해야 한다면 이런 흐름은 ViewModel보다 Repository에 모으는 편이 훨씬 낫습니다.
class SearchRepository(
private val api: SearchApi,
private val recentKeywordDao: RecentKeywordDao,
private val cachedResultDao: CachedResultDao
) {
suspend fun search(query: String): List<SearchItem> {
recentKeywordDao.insert(query)
return try {
val remoteItems = api.search(query)
cachedResultDao.replaceAll(query, remoteItems)
remoteItems.map { it.toSearchItem() }
} catch (e: IOException) {
cachedResultDao.findByQuery(query).map { it.toSearchItem() }
}
}
}이제 Repository는 단순 전달자가 아닙니다. 어디서 읽고, 실패하면 무엇을 대체로 쓰고, 어떤 형태로 밖에 내보낼지를 정리하는 자리가 됩니다. 반대로 데이터 소스가 하나뿐이고 가공도 거의 없다면 Repository는 아주 얇아도 괜찮습니다. 그 단계에서는 경계를 미리 무겁게 만들지 않는 것이 더 중요할 수 있습니다.
UseCase가 필요한 순간
UseCase는 Repository보다 더 쉽게 과해집니다. Android 공식 문서도 domain layer를 optional이라고 설명하며, 복잡성을 다루거나 재사용성을 높일 때만 쓰라고 안내합니다. 즉 ViewModel 메서드마다 UseCase 클래스를 하나씩 세우는 방식이 기본값은 아닙니다.
- 여러 Repository 결과를 조합해야 한다
- 비즈니스 규칙이 두세 단계 이상 이어진다
- 같은 규칙을 여러 화면에서 재사용한다
- UI와 무관하게 테스트하고 싶은 계산 규칙이 있다
- 실패 처리나 분기 로직이 화면보다 도메인 쪽에 더 가깝다
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가 자연스럽습니다.
UseCase가 과한 순간
class GetProfileUseCase(
private val repository: ProfileRepository
) {
suspend operator fun invoke(userId: Long): Profile {
return repository.getProfile(userId)
}
}이 코드는 틀렸다고 보기는 어렵지만, 지금 이 클래스가 실제로 무엇을 더하는지는 따져봐야 합니다. 규칙이 추가되지 않고, 재사용 포인트도 없고, 테스트 대상만 늘어난다면 호출 경로를 한 단계 더 늘린 셈에 가깝습니다. 작은 앱에서는 이런 얇은 UseCase가 금방 ceremony가 됩니다.
- GetXxxUseCase가 repository 한 줄 위임만 한다
- 기능 하나 추가할 때 ViewModel, UseCase, Repository, mapper 파일이 한 세트로 늘어난다
- 코드 읽는 시간보다 파일 점프 시간이 더 길어진다
UseCase는 호출 단계를 늘리는 장식이 아니라 복잡한 규칙을 모으는 자리일 때만 값이 생깁니다.
앱 크기별 판단
현실적으로는 앱 규모에 따라 권장 분리 수준이 조금 달라집니다. 다만 기준은 화면 수 자체보다 데이터 소스 개수, 규칙 복잡도, 협업 경계, 테스트 필요성에 더 가깝습니다.
작은 앱
화면 수가 적고 데이터 소스가 하나이며 규칙도 단순하다면 UI, ViewModel, Repository, 필요한 경우에만 mapper 정도면 충분한 경우가 많습니다. 이 단계에서는 Repository만으로도 UI가 데이터 소스를 직접 부르지 않게 막아주고, 나중에 캐시나 Room을 붙일 자리도 생깁니다. UseCase는 아직 없어도 괜찮은 경우가 많습니다.
성장 중인 앱
API와 Room을 함께 쓰고, 같은 데이터를 여러 화면에서 보고, 실패 처리와 캐시 전략이 생기고, ViewModel이 점점 비대해진다면 Repository의 역할이 눈에 띄게 커집니다. 이때는 모든 기능에 UseCase를 넣기보다 복잡한 기능부터 일부 도입하는 방식이 잘 맞습니다. 예를 들면 단순 목록 조회보다 검색, 필터, 정렬, 캐시 조합 같은 흐름이 먼저 후보가 됩니다.
큰 앱
팀이 커지고 기능이 분화되면 경계가 합의 장치가 됩니다. 여러 팀이 동시에 같은 영역을 건드리고, 규칙 재사용 범위가 넓고, 테스트해야 할 핵심 로직이 많다면 Repository와 UseCase를 더 적극적으로 분리할 이유가 생깁니다. 이 수준에서는 작은 앱에선 과했던 구조가 오히려 유지보수 비용을 줄이는 장치가 되기도 합니다.
interface도 항상 필요할까
Repository를 말할 때 interface와 impl을 무조건 같이 만들어야 한다고 느끼는 경우가 많습니다. 하지만 이것도 기본값으로 두면 과해질 수 있습니다. 교체 가능성이 분명하거나, 테스트 대역을 분리해야 하거나, 모듈 경계를 명확히 두려는 상황이라면 interface가 잘 맞습니다. 반대로 구현이 하나뿐이고 같은 모듈 안에서만 단순하게 쓰는 단계라면 concrete class 하나로 시작해도 충분할 수 있습니다.
- 구현이 실제로 바뀔 가능성이 큰가
- 테스트에서 분리해야 할 경계가 분명한가
- 팀이 이 경계를 합의 지점으로 쓰는가
이 질문에 선명한 이유가 없다면 interface부터 무조건 세우는 습관은 다시 생각해볼 만합니다. 중요한 것은 interface를 만들었느냐가 아니라 변경 비용을 줄였느냐입니다.
추천 시작점
- UI가 데이터 소스를 직접 호출하지 않게 한다
- Repository에서 데이터 접근을 모은다
- 복잡한 규칙이 생기면 그 기능에만 UseCase를 도입한다
- 모델 경계를 보호해야 할 때만 mapper를 늘린다
이 흐름이 좋은 이유는 구조가 필요에 따라 자라기 때문입니다. 처음부터 미래를 다 대비하려 하면 얇은 클래스만 많이 남기 쉽고, 반대로 아무 경계도 없이 시작하면 나중에 ViewModel이 모든 것을 떠안게 됩니다. Repository는 보통 그 중간에서 가장 먼저 가져갈 만한 안전한 경계입니다.
구조 판단의 큰 그림은 안드로이드 앱 구조를 볼 때 무엇부터 판단해야 할까, 복잡도 전체 판단은 안드로이드 클린 아키텍처, 작은 앱에도 필요할까 글과도 자연스럽게 이어집니다.
빠른 체크리스트
- ViewModel이 API, Room, DataStore를 직접 다루고 있는가
- 같은 데이터 접근 로직이 여러 ViewModel에 흩어져 있는가
- 네트워크 실패 시 대체 전략이나 캐시 정책이 있는가
- 여러 Repository 결과를 조합하는 규칙이 있는가
- 화면을 벗어난 비즈니스 규칙을 테스트하고 싶은가
- UseCase가 실제 규칙을 담고 있는가, 아니면 한 줄 위임인가
여기서 1~3이 많이 걸리면 Repository 경계를 먼저 손볼 때입니다. 4~6이 많이 걸리면 일부 기능에 UseCase를 두는 쪽이 더 효과적일 가능성이 큽니다. 상태 변경 경로가 헷갈린다면 안드로이드 UDF는 왜 중요할까, ViewModel의 역할이 애매하다면 ViewModel은 왜 필요할까를 함께 보면 좋습니다.
정리
안드로이드 Repository 패턴은 모든 앱에서 거창하게 구현할 필요는 없습니다. 하지만 없어도 되는 장식으로 보기에도 아까운 경계입니다. 작은 앱에서는 Repository가 가장 현실적인 최소 경계가 되는 경우가 많고, UseCase는 복잡성이 생길 때 천천히 도입해도 충분합니다. 반대로 앱이 커지고 규칙이 늘어나면 Repository만으로는 버티기 어려워지고, 그때 UseCase가 제 역할을 시작합니다.
결국 기준은 간단합니다. Repository는 데이터 접근의 주인을 모으는가, UseCase는 복잡한 규칙을 모으는가, 지금 앱 규모가 그 분리 비용을 감당할 만큼 복잡한가. 이 세 가지에 그렇다가 많아질수록 구조를 한 단계 키우면 됩니다. 공식 기준은 Guide to app architecture, Architecture recommendations, Domain layer 문서를 함께 보면 더 분명합니다.