잡담

앱을 리팩토링 하기 이전에는 Firebase의 onDataChanged의 콜백을 잘 몰랐습니다. 그로인해 Firebase에서 데이터를 불러온 후 SQLite DB로 다시 담아온 후 데이터를 다시 불러와서 어댑터와 연결하는 미친코드를 작성했었습니다.

특정한 디자인 패턴도 없었던 터라 UI 구성요소에 모든 코드를 때려박으면서 데이터의 조회가 필요한 UI에 Firebase에서 파일을 읽어와 SQLite에 저장하고, 그 SQLite 데이터를 다시 읽어와 배열에 저장하고 어댑터와 연결하는 코드 를 마구잡이로 붙여넣곤 했습니다. (어떻게 갤럭시 스토어 앱 심사가 통과되었으며, 어떻게 300명의 회원이 이 앱을 문제 없이 사용한건지 의문이면서 죄송스런 마음이 드네요..)

아래 코드는 앞서 말한 문제가 많은 코드입니다.

// 믿기 힘들겠지만 한 프래그먼트 내의 코드입니다.

// Firebase에서 파일을 읽어와 SQLite에 저장하고, 그 SQLite 데이터를 다시 읽어와 배열에 저장하고 어댑터와 연결하는 코드.
private fun firebaseUpdate() {
        val progressDialog = ProgressDialog(context)
        progressDialog.setTitle(getString(R.string.syncData))
        progressDialog.setCancelable(false)
        progressDialog.show()
        /** SQLite DB 선언  */
        sqLiteManager = SQLiteManager(context, "writeYourThink.db", null, 1)
        database = FirebaseDatabase.getInstance()
        /** 파이어베이스 데이터베이스 연동  */
        databaseReference = database!!.getReference(userName!!)
        /** DB 테이블 연결  */
        databaseReference!!.addListenerForSingleValueEvent(object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                /** 파이어베이스 데이터베이스의 데이터를 받아오는 곳  */
                for (snapshot in dataSnapshot.children) {
                    /** 반복문으로 데이터 List를 추출해냄  */
                    val diary = snapshot.getValue(Diary::class.java)
                    /** 만들어뒀던 User 객체에 데이터를 담는다.  */
                    if (diary!!.date != null) {
                        date = diary.date
                        location1 = diary.location
                        with1 = diary.where
                        contents1 = diary.contents
                        profile1 = diary.profile
                        userUID1 = diary.userUID
                        /** Firebase DB 데이터를 불러오자마자 바로 SQLite DB에 삽입  */
                        sqLiteManager!!.insert2(
                            userUID1,
                            with1,
                            contents1,
                            profile1,
                            date!!.substring(0, 10),
                            date!!.substring(11, 19),
                            if (location1 == " " || location1 == null) " " else location1
                        )
                    }
                }
                val controller = LayoutAnimationController(set, 0.17f)
                binding.rv.layoutAnimation = controller
                updateList()
                progressDialog.dismiss()
            }
            override fun onCancelled(databaseError: DatabaseError) {
                // 디비를 가져오던중 에러 발생 시
                Log.e("MainActivity", databaseError.toException().toString()) // 에러문 출력
            }
        })
    }

// SQLite 데이터를 읽어와 배열에 담고, 순서대로 어댑터와 연결하는 코드...
private fun updateList() {
    idIndicator.clear()
    matchtitle.clear()
    matchContents.clear()
    matchAddress.clear()
    matchProfile.clear()
    matchdate.clear()
    matchtime.clear()
    matchID.clear()
    diaryAdapter!!.removeItem() // ListView 내용 모두 삭제
    val array = sqLiteManager!!.getResult(binding.tvDate.text.toString()) // DB의 내용을 배열단위로 모두 가져온다
    try {
        val length = array.size // 배열의 길이
        for (idx in 0 until length) {  // 배열의 길이만큼 반복
            val `object` = array[idx] // json의 idx번째 object를 가져와서,
            val userName = `object`.getString("userName") // object 내용중 id를 가져와 저장.
            val id = `object`.getString("id") // object 내용중 id를 가져와 저장.
            val title = `object`.getString("title") // object 내용중 id를 가져와 저장.
            val contents = `object`.getString("contents") // object 내용중 contents를 가져와 저장.
            val profile = `object`.getString("profile") // object 내용중 profile를 가져와 저장.
            val date = `object`.getString("date") // object 내용중 date를 가져와 저장.
            val time = `object`.getString("time") // object 내용중 time을 가져와 저장.
            val address = `object`.getString("address")
            matchID.add(id)
            matchAddress.add(address)
            matchContents.add(contents)
            matchProfile.add(profile)
            matchdate.add(date)
            matchtime.add(time)
            idIndicator.add(id)
            matchtitle.add(title)
            if (Locale.getDefault().isO3Language == "eng") {
                diaryAdapter!!.addItem(
                    Diary(
                        userName, profile,
                        "At a $title, $address",
                        contents,
                        date.substring(0, 4) + "-" + date.substring(5, 7) + "-" +
                                date.substring(8) + "-" + "(" + time + ")", ""
                    )
                )
                binding.rv.adapter = diaryAdapter
            } else {
                // 저장한 내용을 토대로 ListView에 다시 그린다.
                diaryAdapter!!.addItem(
                    Diary(
                        userName, profile,
                        if (address == " " || address == null) title + "에서.." else "의 " + title + "에서..",
                        contents,
                        date.substring(0, 4) + "년 " + date.substring(5, 7) + "월 " +
                                date.substring(8) + "일" + "(" + time + ")", address
                    )
                )
                binding.rv.adapter = diaryAdapter
            }
        }
    } catch (e: Exception) {
        Log.i("Lee", "error : $e")
    }
    diaryAdapter!!.notifyDataSetChanged()
}

혹시 억소리가 나오셨나요? 정상입니다.
문제는 이 코드를 여러 군데의 UI 컴포넌트에 복사 붙여넣기 하면서 쓰고 있었다는 사실입니다.

여러 회사에 입사 지원을 하면서 왜자꾸 서류에서 떨어지나 싶었는데 역시 코드에 마구니가 꼈군요.

각설하고 이렇게 구조가 잡혀있지 않고 중구난방했던 코드를 MVVM 패턴으로 리팩토링을 해보았습니다.

MVVM

그림은 MVVM 패턴의 구조인데요, DB의 data나 API의 response가 ViewModel을 통해 View로 전달되려면 Repository 라는 곳에서 데이터를 받아서 ViewModel로 전달해주는 과정이 필요합니다.

Repository 사용

그래서 Repository가 무엇인지 코드로 알려드리자면 다음 코드와 같이 Firebase의 데이터를 LiveData 형태로 ViewModel에 전달해주는 것입니다. 다음 코드는 Firebase의 onDataChange 콜백 함수를 이용하여 데이터를 실시간으로 읽어와서 LiveData 형태로 보내주는 코드입니다.

class DiaryRepository(
    val databaseReference: DatabaseReference,
) {

    fun getFirebaseData(
        mutableLiveData: MutableLiveData<MutableList<Diary>>,
        count: MutableLiveData<HashMap<String, Int>>
    ) {
        databaseReference.addValueEventListener(object : ValueEventListener {
            override fun onDataChange(snapshot: DataSnapshot) {
                val diaryList = mutableListOf<Diary>()
                val itemCount = HashMap<String, Int>()
                for (snapshot in snapshot.children) {
                    val diary = snapshot.getValue(Diary::class.java)
                    diaryList.add(diary!!)

                    if (!diary.date.isNullOrEmpty()) {
                        val dateYMD = diary.date.substring(0, 10)
                        if (!itemCount.containsKey(dateYMD)) {
                            itemCount[dateYMD] = 1
                        }
                        else {
                            itemCount[dateYMD] = itemCount[dateYMD]!!.plus(1)
                        }
                    }

                }
                mutableLiveData.value = diaryList
                count.value = itemCount
                Log.d("Lee", "Data Changed ${count.value}")
            }

            override fun onCancelled(databaseError: DatabaseError) {
                // 디비를 가져오던중 에러 발생 시
                Log.e("firebase", databaseError.toException().toString()) // 에러문 출력
            }
        })
    }
}

ViewModel에서 Repository에 데이터 요청

이제 ViewModel에서 위에 작성했던 Repository를 통해 데이터를 요청하고 받아야합니다. 다음 코드는 Repository를 통해 다이어리 데이터와 날짜별 데이터의 개수를 담아내는 LiveData 를 받아오는 코드입니다.

class DiaryViewModel(
    val diaryRepository: DiaryRepository
) : ViewModel() {
    var _diaryData = MutableLiveData<MutableList<Diary>>()
    var _countDiaryContents = MutableLiveData<HashMap<String, Int>>()

	val diaryData get() = getData()
	val countDiaryContents get() = _countDiaryContents
    
    private fun getData(): MutableLiveData<MutableList<Diary>> {
        diaryRepository.getFirebaseData(diaryData, countDiaryContents)
        return diaryData
    }
}

LiveData 프로퍼티를 위와 같이 노출하는 이유는 내부에서는 Mutable한 데이터를, 외부에서는 Immutable하게 제약을 주기 위함입니다. 위와 같은 코드를 작성한다면 외부 클래스에서 뷰모델의 데이터 상태변경을 막을 수 있습니다.

View에 Observer를 두어 UI 데이터 실시간 갱신

이제 View에 onChanged() 콜백 함수를 정의하는 Observer 객체를 인스턴스화 하여 LiveData의 변경사항에 대한 처리를 하면 됩니다.

그 전에, ViewModel에서 Repository를 참조할 수 있도록 ViewModelProviderFactory 클래스를 다음과 같이 작성해줍니다.

class DiaryViewModelProviderFactory(
    val diaryRepository: DiaryRepository
) : ViewModelProvider.Factory {
    override fun <T: ViewModel?> create(modelClass: Class<T>): T {
        return DiaryViewModel(diaryRepository) as T
    }
}

그 다음 메인 Activity 에서 ViewModel을 정의해줍니다.

class DiaryActivity : AppCompatActivity(), BottomSheetDialogFragment.BottomSheetListener {

    private lateinit var databaseReference: DatabaseReference

    lateinit var viewModel: DiaryViewModel
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 일부 코드 생략.
        val firebaseRepository = DiaryRepository(databaseReference)
        val viewModelProviderFactory = DiaryViewModelProviderFactory(firebaseRepository)
        viewModel = ViewModelProvider(this, viewModelProviderFactory).get(DiaryViewModel::class.java)
        })
    }
}

이제 ViewModel을 사용하고자 하는 프래그먼트에서 다음과 같이 뷰 모델을 선언하고, Observer객체를 선언하여 onChange 콜백 메서드를 정의해줍니다. 다음 코드는 ViewModel을 이용하여 데이터를 날짜별로 필터링하여 보여주는 코드입니다.

viewModel = (activity as DiaryActivity).viewModel

viewModel.selectedDateTime.observe(viewLifecycleOwner) {
    viewModel.setFilter()
    binding.tvDateAndTime.text = it
}
viewModel.filteredList.observe(viewLifecycleOwner) {
    diaryAdapter.differ.submitList(it)
}
viewModel.getData().observe(viewLifecycleOwner, Observer { diary ->
    // 데이터가 변경되면 filterlist를 바꿔주어야한다.
    viewModel.setDate(binding.tvDateAndTime.text.toString())
    viewModel.setFilter()
    hideProgressBar()
})

이렇게 MVVM 구조로 코드를 짜게 되면 ViewModel의 데이터를 모든 UI 컴포넌트가 공유할 수 있을 뿐더러, repository로 인한 코드의 재사용성 또한 높아집니다. 또한 앱의 구성 변경(화면 회전 등)이 발생하였을 때, 최근 UI데이터를 자동으로 갱신시켜주는 등의 장점이 많은 디자인 패턴입니다.

리팩토링을 통하여 디자인패턴에 대해 더 자세하게 이해한 계기가 되었네요.

0개의 댓글