Android/실전 회고

[Android] Firebase 기반 채팅 구현 적용기와 앞으로의 계획 (MVVM)

노소래 2022. 2. 26. 05:44

[발단]

  채팅 기능이 핵심이 되는 데이팅 앱 프로젝트를 맡게 되었다. 채팅을 어떻게 구현할지 다른 Ios, Server 담당 개발자 팀원과 많은 회의를 거쳤다. 주요 이슈는 채팅을 구현하는 여러 방법(유료 SDK, Firebsae, 자체 서버 등) 중에 Firebase 를 선택했다.

  그 이유는 매우 저렴하고(여기서 유료 SDK 탈락) 자체 채팅 서버를 제대로 구축할 시간을 벌 수 있어(여기서 자체 서버 탈락) 부담이 줄기 때문이다. 핵심기능을 중심으로 MVP 를 만들어 피드백을 받고 발전시켜야 하는 상황에서 Firebase(특히 Firestore) 로 먼저 서비스를 진행하며 자체 채팅 서버를 구축하고 채팅 서버 마이그레이션을 진행하자는 게 주 요지였다. 그렇게 채팅 메시지는 Firestore 로 관리하고 메시지 아이템 중 이미지와 오디오는 Firebase storage 로 결정했다. (자체 채팅 서버로 마이그레이션할 때는 소켓과 s3 이용 예정, 현재는 마이그레이션 과정 중에 있기에, 깃헙에 올라온 데이터베이스 key-value 부분이 조금 다를 수 있음. 후속 글로 이 과정도 꼭 작성할 예정!)   

  글을 읽는 도중 MVVM 패턴을 따르는 코드를 만날 수 있는데 아래 글 패키지 구조를 보면 더 쉽게 이해할 수 있을 것이다. 

 

[Android] 익명 데이팅 앱 '블라인드 카페' 에 MVVM Clean Architecture 적용기

[발단] 작년 9월 수익형 앱 런칭 동아리 CMC 에서 월 Android 앱 제작에 참여했다. 내가 고른 프로젝트는 블라인드 카페라는 기획이었다. (앱에 대한 설명은 아래 링크를 참고) GitHub - Blind-Cafe/BlindCafe-

nosorae.tistory.com

 

그리고 앱에 대한 설명은 아래 깃허브 링크를 보면 더 이해가 쉬울 것이다.

 

GitHub - Blind-Cafe/BlindCafe-AOS

Contribute to Blind-Cafe/BlindCafe-AOS development by creating an account on GitHub.

github.com

  또한 Firestore 에 대한 얘기를 계속 하게 될텐데, 이 글은 내가 채팅 기능을 구현할 때를 회고하며 어떻게 구조를 잡았고 개선할 점을 고민하는 글이다. 그래서 Firestore 가 처음이라면, 공식문서와 내가 요약정리한 노션링크를 보면 도움이 될 것 같아서 링크를 달아본다.

 

Cloud Firestore  |  Firebase Documentation

유연하고 확장 가능한 NoSQL 클라우드 데이터베이스를 사용해 클라이언트 측 개발 및 서버 측 개발에 사용되는 데이터를 저장하고 동기화하세요.

firebase.google.com

 

 

Cloud Firestore 정리

Cloud Firestore는 클라우드에 호스팅되는 NoSQL 데이터베이스 (SDK)

radical-cuticle-394.notion.site

 

[참고 - 기본 요구사항과 완성영상]

  채팅은 일대일을 기본으로 하고, 일대다 채팅을 염두하고 구조를 잡는다. 채팅 메시지는 Text, Image, Audio 를 기본으로하고, 대화의 윤활제인 토픽메시지(토픽 메시지 역시 Text, Image, Audio 존재), 그리고 안내 메시지 총 7 가지가 필요하다. 추가적으로 채팅방 나가기, 상대 유저 신고하기, Audio 시간 제한 등의 요구사항이 있지만 이번 글은 채팅적용기이므로 생략한다.

안내 메시지와 텍스트 전송
이미지, 음성 전송

 

[구현과정1 - Firestore 선택 이유와 데이터베이스 구조]

  먼저 볼 것은 이번에 채팅 기능을 구현할 때의 핵심이 되었던 Firestore 이다. Firestore 클라우드에 호스팅되는 NoSQL 데이터베이스이다.  참고로 Firebase 의 기존 데이터베이스였던 Realtime database 도 대신 Firestore 를 사용한 이유는 페이지네이션 구현이 더 수월하고, 채팅 관련 검색기능 같은 것이 생겼을 때 더 복잡한 기능을 수행할 수 있으며, 자동 스케일 확장, 오프라인 캐싱 지원 등이 있었다. 아래는 내가 구성한 1대1채팅을 위한 데이터베이스 구조이다.

(FYI. Message 컬렉션의 document 구성은 실제와 조금 다르다. 앞으로 업데이트할 때의 기준으로 수정한 내용이다. 글 읽는 데는 전혀 무관)

<Path>

  위에서 확인할 수 있듯이 구조는 collection 과 documet 의 연속이다. collection 은 여러 document 를 가질 수 있고 document 는 key-value 쌍을 까지고 추가로 하위 collection 을 가질 수 있다. (이 글에선 구현 회고이기 때문에 Firestore 의 더 자세한 설명은 생략한다.)

  최상단에 "Rooms" 이라는 collection 이 있고 그 안에 "Room" 이라는 document 들이 존재한다. 매칭이되고 서버에서 Room 의 id 를 주면 채팅방에 입장할 때 환영메시지와 함께 Room document 가 만들어진다.

  Room document 의 path 가 곧 Room 의 id 로 설정했다. 이유는 자동생성과 key-value 로 놓을 때는 쿼리를 넣어야겠지만 path 를 id 로 설정하면 바로 접근하기 위할 수 있기 때문이다. 공간 시간 모두 이득이라고 생각했다.

팀원과 공유했던 database 구조

<key-value>

Message collection 의 각 document 가 가진 key-value 의 기능도 알아보자

  • contents
    이 곳에는 문자열 메시지라면 그 메시지 내용이 들어간다.
    이미지, 오디오라면 stroage 에 올릴 때 사용한 id 가 들어간다. (이 부분이 개선할 점 중에 하나이다. id 가 아닌 url 을 넣는 것이 더 효율적임을 나중에야 깨달았다.)
    그리고 안내메시지도 여기에 안내 내용이 들어간다.
  • userId
    메시지를 전송한 유저의 유저 id 이다.
    이걸로 왼쪽 오른쪽을 구분한다.
  • type
    여러 메시지 형태를 구분하는 값이다. 
    7가지마다 각기 다른 뷰를 가지고 있기 때문에 메시지마다 타입 값을 가지게하였다.
  • timestamp
    firestore 에서 자동으로 생성해주는 타임스탬프 값이다.
    이 값으로 정렬하여 가져온다.
    페이지네이션할 때도 이 값을 기준으로 하게된다.

<console 에서 확인하기>

 

타입 7로 안내메시지를 나타낸다.

<Storage>

  • /image → id 는 currentTimeMillis().toString()+유저id → UID
  • /audio → id 는 currentTimeMillis().toString()+유저id → UID
  • /profile_image → id 는 currentTimeMillis().toString()+유저id → UID

 

[구현과정2 - MVVM 패턴에 적용하기(Data layer)]

  Firestore 를 MVVM 패턴에 적용할 때 어려웠던 점은 data layer 에서 발생했다. 왜냐하면 domain 코드는 다른 MVVM 코드와 다를 것이 없기 때었는데 firestore 를 사용할 때는 snapShotListener 를 사용해야했기 때문이다. 고민하며 공부하다가 callback flow 가 존재한다는 사실을 알게 되었고 적용하게 되었다. 어쨌든 그래서 MVVM 패턴에 적용하기 섹션에서는 Repository 을 중점적으로 다루고 presentation 을 간략하게 설명하고 나머지는 생략하려고한다.  firestore 에 접근할 수 있는 객체를 주입받고 총 세 가지 기능을 담고 있다. 

  1. sendMessage
    메시지 전송시 사용한다.
  2. subscribeMessages
    addSnapshotListener 를 달아서 변화를 감지하고 새로운 메시지실시간으로 업데이트하는 데 사용한다.
    callbackFlow 를 사용하여 데이터를 쏴준다.
  3. receiveMessages
    현재시간보다 이전 메시지를 가져오는데 사용된다.
    페이지네이션을 기능을 위해 limit 을 사용하였다.

  새로운 메시지 구독과 이전메시지를 가져오는 기능을 나눈 이유는 페이지네이션을 위함이다. 페이지네이션은 기본적으로 현재시간보다 이전 메시지를 일정한 갯수로 가져와야하는데, 그 와중에 새로운 메시지를 실시간으로 받아와야하기 때문이다.

아래는 그 구현코드이다. 

interface FirestoreRepository {
    suspend fun sendMessage(message: Message): DocumentReference?
    suspend fun subscribeMessages(roomId: String): Flow<Resource<List<Message>>>
    suspend fun receiveMessages(roomId: String, lastTime: Timestamp): List<Message>
}
class FirestoreRepositoryImpl(
    private val firestore: Firestore,
) : FirestoreRepository {
    
    override suspend fun sendMessage(message: Message): DocumentReference? {
        return firestore
            .roomCollectionRef
            .document(message.roomUid)
            .collection(SUB_COLLECTION_MESSAGES)
            .add(message)
            .await()
    }
    
    @ExperimentalCoroutinesApi
    override suspend fun subscribeMessages(roomId: String): Flow<Resource<List<Message>>> =
        callbackFlow {
            val time = Timestamp.now()
            val subscription =
                firestore
                    .roomCollectionRef
                    .document(roomId)
                    .collection(SUB_COLLECTION_MESSAGES)
                    .whereGreaterThanOrEqualTo(TIME_STAMP, time)
                    .orderBy(TIME_STAMP, Query.Direction.DESCENDING)
                    .addSnapshotListener { snapshot, error ->
                        if (snapshot != null) {
                            val messages = mutableListOf<Message>()
                             snapshot.documentChanges.forEach { dc ->
                                 if (dc.type == DocumentChange.Type.ADDED) {
                                     messages.add(dc.document.toObject<Message>())
                                 }
                             }
                            trySend(Resource.Success(messages))
                        } else {
                            trySend(Error(error?.message ?: error.toString()))
                        }
                    }
            awaitClose {
                subscription.remove()
            }


        } as Flow<Resource<List<Message>>>

    override suspend fun receiveMessages(roomId: String, lastTime: Timestamp): List<Message> {
        return firestore
            .roomCollectionRef
            .document(roomId)
            .collection(SUB_COLLECTION_MESSAGES)
            .whereLessThan(TIME_STAMP, lastTime) // 인자보다 오래된 메시지 중에
            .orderBy(TIME_STAMP, Query.Direction.DESCENDING) // 최근 순으로
            .limit(CHAT_PAGE_SIZE) // 페이지 사이즈 만큼
            .get() // 가져와
            .await()
            .documents
            .map { doc ->
                doc?.let {
                    doc.toObject<Message>()
                }?: kotlin.run {
                    Message()
                }
            }
    }

}

 

[구현과정3 - Presentation layer]

  우선 채팅 뷰에서 주가 되는 라이브러리는 Groupie 이다. 뷰단에선 채팅 아이템이 10개(텍스트, 이미지, 오디오 메시지 유저 양쪽을 따로 제작해야해서 6개, 토픽도 텍스트, 이미지, 오디오 따로 존재해서 3개, 그리고 안내메시지 1개)나 있는 상황에서 ViewType 을 나눌 것을 생각하고 유지보수할 생각을 하니 비효율적이라고 생각했다. 안그래도 채팅 관련 코드 양은 넘쳐날 것이기 때문이다. 

  그래서 다양한 뷰를 가진 RecyclerView 를 깔끔한 코드로 관리할 수 있는 라이브러리를 찾게되었고 groupie 를 선택하게 되었다. star 수도 많고 리드미도 자세하고 깔끔하게 작성되어있다. 하단에 링크를 첨부한다.

 

GitHub - lisawray/groupie: Groupie helps you display and manage complex RecyclerView layouts.

Groupie helps you display and manage complex RecyclerView layouts. - GitHub - lisawray/groupie: Groupie helps you display and manage complex RecyclerView layouts.

github.com

 

  좌측상단 스크린샷처럼 아이템이 되는 xml 파일을 만들고, groupie 의 BindableItem 클래스를 만든다. 그리고 이 클래스를 Adapter 에 add 해주면 끝었다.

  물론 페이지네이션 기능과, 1분마다 메시지 뷰를 구분하는 (예를 들어 카톡에서 1분 간격으로 같은 분동안 보낸 메시지는 첫 메시지에만 프로필 사진과 이름이 보이고 나머지 메시지는 보이지 않는다. 그리고 마지막 메시지에만 시간이 보인다.) 기능도 구현했지만 그 내용은 다른 곳에서 다루려고 한다.  

 

[개선할 점]

  서버 개발자 분에게 유저 id 가 겹치지 않는다는 확인을 듣고 image, audio  의 id 를 현재시간 + 유저 id 조합으로 정했는데 이보다 더 안전하게 UID 를 생성하는 방식으로 개선하려고한다. (그런데 아마 자체 채팅 서버로 이전하면서 클라에서 처리할 일이 아니게 될 것 같다.) 

  Firestore 데이터베이스 Message document 의 key-value 중에 room id 가 껴 있는데, 공간을 조금이라도 아끼기 위해서 제거하는 게 더 좋을 것 같다.

  한동안 자체 채팅 마이그레이션 진행이 더뎠는데 (그래도 중간중간 회의하며 하이레벨한 관점에서의 설계를 진행했다.) 이제 끝나셨다니 자체 채팅 서버 연결할 생각하니 너무 기쁘다. 왜냐하면 Firestore 는 느리게 느껴졌기 때문이다. 이젠 안녕 Firestore, Firebase storage~