다른 프로젝트 코드를 볼 때마다 BaseActivity, BaseViewModel 같은 베이스 클래스를 어렵지 않게 볼 수 있습니다. 하지만 솔직히 저는 이런 구조를 이해하지 못했습니다. 항상 왜 이렇게 만들었을까, 어떤 점이 좋다는 걸까 하는 의문을 계속 가졌습니다.
물론 쓰는 이유에 대해서 어느정도 짐작이 가긴합니다. 보통은 공통 로직을 한 곳에 모아두면 편하고 코드 중복을 줄이면서 일관된 구조를 유지하는 이점이 있습니다만 그 장점만으로는 베이스 클래스를 쓰지 않는 편이 낫다고 생각했어요.
왜 그런 기시감이 들었는지 계속 궁금했는데 최근에 본 영상에서 제가 막연하게 느꼈던 불편함에 대해서 구체화 한것 같았습니다. 그래서 그 내용을 바탕으로 제 생각을 정리해봤습니다.
클래스 네이밍으로는 아무것도 알 수 없음
코드를 한번 볼까요?
class TodoListViewModel(
// ...
) : BaseViewModel<TodoListState, TodoListAction, BaseEvent>() {
override val initialState = TodoListState()
override fun onAction(action: TodoListAction) {
// ...
}
}
이 코드만 봤을 때 BaseViewModel이 정확히 무엇을 하는지 느껴지시나요? initialState와 onAction을 오버라이드한다는 건 보이지만 그 외에는 BaseViewModel 파일을 직접 열어봐야 알 수 있습니다.
좋은 클래스명은 그 클래스의 책임을 바로 알려줘야 합니다. TodoRepository라는 이름만 봐도 "Todo 데이터를 관리하는 클래스구나" 하고 이해할 수 있어요. 하지만 BaseViewModel은 "뷰모델들의 부모 클래스"라는 것 외에는 아무것도 알려주지 않습니다.
베이스 클래스가 이런 이름을 가질 수밖에 없는 이유는 명확한 책임이 하나가 아니기 때문입니다. 여러 유틸리티 함수를 넣다 보니 적절한 이름을 붙이는 것 자체가 불가능해진다고 생각해요. 300자짜리 클래스명을 만들 게 아닌이상...
숨겨진 의존성이 너무 많다
의존성은 보통 생성자에 명시적으로 넣습니다.
class TodoListViewModel(
private val todoRepository: TodoRepository
) : ViewModel() {
// todoRepository에 의존한다는 게 바로 보인다
}
이렇게 코드를 작성하면 뷰모델이 무엇을 사용하는지 한눈에 알 수 있습니다..
그런데 상속을 사용하면 달라지는데요. BaseViewModel을 상속받는 순간 그 안에 있는 모든 의존성을 함께 가져오게 되는데 이게 자식 클래스에서는 전혀 보이지 않습니다.
abstract class BaseViewModel<State, Action, Event> : ViewModel() {
private val analyticsTracker = AnalyticsTracker() // 보이지 않는 의존성
init {
trackScreenView() // 모든 ViewModel 생성할 때마다 자동 실행
}
private fun trackScreenView() {
analyticsTracker.track("screen_view")
}
}
이제 TodoListViewModel을 만들 때마다 자동으로 화면 조회 이벤트가 전송됩니다. 하지만 TodoListViewModel의 코드 어디에도 이런 동작에 대한 힌트가 없습니다.
작은 프로젝트에서는 괜찮을 수도 있습니다. 하지만 프로젝트가 커지고 팀원이 늘어나면 누군가 BaseViewModel을 수정하는 순간 수십 개 뷰모델이 영향받게 됩니다. 예상하지 못한 동작이 생겨나고 의도에 맞지 않는 동작을 유발할 가능성이 증가합니다.
유연하게 대응하기 어렵다
프로젝트를 진행하다 보면 요구사항이 수도없이 계속 바뀝니다. 어떤 화면은 애널리틱스 추적이 필요 없을 수도 있고 어떤 뷰모델은 상태 관리가 필요 없을 수도 있습니다.
abstract class BaseViewModel<State, Action, Event> : ViewModel() {
abstract val initialState: State
abstract fun onAction(action: Action)
protected val _state = MutableStateFlow(initialState)
val state = _state.asStateFlow()
protected val _events = Channel<Event>()
val events = _events.receiveAsFlow()
}
이 구조는 모든 뷰모델이 State, Action, Event를 가져야 한다고 강제합니다. 하지만 실제로는 정적인 화면이라 상태가 필요 없거나 이벤트 채널이 필요 없는 경우도 있습니다. 그래도 베이스 클래스를 상속받으면 억지로 만들어야 합니다. 그렇다고 상속받지 않으려니 직접 구현해야하는 문제와 컨벤션이 틀어진다는 문제도 생깁니다.
더 큰 문제는 Kotlin에서는 클래스 하나만 상속받을 수 있다는 겁니다. 만약 외부 라이브러리가 제공하는 클래스를 상속받아야 한다면 BaseViewModel을 포기하거나 코드를 중복해서 작성해야 합니다.
코드는 시간이 지나면 쌓인다
처음에는 간단합니다. BaseViewModel에 상태 관리 로직만 넣을 생각이었지만 시간이 지나면서 이런 식으로 변할 수 있습니다.
"모든 화면에서 로딩을 보여줘야 하니까 showLoading() 함수를 추가하자."
"에러 처리도 공통으로 하면 좋겠는데, handleError() 함수를 넣자."
"권한 체크도 여러 곳에서 하니까 여기 넣으면 편하겠다."
"로그아웃 처리도 공통화하자."
결국 BaseViewModel은 수백 줄짜리 거대한 클래스가 되고 누구도 완벽하게 이해하지 못하는 레거시가 됩니다. 수정하려니 어디에 영향을 미칠지 모르겠고 그래서 아무도 손대지 못하는 코드가 되는 거죠.
그럼 어떻게 하면 좋을까?
공통 기능이 필요하다면 상속보다는 컴포지션을 사용하는 편이 좋습니다. 공통 로직은 유틸리티나 별도의 클래스로 분리한 뒤 필요한 지점에서만 주입해 사용하면 됩니다. 구글에서도 권장하는 설계 방식이고 흔히 말하는 “상속보다는 조합” 원칙에 해당합니다.
class AnalyticsTracker {
fun trackScreenView(screenName: String) {
// 애널리틱스 추적 로직
}
}
class TodoListViewModel(
private val analyticsTracker: AnalyticsTracker // 명시적인 의존성
) : ViewModel() {
init {
// 필요할 때만 호출
analyticsTracker.trackScreenView("todo_list")
}
}
이렇게 작성하면 TodoListViewModel의 의존성이 명확하게 나타나죠? 애널리틱스 추적이 필요 없는 뷰모델에서는 주입하지 않으면 그만입니다. 반복적인 보일러플레이트가 있다면 확장 함수로 빼는 방법도 있습니다.
fun <T> ViewModel.collectAsState(
flow: Flow<T>,
initial: T
): StateFlow<T> {
return flow.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = initial
)
}
// 사용
class TodoListViewModel : ViewModel() {
val todos = todoRepository.getTodos()
.collectAsState(initial = emptyList())
}
정말로 아키텍처 가이드라인을 강제하려고 상속을 쓰고 싶다면 최소한 명확한 이름을 붙이는게 좋습니다. BaseViewModel 대신 MviViewModel 같은 이름을 쓰면 "MVI 패턴을 강제하는 클래스구나" 하고 바로 이해할 수 있을 것 같습니다.
abstract class MviViewModel<State, Action> {
abstract val initialState: State
abstract fun onAction(action: Action)
// MVI 패턴 강제를 위한 최소한의 코드만
}
하지만 이 방식도 완벽하지는 않습니다. 정적인 화면처럼 상태가 필요 없는 경우에도 무조건 State를 정의해야만 합니다.
정리해보면
저는 프로젝트를 진행하면서 베이스 클래스를 적극적으로 쓴 적이 아직 없습니다. 사용해야 하는 이유에 대해서, 왜 쓰는지 공감하지 못했기 때문이에요.
물론 쓰는 이유는 생각해봤습니다. 공통 로직을 재사용하고 일관된 패턴을 강제하고 보일러플레이트를 줄이려는 의도, 하지만 그것만으로는 부족하다고 느꼈습니다. 오히려 베이스 클래스를 쓰지 않는 편이 훨씬 이점이 크다고 생각했습니다.
왜 그런 기시감이 들었는지 이제야 좀 알 것 같아요. 대신 공통 기능은 별도 클래스로 분리해서 의존성 주입으로 사용하고 확장 함수로 반복 코드를 줄이고 정말 상속이 필요하면 명확한 이름과 단일 책임을 지키는 게 낫다고 생각합니다. 실제로 하이링구얼이라는 프로젝트에서 커스텀 플러그인을 적용할때 조합을 사용했습니다.👍🏻
여러분은 어떻게 생각하시나요? 혹시 다른 의견이 있다면 언제든 피드백 환영합니다.
'Android' 카테고리의 다른 글
| Android 17 BETA, 공부 많이 된다 (0) | 2026.02.24 |
|---|---|
| Google Mobile Ads Next-Gen SDK, 왜 만든건데? (0) | 2026.02.09 |
| AGP 9.0 마이그레이션, 뭐가 달라졌는데요? (1) | 2026.01.21 |
| HttpLoggingInterceptor JSON 파싱 최적화로 95% 성능 개선하기 (0) | 2026.01.21 |
| Android CI 빌드 속도 1분대로 줄여보기 (0) | 2026.01.06 |