안드로이드의 익숙한 BaseXXX class를 알아보고, 설계의 중요성을 알아보자.



개인 광고 영역

Java에서 상속은 매우 익숙하다. 앞에서 적은 상속! 악마의 속삭임, 그 속에 숨겨진 문제점, Kotlin에서는 Delegation을 활용해 보자. 글을 보면 알겠지만 상속에는 숨겨진 문제점이 있다.

상속을 지키리 면 open을 통해 열고 닫고를 잘해야 하고, 설계를 잘 해야 하고 주석도 잘 달아주는 게 좋다.

처음의 의도와 달라질 수 있는 환경이라면 상속보다는 다른 방법을 활용하는 게 좋다.

이 글에서는 Android에서 흔하게 작성하는 class BaseXXX를 알아보고 좀 더 좋은 접근 방법을 알아보려고 한다.


이 글에서 알아볼 내용

  • 안드로이드에서 Lifecycle을 따를 수 있는 상속을 계획할 수 있도록 글을 작성
  • 몇 가지 Base를 살펴보고 좀 더 좋은 방향을 알아보자.


익숙한 상속

자바를 활용하는 데 있어 상속은 매우 유용했다. 안드로이드 역시 상속을 매우 많이 하고 있는데, 지난 글 상속! 악마의 속삭임, 그 속에 숨겨진 문제점, Kotlin에서는 Delegation을 활용해보자.에서도 언급했던 Activity의 상속은 매우 깊은데 아래와 같다.

  • android.app.Activity : 가장 기본이 되는 Activity
  • androidx.core.app.ComponentActivity : Activity를 상속받아 재정의 한 class
  • androidx.activity.ComponentActivity : core의 ComponentActivity을 상속받아 재정의 한 class
  • androidx.activity.FragmentActivity : Fragment가 만들어지면서 생긴 상속
  • androidx.appcompat.app.AppCompatActivity : 모든 Activity에서 이를 상속받아 구현한다.
  • BaseActivity : 있을 수도 있고 없을 수도 있는데, 프로젝트의 뼈대로 활용하기 위한 class로 만든다.
  • ScreenActivity : BaseActivity를 상속받아 구현하는 class

Activity의 상속은 위와 같이 최소 6단계를 넘는다. 상속에서의 확장성과 설계는 매우 주의해야 한다. 상속에서 기존과 달라지고 새로운 코드로 이동하도록 하고 싶다면 Deprecated를 걸어두고 새로운 함수로 이동할 수 있는 구조 역시 만들어야 한다. 이런 설계를 잘 할 수 있어야 하는데, AppCompatActivity을 포함하는 상위 상속은 구글이 책임지고 있으니 구글이 알아서 잘 해줘야 한다.


상속은 나쁜 건 아니다.

상속이 나쁘다는 건 아니다. 이펙티브 자바에서 나오 듯

is-a 관계를 유지하기 어렵다면 이 역시 상속을 생각하지 않는 게 좋다.

이 내용에 따라 정말 상속으로 풀 수 있다면 상속을 허용하고, 그렇지 않다면 상속을 하지 않는 편이 좋다.

상속은 누구나 쉽게 접근할 수 있다. 다만 상속을 해야 하는 이유는 명확해야 한다. 가능하다면 누구나 보고 아 이래서 상속을 만들었군 정도면 더 좋다.

설계에 있어 상속은 매우 중요하다. 한번 만들고 수정을 했을 때 미치는 영향도가 엄청나기 때문이다.

안드로이드에서 상속은 보통 Activity/Fragment에 Base를 만드는 형태이다. 중복 코드를 줄일 수 있고, 나만의 형태를 만들기도 하지만 사용하다 보면 이런 패턴에 빠진 게 있다.

바로 Lifecycle을 생각지 않거나, 자연스럽게 상속을 만들 수 있음을 놓치고 지나치는 경우다.

Lifecycle 관련한 함수들은 언제나 상속을 다시 구현할 수 있다. 상위에 어떤 작업이 되어있는지를 매번 신경 쓰면서 작업하지 않을 것이다. 그냥 이렇게 하면 돌아가는구나는 알 수 있지만 상세하게 알고 쓰는 사람이 많지 않을 수 있다. 이렇게 작업하면 추후 Lifecycle 문제가 생겨 발생할 수 있는 문제를 한참의 디버깅을 통해 재확인해야 하는 경우도 생길 수 있다.

안드로이드 상속에서는 조금 과하지 않게, 정말 필요한 내용만을 담아두고 재사용할 수 있는 형태로 만들어 두는 게 좋다고 생각한다.

몇 가지 예를 통해 알아보도록 하자.


코틀린에서의 상속을 허용하려면

코틀린 class는 상속을 허용치 않는다. 이는 함수도 마찬가지다. 그래서 open 키워드를 추가해 주거나 abstract을 적어줘야 상속을 할 수 있다.

class BaseActivity : AppCompatActivity() {}

이 코틀린 클래스는 상속이 불가능하다.

그렇기에 open을 붙여 정의하거나, abstract으로 정의해야 한다.

open class BaseActivity : AppCompatActivity() {}

// 또는
abstract class BaseActivity : AppCompatActivity() {}

open 키워드를 추가할 때는 상속을 허용하지만 그에 따른 책임도 커져야 한다. 필요한 함수들에 대한 open을 허용하고 주석을 잘 달아두는 걸 추천한다.


상속은 좋지만 설계를 잘 해야 한다.

필자도 상속을 좋아한다. 미리 만들어두고 나중에 재사용 시 편하게 사용하려고 만드는 편이다.

MVP의 경우 상속을 미리미리 만들어두면 편한 이점이 있다. MVP의 경우 1:1 관계를 벗어나지 않기 때문에 이런 상속은 잘 어울릴 수 있다.

다만 P가 필요 없는 클래스도 이를 만들어야 하는지는 또 다른 이야기이다.


Layout 초기화를 쉽게 하기 위한 BaseActivity

레이아웃을 쉽게 하기 위한 BaseActivity 코드이다.

필자가 작성한 과거 코드에서 발견한 코드이다.

abstract class BaseActivity : AppCompatActivity() {

    @get:LayoutRes
    protected abstract val layoutRes: Int

    @get:StringRes
    protected abstract val toolbarTitle: Int

    private val toolbar: Toolbar? by lazy {
        findViewById<Toolbar>(R.id.toolbar)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(layoutRes)
        setToolBar()
        onCreate()
    }

    private fun setToolBar() {
        toolbar?.run {
            setTitle(toolbarTitle)
            setSupportActionBar(this)
        }
    }

    protected abstract fun onCreate()
}

이 코드에서 보이는 몇 가지 문제가 보이는데 정리하면 아래와 같다.

  1. setContentView는 항상 layoutRes만을 바라본다.
  2. toolbar라는 이름을 가진 Toolbar가 layout에 존재하지 않는다면 이 코드는 setToolBar가 있더라도 동작하지 않는다.
  3. onCreate()를 abstract으로 작성했다.

1 번의 경우 대부분의 레이아웃을 R.layout.xxxxx로 사용하기 때문에 큰 문제는 없다.

2 번 케이스는 문제가 있을 수 있다. 모든 사람이 같은 이름을 쓴다는 보장은 없다. BaseActivity를 항시 보면서 작성할 사람은 없다는 말이다. 그렇다고 큰 문제는 없다. 빌드를 한두 번 더 돌려서 해결할 수 있는 문제이기 때문에 큰 문제는 없지만 당하는 사람은 시간을 낭비하게 된다.

3 번 케이스는 Lifecycle을 지키도록 설계되어 있는 Android에서 새로운 onCreate를 만들어 처리하도록 했다.

케이스에 따라 화면을 해전 할 때 savedInstanceState를 처리하는 경우가 있는데, 어디에서도 보장해 주지 않는다.


번호에는 안 적었지만 더 큰 문제가 있다.

번호에는 나열하지 않았지만 가장 큰 문제는 setContentView()를 다시 상속해 구현할 수 있다. 이 말은 setContentView도 다시 할 수 있고, setSupportActionBar 역시 다시 할 수 있다는 말이다.

이 말은 이미 상속 구조에서 편하게 쓰도록 만들어둔 onCreate 등의 코드가 의미가 달라졌다는 말이 된다.

우리는 이렇게 작성하지 않는다.라고 했더라도 한 명이라도 이렇게 작성한 것이라면 이런 상속 구조는 언제든 깨질 수 있음을 인지해야 한다.

결론은 onCreate 기본 함수를 수정하되, 필자가 만든 abstract fun onCreate() 같은 새로운 구조는 생성하지 않아야 한다. 오랜만에 작성하면서 안드로이드 라이프 사이클을 벗어난 코드가 보이면 구조를 파악하기 힘들다. 저 onCreate가 정말 onCreate() 시점에 불렸는지 보장되는 건지 알기 어렵다. 정말 필요하다면 주석을 잘 달거나, lint를 작성하는 걸 추천한다.


Presenter를 포함하는 BaseFragment

이번엔 1:1관계를 허용하는 Presenter의 Base를 작성한 코드이다. 이 클래스 역시 abstract class로 작성했고, 여기서는 Generics을 많이 사용했다.

매번 BasePresenterFragment 사용 시 자바보다 강제화되어있는 <>을 필수로 입력해야 한다.

abstract class BasePresenterFragment<VIEW : BaseView?, P : BasePresenter<VIEW>?> : Fragment(), BaseView {

    protected var presenter: P? = null

    @get:LayoutRes
    protected abstract val layoutRes: Int

    protected abstract fun onCreatePresenter(): P?

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? =
        inflater.inflate(layoutRes, container, false)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        presenter = onCreatePresenter()
        presenter?.attachView(this as VIEW)
    }

    override fun onDestroy() {
        super.onDestroy()
        presenter?.detachView()
        presenter = null
    }

    // BaseView에 포함된 기본 isNotFinish
    override val isNotFinish: Boolean
        get() = activity?.isFinishing != true
}

이 클래스는 어떤 문제가 있을까?

  1. Presenter를 사용치 않는 클래스에서도 이 BasePresenterFragment를 사용 시 onCreatePresenter를 상속해야 한다.
  2. Activity와 동일하게 Android에서 제공하는 기본 함수는 언제나 상속을 허용한다.

1번 케이스는 적긴 했지만 뭐가 문제인가?라고 반문할 수 있다.

그렇다 딱히 문제는 없다. 하지만 BasePresenterFragment에 포함되어 있는 2개의 Generics 타입이 항상 강제되어 있어 불필요한 클래스 2개를 항상 생성해야 하는 점이다.

사용치 않아도 이들을 껍데기는 만들어야 한다. 그렇기에 새로운 BaseFragment를 하나 더 추가하던지, 아예 만들지 않는 편이 더 좋다.

2 번 케이스는 Activity에서도 설명했지만 언제나 일어날 수 있다. override는 계속적인 상속이 가능하다는 점이다.

그나마 BaseActivity에서처럼 Lifecycle을 벗어나지는 않으니 그나마 괜찮은 상속일 수 있다.


단순 반복을 줄일 수 있잖아.

단순 반복을 줄이기 위해 상속을 사용한다. 단순 반복을 줄이기 위해 유틸 클래스를 이용할 수도 있다.

결국 모두 단순 반복을 줄이기 위한 설계를 할 수 있다.

예를 들면 Activity/Fragment에서 모두 같은 코드가 필요하다면 이는 Base에 있어야 할까? Util에 있어야 할까?

답은 Util에 있는 편이 더 좋다.


Android에서의 상속 선택지

안드로이드에서의 레이아웃 초기화 상속 선택지는 많은데, 그나마 줄어서 이젠 3가지 정도를 할 수 있다.

  • ViewBinding
  • DataBinding
  • findViewById

위 세 가지는 뷰를 접근하는 방법이다. 가장 고전적인 방법이 findViewById이고, ViewBinding이 가장 최신 방법이다.

DataBinding을 허용하는 경우 레이아웃에는 꼭 아래 코드가 들어가야 한다.

<layout>
  <!-- 여기에 data -->
  <!-- 여기에 layout -->
</layout>

그렇지 않으면 gradle 설정 상태에 따라 ViewBinding을 허용하거나, findViewById만 허용된다.

결국 DataBinding을 쓰지 않는 클래스에서도 강제로 을 적용해야 한다는 점이다.

그럼 3가지 다 만들면 되는 것 아닌가? 가능은 하다. 선택지가 많아지면 많아질수록 실수할 확률은 높다. 그냥 첨부터 Base를 만들지 않고 알아서 쓰는 게 오히려 모두가 이해하기 쉽고 Lifecycle을 확인하기도 쉬워질 수 있다.


DataBinding과 ViewModel의 강제는

사실 필자는 맞지 않다고 생각한다.

  • ViewModel은 절대로 1:1 관계 만 있을 리 없다.
  • DataBinding이 항상 고정일 리 없다.

몇 없는 클래스를 만들거나 설계할 때는 충분히 편하고, 잘 활용하면 매우 좋다.

요런 것 때문에 Base를 만드는 건 아깝다는 생각이다. 차라리 필요한 중복 코드를 유틸로 잘 뽑아두는 게 더 유용한데, 이미 이들의 클래스는 모두 유틸로 만들어져있다.

DataBindingUtil, viewModel을 초기화하는 코드 역시 유틸로 만들어져있다. 이들을 조합해 사용하는 게 Activity이고 Fragment이다.

그걸 다시 한 번 더 감싸 만든게 BaseActivity/BaseFragment이다.

필자가 별로라고 생각하는 코드를 하나 만들어봤다.

abstract class BaseActivity<BINDING : ViewDataBinding, VIEW_MODEL: ViewModel> : AppCompatActivity() {

    protected lateinit var binding: BINDING
    protected abstract var viewModel: VIEW_MODEL

    @get:LayoutRes
    protected abstract val layoutRes: Int

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, layoutRes)
        binding.run {
            this.lifecycleOwner = this@BaseActivity
            this.setVariable(BR.viewModel, viewModel)
        }
    }
}

이 코드의 개선점은 무엇일까?

  1. DataBinding만을 위한 클래스로 바꾸는 게 좋아 보이고, 이름도 변경하는 게 좋다.
  2. ViewModel은 1개 이상이 만들어질 가능성은 0이 아니기에 이 코드에서는 제거한다.
  3. setVariable이 항상 BR.viewModel과 viewModel만을 처리할 이유는 없다.
  4. onCreate는 재 상속이 가능하다.

4 번의 문제는 여전히 존재하니 알고 넘어가자.

1 번의 문제를 언급한 이유는 이 클래스 이름이 명확해야 함이다. 선택지를 많이 만들 가능성이 농후하다. ViewBinding과 DataBinding, 이 둘도 필요 없는 경우가 분명 존재한다. 100% 하나만 쓸 일은 없다. 이런 선택은 각각이 구현하는 클래스에서 하는 게 더 좋지만 그래도 귀찮은 점이 있으니 Base를 잘 설계하는 걸 추천한다.

2 번의 문제는 그냥 지우는 걸 추천한다. 2번이 지워지면 자연스럽게 3번도 날아간다. setVariable을 통해 주입하는 이름이 항상 BR.viewModel이라는 전재는 없다. viewModel이 많아지면 1, 2, 3 케이스를 다 추가할 것인가? 그렇지는 않을 것이니 사전에 이런 설계를 하지 않는 걸 추천한다.

그렇다고 BR.viewModel 이름도 같이 받아보겠다고 하더라도, BR.viewModel과 viewModel은 쌍이어야 한다. key가 BR.viewModel인 것이고, 값이 viewModel인데 이 값이 항시 같은 식으로 쓰일 거라고 장담할 순 없다. 차라리 지금처럼 viewModel을 강제하는 대신 주석을 잘 보강하고 설명하는 걸 추천한다.


마무리

다양한 설계를 고민해 보는 게 좋지만 이런 문제들을 인지하고 설계하는 걸 추천한다.

  • Lifecycle을 꼭 지키도록 설계하자. 상속이라서 언제든 똑같은 클래스를 다시 상속받아 다른 방법으로 작성할 수 있다.
  • Presenter의 경우 1:1 관계를 유지할 수 있는 게 편하다.
  • ViewModel의 경우 N:N 관계를 생성할 수 있는 게 좋다.
  • 선택지가 많다면 그냥 기본 AppCompatActivity, Fragment를 상속받아 구현하도록 하는 게 좋다.
  • 공통 코드가 문제가 없다면 Delegation을 검토해보자.

만약 코루틴 스코프를 아래처럼 사용해야 한다면 Delegation을 만들어 사용할 수도 있다.

이 코드는 현재는 의미 없지만 과거에 작성한 코드를 가져왔다.

interface BaseCoroutineScope : CoroutineScope {
    val job: Job
    /**
     * Coroutine job cancel
     */
    fun releaseCoroutine()
}

실제 구현체는 아래와 같다.

class UICoroutineScope(
    private val dispatchers: CoroutineContext = Dispatchers.Main
) : BaseCoroutineScope {

    override val job: Job = SupervisorJob()

    override val coroutineContext: CoroutineContext
        get() = dispatchers + job

    override fun releaseCoroutine() {
        if (DEBUG) {
            Log.d("UICoroutineScope", "onRelease coroutine")
        }
        job.cancel()
    }
}

이를 사용할 때는 아래와 같다.

class MainActivity : AppCompatActivity(), BaseCoroutineScope by UICoroutineScope() {}

안드로이드에서 BaseActivity를 생성 시 가장 중요한 부분은 Lifecycle을 벗어나지 않고, 그냥 잘 정리하는 걸 추천한다.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments