회고록/[Nexters]

[Nexters] AZ - 아재트(아재 개그 앱) 프로젝트 회고

NukeOlaf 2020. 9. 9. 03:13

아재트 프로젝트가 마무리된지는 2주정도 지났는데 그동안 복학 및 개강 준비로 정신이 없었어서 회고록을 이제야 올린다. 일단 결론부터 얘기하자면, 아재트는 안드로이드, ios 모두 정식 출시되었다! 야호

홍보 겸 아재트 안드로이드 플레이스토어 링크를 첨부한다.

play.google.com/store/apps/details?id=com.az.youtugo

 

아재트 - AZ - Google Play 앱

커피우유가 모기에 물리면? 정답은 "커우유"입니다. "피"를 빨렸기 때문이죠! 이런 아재개그를 몸서리치게 싫어하시는 분들도 있을겁니다!ㅠㅠ 하지만 방금 개그를 듣고 빵 터지신 분들도 있을��

play.google.com

 

0. 인트로

"인간은 허구를 상상하는 능력 덕분에 유연하게 협력할 수 있는 유례없는 능력을 가질 수 있었다."

내가 좋아하는 책인 유발 하라리의 "사피엔스"에 나오는 내용이다. 나는 팀 프로젝트가 좋다. 팀프로젝트가 이루려는 결과물이 실체로 나타나기 전까지 우리는 허구의 성취를 상상할 뿐이다. 또한, 팀프로젝트가 언제나 예상하는대로 흘러가고 성공하지는 않는다. 그러나 모든 팀원이 하나의 같은 목표를 상상하고 유연하게 협력하여 일을 이루어나가는 과정 그 자체만으로 팀프로젝트는 가치가 있다.

넥스터즈는 "IT 서비스의 출시" 라는 목표를 상상한다. 그리고 그것을 이루기 위해 디자이너와 개발자가 팀으로 모여 협력한다. 나는 넥스터즈에서 여러 사람들과 일하며 "같이 일하고 싶은 개발자"에 좀더 가까워질 수 있다고 생각했다. 그리고 개발적으로도 좀 더 시야를 넓히고 성장하고 싶었다.

나는 Metaler 를 출시한 뒤 안드로이드 개발에 대한 자신감으로 가득 차 있었다. 오직 Activity 에 코드를 몇백줄, 몇천줄씩 적던 과거를 지나 MVP 아키텍쳐를 적용하고, Repository 패턴을 이해하고, MVVM 으로 코드를 리팩토링 해보면서 나 스스로 실력이 많이 늘었다고 생각했다.

그러나 내가 스승님이라고 부르게 된 14기 선배 개발자님과 같이 일하면서 내가 알던 지식은 아주 작은 것이었구나를 깨닫고 겸손해졌다.

 

1. 멀티모듈을 시도하다

우리는 이번 프로젝트에서 "멀티 모듈"을 시도해보았다. 멀티 모듈이란, 어플리케이션을 각 기능 또는 레이어 별로 모듈화시켜서 개발하는 방법이라고 할 수 있다. 어플리케이션을 제대로 모듈화 해놓으면 추후에 코드를 수정 또는 재사용하거나, 새로운 기능을 추가할때 매우 편리하다. 즉, 유지보수가 용이해진다. 코드 사이에 의존성이 매우 낮아지기 때문이다. 또한, 모든 모듈을 동시에 빌드할 수 있기 때문에 빌드속도가 빨라진다는 장점이 있다.

그러나 멀티모듈을 처음 시도해보았기에 많은 난관들에 부딪혔다. 첫 번째로는 buildSrc 모듈로 버전을 관리하기로 했는데 그래들 6.0 버전부터는 settings.gradle 이 buildSrc 모듈보다 먼저 컴파일 되어서 settings.gradle 에서 buildSrc 를 참조하지 못하는 이슈가 있었다. 해당 이슈는 그래들을 5.6 버전으로 다운그레이드 하면서 해결할 수 있었다.

그 외에도, app 모듈의 drawable 리소스를 다른 모듈에서 개발 시점에는 참조할 수 있지만, 빌드 시점에서는 참조하지 못하는 이슈도 있었다. 해결 방법은 모듈마다 필요한 drawable 리소스를 넣어주는 것이었다. 그런데 이때, 다른 모듈에 있는 리소스라 하더라도, 이름이 같은 경우는 signed apk 빌드시 에러가 발생하여 apk 빌드가 불가능하므로, 이를 주의해야한다.

그 외에도 서브 모듈을 Dynamic Feature Module 로 만들어야할지, Android Library 로 만들어야할지에 대한 고민이 있었다. 모델과 레포지토리 레이어의 경우에는 Android Library 로 만들었고, 앱의 기능(Feature)과 관련된, 특히 UI가 존재하는 모듈의 경우에는 DFM 으로 만들었다. DFM 을 런타임시에 설치하는 것으로 할 지, 앱 인스톨 시점에 설치하는 것으로 할지에 대해서도 생각해야 했다. 우리는 DFM 을 런타임시 설치되는 것으로 했었는데, 이렇게 하니 안드로이드 스튜디오로 테스트 할때는 문제가 없었지만, 플레이스토어에 배포 후 다운받았을때는 DFM 모듈 설치가 완료되지 않는 이슈가 있었다. 이 문제는 DFM 모듈을 앱 인스톨 시점에 설치하는 방식으로 변경하니 해결되었다.

 

2. 바텀 시트를 몰라서 스티키 헤더 방식으로 구현하고자 했던 눈물의 똥꼬쇼

나는 메인 페이지 레이아웃에서 개그목록을 스크롤 했을때, 개그 목록 윗부분이 화면 상단에 붙으면 그 상태에서 개그 목록 윗부분은 움직이지 않고 게시물 카드들만 스크롤 될 수 있도록 구현해야했다.

메인화면의 스크롤 전과 후

나는 처음에 이 화면을 웹에 있는 Sticky Header 개념으로 해결하려고 했다.

즉, 아래사진처럼 개그 목록의 타이틀과 명예의 전당 버튼이 있는 부분을 Header 로 처리하여, rootview 의 0,0 에 위치하는 순간부터는 HeaderView 는 더이상 스크롤 되지 않고 상단에 고정되며, 개그 목록 아이템들만 스크롤이 되는 것이다.

내가 Sticky Header 라고 생각했던 부분

그래서 이 Sticky Header 를 안드로이드에서 어떻게 구현할 수 있을지 찾아봤다. 그러나 안드로이드에서 기본적으로 제공하는 View 로는 Sticky Header 를 구현할 수 있는 방법을 찾지 못했다. Collapsing Toolbar 로 유사한 기능을 구현할 수는 있지만, Collapsing Toolbar 로 구현하는 방법은 내가 원하는 Sticky header 와는 조금 다른 기능이었다. Collapsing Toolbar 는 Toolbar 가 열렸다가, 닫혇다가 하는 개념이여서, 아래 이미지에 나온부분을 전부다 Toolbar 로 처리해야 했다. 그래서 구글링 하다가 찾은 Sticky Scroll View 구현 관련 블로그의 코드를 복사 + 붙여넣기 해봤다. (Sticky Scroll View 블로그 링크 : https://deque.tistory.com/140)

그러나, 가져다 붙인 코드는 기능은 작동하기는 했지만, 많이 불안정했고, 리사이클러뷰의 마지막 아이템이 씹혀서 보이는 버그가 존재했다. 그러나 내가 작성한 코드가 아니기 때문에 코드를 전부 파악하지 못해서 버그를 잡기가 어려웠다. 그러던 중, 위의 방법 외에 편법으로, Sticky Header 를 구현할 수 있는 방법을 알게 되었다.(https://medium.com/@saber.solooki/sticky-header-for-recyclerview-c0eb551c3f68) Header 부분을 RecyclerView 의 Item View 로 만들고, View Type 을 지정하여, header View Type 인 아이템이 rootview 상단에 위치하게 되면, onDrawover() 함수로 해당 Header View 를 똑같이 상단에 그려버리는 방법이었다. 이렇게 되면 리사이클러뷰 아이템들은 제대로 스크롤이 되고 있지만, 맨 상단에 Header View 가 또 새로 그려져 버리기 때문에, 사용자에게는 header View 가 상단에 붙어있는 것으로 인식되게 할 수 있다.

이렇게 Sticky Header View 를 구현하기 위해서 이런저런 고민을 하다가 수요일 회의날이 되었다. 회의때 헌진 스승님께 이 이슈에 대해 다시 공유하니, 내게 왜 꼭 Sticky Header 로 구현하려고 생각하냐고 물어봤다. 나는 그래서 제목 부분이 맨위에 붙어있어야 해서 Sticky Header 밖에 생각나지 않았다고 대답했다. 그랬더니 헌진 스승님이 본인이 이 화면을 구현해야한다면, Sticky Header 가 아니라 Bottom Slide View 를 이용해서 구현할 것 같다고 키워드를 던져주셨다. 구글링을 좀 더 진행해보니, Bottom Slide View 는 라이브러리로도 많이 구현되어있었으며, 안드로이드 material design 중 하나인 Bottom Sheet 라는 명칭이 좀 더 정확했다. Bottom Sheet 에 대해 더 알아보니 이게 내가 원하는 기능을 구현할 수 있는 방법인 것 같았다. 그래서 Bottom Sheet 를 공부해서 적용했더니 문제가 해결되었다!

이렇게 바텀 시트를 알았다면 간단히 해결되는 문제를 커스텀뷰를 만들어서 해결하려고 했던 과정을 통해 몇 가지 느낀점이 있다. 우선 첫 번째로, 정말 필요한때가 아니면 뷰를 커스텀하려고 들지 말자는 것이다. 이러한 형식의 View 는 나 말고도 다른 개발자들도 많이 필요로 할 것이다. 그렇기 때문에 이미 라이브러리로 기능이 나와있을 가능성이 높은데, 제대로 찾아보지 않고 내가 생각한대로만 구현하려고 해서 시간이 몇배로 들었다. 두 번째로는, 어떤 기능을 구현하기 전에 그 기능을 구현하는 방법이 내가 생각하는 것 이외에도 여러가지가 있을 수 있다는 것 알고, 그 기능을 구현하는 방법에 대해 조사하는 시간을 가져야한다는 것이다. 나는 몰랐지만 바텀 시트라는것은 흔히 알려져 있는 UI 였다. 그래서 조금만 검색해보면 바텀시트에 대해 나왔겠지만, 나는 이 UI 는 스티키 헤더 방식으로만 구현해야한다고 속으로 단정짓고 개발을 시작했기 때문에 뷰를 커스텀하여 구현하려는 잘못된 시도를 했다. 그리고 세 번째로는, 내가 아직 많이 부족하고 안드로이드에 대해 많이 모르기 때문에 공부를 게을리하지 말아야 겠다는 것이다.

3. 리사이클러뷰 무한 스크롤 기능 라이브러리화하기

아재트 안드로이드 앱은 커뮤니티이기 때문에 모든 데이터를 서버로부터 가져온다. 그래서 무한 스크롤 기능이 로그인/ 회원가입 부분을 제외한 거의 모든 페이지에 쓰였다고 해도 무방했다. 그래서 무한스크롤에 관련된 코드를 모듈로 라이브러리화하여 우리 프로젝트에서 재사용하기 쉽게 만들어보았다.

기존에 멀티 모듈 프로젝트 전의 Monolithic 앱 구조에서는 중복되는 코드들을 하나의 App 모듈안에서 클래스화하여 App 모듈안에서만 사용하는 방식으로 재사용했었다. 하지만 이번에는 무한 스크롤과 관련된 기능들을 모듈로 빼서 사용해보았다. 간단한 기능이라서 라이브러리화하는 과정에서 큰 어려움은 없었던 것 같다. 그런데 스승님이 작업하시던 마이페이지에서는 내가 만든 무한 스크롤 기능을 사용하기 까다로웠다고 하셨다. 내가 작업하던 페이지들은 리사이클러뷰의 아이템뷰 뷰타입이 한가지 밖에 없어서 리사이클러뷰 어댑터를 한개만 설정하도록 작업했는데, 스승님이 작업하시던 마이페이지는 뷰타입이 여러개 필요했기 때문이다.

어찌어찌 스승님께서 enum 으로 아이템뷰 코드를 구분하는 방식으로 문제를 해결하시긴 했다. 나는 이 이슈가 발생하기 전부터 내가 무한 스크롤 기능을 라이브러리화하긴 했지만, 재사용이 쉽게 제대로 코드를 분리했다고는 생각하지 않았다. 그러나 해당 이슈를 겪으면서 내가 이러한 부분에서 코드를 재사용하기 불편하게 분리했구나 생각했다. 또한, 이론이 아니라 실질적으로 재사용하기 쉽게 코드를 분리하는 방법에 대해 생각해보는 계기가 되었다.

더보기

무한 스크롤 기능 라이브러리 코드 발췌

abstract class InfiniteFragment<VM : InfiniteViewModel<ITEM>, ITEM : Any> : Fragment() {

    abstract val viewModel: VM

    protected fun setRecyclerViewScrollListener(view: RecyclerView) {
        view.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                val layoutManager = view.layoutManager
                if (viewModel.hasNextPage() && !viewModel.getIsLoading()) {
                    val lastVisibleItem = (layoutManager as LinearLayoutManager)
                        .findLastCompletelyVisibleItemPosition()
                    if (layoutManager.itemCount <= lastVisibleItem + 5) {
                        viewModel.loadMore()
                    }
                }
            }
        })
    }
}
abstract class InfiniteViewModel<ITEM : Any> : ViewModel() {
    protected val _items = MutableLiveData<List<ITEM?>>()
    val items: LiveData<List<ITEM?>> = _items

    private var isLoading = false

    abstract fun hasNextPage(): Boolean

    fun loadMore() {
        setItemLoadingView(true)
        getItems()
    }

    protected fun setIsLoading(isLoading: Boolean) {
        this.isLoading = isLoading
    }

    fun getIsLoading(): Boolean {
        return isLoading
    }

    protected abstract fun getItems()

    protected fun setItemLoadingView(isLoadingView: Boolean) {
        val list = items.value
        val nullPost: ITEM? = null
        if (list.isNullOrEmpty()) {
            return
        }
        if (isLoadingView) {
            _items.value = list.plus(nullPost)
            return
        }
        if (list[list.size - 1] != null) {
            return
        }

        _items.value = list.filterIndexed { index, _ ->
            index < list.size - 1
        }
    }

}
abstract class InfiniteAdapter<ITEM : Any> : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    companion object {
        const val VIEW_TYPE_ITEM = 0
        const val VIEW_TYPE_LOADING = 1
    }

    protected val items = mutableListOf<ITEM?>()

    fun replaceAll(list: List<ITEM>) {
        list.let {
            items.clear()
            items.addAll(it)
        }
    }

    override fun getItemCount(): Int {
        return items.size
    }

    override fun getItemViewType(position: Int): Int {
        return when (items[position]) {
            null -> VIEW_TYPE_LOADING
            else -> VIEW_TYPE_ITEM
        }
    }
}
@BindingAdapter("setItems")
fun setItems(view: RecyclerView, items: List<ExampleData>?) {
    (view.adapter as? InfiniteAdapter)?.run {
        items?.let { replaceAll(it) }
        notifyDataSetChanged()
    }
}

 

4. 그 외에 기억에 남는, 프로젝트를 하면서 배운 것들

그 외에도 프로젝트를 하면서 기억에 남는 것들이 많다. 기능을 구현하기 위해 새로이 공부한 아키텍쳐나 기능들에 대한 내용도 언젠가 시간이 나면 조금씩 정리해보려고 한다.

  • SAA(Single Activity Application)
  • Jetpack 라이브러리 - Navigation 을 사용해봄
  • Fragment 를 처음!! 사용해봄 (이전까지는 액티비티만 사용했었다)
  • buildSrc 를 이용해 그래들 버전을 관리하는 법에 대해 알게됨
  • Coroutine 을 처음 사용해봄
  • 고차함수를 이용해 MVVM 아키텍쳐를 좀더 깔끔하게 만들어봄
  • if return 문에 대해 알게 됨
  • enum class 에 대해 알게 됨
  • response handler 를 이용해서 통신 에러 핸들링해봄
  • 디렉토리 구조를 DDD 방식으로 정리해봄
  • two-way data binding 을 구현해봄

 

5. 팀원과 의견 충돌이 있었던 부분들

나는 아직 많이 부족하다. 안드로이드에 대해서도 아직 모르는 부분이 많이 있다. 그래서 모르기 때문에 끊임없이 질문하고, 질문에 대한 답을 얻기 위해 노력해야한다고 생각한다. 비슷한 맥락에서 나는 팀원과도 끊임없이 묻고 토론해야한다고 생각하는 주의이다(이런 내 생각이 항상 맞다고 생각하진 않는다. 하지만 팀 프로젝트를 하는 옳은 방법에 가깝다고 생각한다). 이것은 왜 이렇게 구현해야하나요? 이건 왜 코드를 이렇게 작성했나요?

이러한 부분에서 스승님과 나는 생각이 비슷했던것 같다. 그래서 우리는 바쁜 와중에도 정말 많은 코드 리뷰를 했다. 안드로이드에 대해 모르는게 생기는게 싫으신 안드로이드 쌉고인물 스승님인데도, 햇병아리같은 내가 거는 태클(?) 같은 질문들을 존중해주고 친절히 대답해주셨다. 솔직히 이러한 모습에서 스승님이 멋진 개발자라고 생각했고, 더 존경하게 되었다.

그 중에서도 스승님과 생각이 달라서 의견충돌이 있었던 이슈 두개가 기억에 남는다.

(1) splash 화면의 구현 방식에 대한 의견

나는 스플래시 화면을 액티비티로 구현하는 것이 좋지 않다고 생각한다. 스플래시 화면은 어플리케이션을 처음 실행할 때 앱이 구성되는 동안에만 보여주는 화면이다. 스플래시 화면을 만들지 않으면 앱 구성시간에 하얗고 빈 화면이 뜨게되는데, 이를 방지하기 위해서 만든 것이 스플래시이다. 그런데 액티비티로 스플래시 화면을 구현하면, 어플리케이션이 이미 실행된 이후에도 사용자에게 지정한 시간동안 강제로 스플래시를 보여주게 된다. 나는 이것이 "스플래시"의 본질적인 목적에 부합하지 않는다고 생각했다.

그래서 스승님이 android:windowBackground 와 액티비티 두 가지 방식으로 스플래시를 구현했을 때 부정적인 의견을 표했다. 한 가지 기능을 두 가지 방식으로 두 번 구현하는 것이 비효율적이고 이상한 방식이라고 생각할뿐만 아니라 스플래시를 액티비티로 구현하는 것 자체를 극도로 혐오했기 때문이었다.

스승님은 스플래시 액티비티가 앱 구성 시간을 채워주는 역할 뿐만아니라, 서비스의 로고를 인지시키는 기능이 있기 때문에 그러한 방식으로 구현했다고 하셨다. 요즘 스마트폰들은 기능이 워낙 좋아서 거의 0.1초 안에 스플래시 화면이 넘어가는데, 스플래시 화면을 이렇게 짧은 시간동안만 노출시키면 사용자에게 우리 서비스의 로고를 인지시키기 어려울 뿐만 아니라 오히려 로고에 대한 의문이 생길 수 있다는 것이 그 이유였다. 더군다나 우리 서비스에서는 스플래시를 보여줘야하는 니즈가 있고, 우리 로고에는 로고와 이미지가 복잡하게 있어서 사용자가 인식을 하려면 다소 시간이 필요하다고 하셨다.

앱 스플래시 화면에 띄우는 아재트 로고

나는 스승님이 스플래시 화면을 왜 그렇게 구현했는지 이유를 알고나자 마땅히 반박할 말이 없었다. 그래서 아직도 스플래시 화면을 액티비티로 구현하는 것은 나쁜 방식이라고 생각하지만, 우리 앱에서 액티비티로 스플래시를 구현하는 것에 대해서는 동의하게 되었다.

(2) 로그인 세션 저장방식에 대한 의견

나는 우리 앱의 아키텍쳐가 레포지토리 패턴을 따른다고 생각하고 있다. 그리고 로그인 세션 정보는 로컬에 저장되는 데이터(Local Data Source)이기 때문에 모델에 로그인 세션관련 코드를 위치해야한다고 생각했다. 그래서 스승님이 로그인 세션을 sharedPreference 로 구현했는데, 모델이 아니라 뷰 단에 위치시킨 것을 보았을 때 조금 의아했다. 그래서 스승님에게 적극적으로 내 의견을 표출했다.

혹시라도 코멘트가 공격적으로 느껴질까봐 최선을 다해 귀엽게 얘기하려고 발악하는 모습

이 부분은 스승님과 내가 개념적인 차이를 잘못 가져가서 생긴 해프닝이었다. 스승님은 로그인 세션 관련 sharedPreference 를 하나의 유틸처럼 생각해서 core 모듈로 올린다음 뷰 단에서 사용했다. 하지만 나는 sharedPreference 를 하나의 저장소로 봐서 mvvm 의 형식을 따라 어떤 저장소를 쓰더라도 동일한 결과를 내는 형식으로 생각을 해서 오해가 있었다. 

스승님의 생각도 충분히 맞지만, 내 생각도 틀린 얘기는 아니기 때문에 우리는 로그인 세션 구현 방식을 두 가지 방식 중 하나로 결정하기로 했다. 그래서 결론적으로는, 당장 고치기엔 시간이 조금 걸려서 그냥 놔두기로 했다. 지금 생각해봐도 이 문제는 정답이 없는 어려운 문제인것 같다. 스승님 말도 듣고보니 맞는 말 같은데, 나는 내가 생각한 방식도 틀렸다고는 생각하지 않는다. 내가 언젠가 이 회고록을 다시 읽을때는 어떤게 더 옳은 방법이었는지 판단할 수 있을까?

 

6. 아재트 프로젝트를 마치며,,,

다 쓰고보니 너무 안드로이드 개발에 관련된 기술적인 사건, 이슈들에만 집중에서 회고록을 작성한 것 같다. 안드로이드 개발 외적으로도 아재트 프로젝트를 진행하면서 정말 많은 일들이 있었다. 그 중에서 가장 좋았던 것은 팀원으로 정말 좋은 인연들을 만났다는 것이다. 끝까지 우리를 잘 이끌어준, 넥스터즈 마지막 세션 때 최종 발표를 하며 눈물이 그렁거렸던 우리 피엠님. 밤 늦게 API 문의해도 웃으면서^^ 받아주던 손흥민 닮은 서버개발자와 초록독버섯. ios 개발을 두 달 안에 혼자서 해내버린(심지어 RN을 이번에 처음써봤다고 함) 웹프론트 개발자. 엄청 이쁜 UI 를 만들어주신(개인적으로 17기 프로젝트 중에 우리 앱이 제일 디자인이 예쁘다고 생각) 디자이너 나래박 언니와 the better. 그리고 마지막으로 새벽에 나의 PR 폭행에도 절대 화내지 않고 옆에서 많은 도움과 가르침을 주신(자료구조 과외 잊지 않을께요-!) 모기 스승님까지. 너무너무 소중한 인연들이었다.

앞으로 우리 팀원들과 또 프로젝트할 기회가 있었으면 좋겠다. 팀원들에게도 내가 또 같이 일하고 싶은 개발자였을까? 프로젝트가 끝난지 벌써 2주가 지났다. 회고록을 쓰면서 그 때를 다시 떠올리니 마음이 조금 북받치는 느낌이다. 

 

아재트 프로젝트 깃허브 링크 : github.com/Nexters/AZ-Android

 

*막간을 이용한 자랑

넥스터즈 공식 홈페이지에도 아재트 앱이 소개되었다!!