24.02.25 FireBase로 채팅기능 만들기

KSang·2024년 2월 25일
0

TIL

목록 보기
71/101

이번시간엔 RealTimeDataBase를 이용해서 채팅기능을 만들어볼 예정이다.

데이터를 json으로 저장하고 모든클라이언트에게 실시간으로 데이터 변경사항을 자동으로 동기화해주기 때문에 채팅기능을 구현 할 수 있다.

채팅을 어떻게 구현할까.

채팅 방을 만들고 그안에 메세지들이 계속 생성될 것이다.

채팅방 데이터와, 메세지 각각의 데이터를 따로 관리해서 저장하면 될것이다.

Entity

data class ChatRoomEntity(
    val key: String = "CR_" + UUID.randomUUID().toString(),
    val title: String? = "",
    val participantsUid: Map<String, Boolean> = emptyMap(),
    val thumbnail: String? = "",
    val type: ChatTabType? = ChatTabType.GENERAL,
    val registerDate: String = LocalDateTime.now().convertLocalDateTime(),
)
data class MessageEntity (
    val key: String = "MSG_" + System.currentTimeMillis().toString() + "_" + UUID.randomUUID().toString(),
    val chatRoomId: String = "",
    val text: String? = "",
    val authId: String? = "",
    val viewUsers: List<String> = emptyList(),
    val registerDate: String = LocalDateTime.now().convertLocalDateTime(),
)

채팅방은 채팅방의 식별자가 있고, 채팅방명, 멤버들의 아이디, 대표하는 이미지, 1:1채팅인지 그룹 채팅인지 구별을 해주게 만들었다.

메세지는 키, 소속된 채팅방 Id 메세지 내용 , 작성자의 Id, 등록시간 그리고 읽음 표시를 처리하기 위해 메세지를 읽은 유저를 리스트로 저장해 (채팅방 전체 멤버수 - 메세지를 읽은 멤버수)로 메세지를 안읽은 유저가 몇명인지 처리를 하게 만들었다.

Repository

class ChatRepositoryImpl @Inject constructor(
    private val db: FirebaseDatabase,
    private val firestore: FirebaseFirestore,
) : ChatRepository {

    override suspend fun createChatRoom(chatRoom: ChatRoomEntity) {
        db.reference.child(DataBaseType.CHATROOM.title).child(chatRoom.key).setValue(chatRoom)
    }

우선 채팅방을 만들어주자

리얼타임데이터 베이스에선 child를 사용해서 데이터를 저장한다.


콘솔에서 보면 이런 모양으로 저장되는걸 볼수 있다.

이제 메세지를 보내는 함수를 만들자

    override suspend fun sendMessage(message: MessageEntity) {
        val messageRef = db.reference.child(DataBaseType.MESSAGE.title).child(message.chatRoomId).push()
        messageRef.setValue(message.copy(key = messageRef.key!!))
    }

push를 이용해 메세지의 키를 생성하고 저장한다.

entity에서 따로 키를 만들었었는데, 그렇게 될경우 순서가 뒤죽박죽으로 섞이게 되어서 메세지 배치가 이상하게 된다.

리얼타임데이터 베이스에선 push를 이용해 자동으로 키를 생성해주는데, 이렇게되면 메세지의 키값이 순서대로 정렬되어 push를 이용해 메세지를 만들고
키값을 데이터에 저장한뒤 setValue로 메세지에 값을 넣어준다.

다음으로 메세지의 변화를 실시간으로 감지할 함수를 만들자

    override suspend fun observeNewMessage(
        chatRoom: ChatRoomEntity,
        callback: (MessageEntity) -> Unit
    ) {
        updateChatParticipants(chatRoom)
        db.reference.child(DataBaseType.MESSAGE.title)
            .child(chatRoom.key)
            .addChildEventListener(
                object : ChildEventListener {
                    override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
                        snapshot.getValue(MessageEntity::class.java)?.let { callback(it) }
                    }
                    override fun onChildChanged(snapshot: DataSnapshot,previousChildName: String?) = Unit
                    override fun onChildRemoved(snapshot: DataSnapshot) = Unit
                    override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) = Unit
                    override fun onCancelled(error: DatabaseError) {
                        Log.e("observeNewMessages", "ERROR: $error")
                    }
                })
    }

리얼타임 데이터 베이스에선 리스너를 통해서 데이터 변화를 보내준다.

addChildEventListener를 이용해서 최근에 데이터가 변하면 변한값만을 감지한다.

onChildAdded로 추가된 메세지만 감지하게 만들었다.

UseCase

채팅방을 만들때 매번 새로운 채팅방이 생기는건 이상하다.

예를 들어 내가 A랑 대화를 했는데, 다음에 A랑 다시 대화하려고 하면 채팅방이 이전 채팅방이 아닌 새로운 채팅방이 생기게 된다면 이상할 것 이다.

채팅방에 인원수를 파악하고 서로의 uid가 있는지 확인하는 방식은 시간도 오래걸리고 같은 구성원으로 이루어진 새로운 그룹을 만든다고하면 채팅방이 새로 생성되지않을 것이다.

그래서 난 키값을 설정해서, 새로운 채팅방이 계속 생기지 않게 만들었다.

suspend operator fun invoke(
        uid: String,
        type: ChatTabType = ChatTabType.GENERAL
    ): ChatRoomEntity {
        val currentUid = authRepository.getCurrentUser().message
        val newChat = ChatRoomEntity(
            key = "CR_" + listOf(uid, currentUid).sorted().joinToString(""),
            participantsUid = mapOf(uid to true, currentUid to true),
            type = type
        )
        CoroutineScope(Dispatchers.IO).launch {
            val userDeferred = async { userRepository.getUserDetails(uid) }
            val myDeferred = async { userRepository.getUserDetails(currentUid) }

            userDeferred.await().onSuccess {
                if (it != null && it.participantsChatRoomIds?.contains(newChat.key)?.not() != false) {
                    userRepository.registerUser(
                        it.copy(
                            participantsChatRoomIds = it.participantsChatRoomIds?.plus(
                                newChat.key
                            )
                        )
                    )
                }
            }
            myDeferred.await().onSuccess {
                if (it != null && it.participantsChatRoomIds?.contains(newChat.key)?.not() != false) {
                    userRepository.registerUser(
                        it.copy(
                            participantsChatRoomIds = it.participantsChatRoomIds?.plus(
                                newChat.key
                            )
                        )
                    )
                }
            }
            this.cancel()
        }
        val chatRoom = chatRepository.getChatRoom(newChat.key)
        return if (chatRoom == null) {
            chatRepository.createChatRoom(newChat)
            newChat
        } else {
            chatRoom
        }
    }

여기선 자신과 다른 유저의 uid를 정렬시킨뒤 채팅방을 나타내는 CR_뒤에 두 유저의 키를 합쳐서 넣었다.

이렇게 되면 상대방아이디로 들어오나 본인 아이디로 들어오나 채팅방이 새로 생길일이 없고 항상 같은 채팅방으로 입장하게 될 것이다.

그룹 같은 경우는 더 간단했다.

그룹마다 고유의 키값이 있으니, CR_뒤에 그룹 고유의 키값을 그대로 넣었다.

    suspend operator fun invoke(
        key: String,
        uids: List<String>,
        title: String,
        type: ChatTabType = ChatTabType.PROJECT,
        thumbnail: String?
    ): ChatRoomEntity {
        val newChat = ChatRoomEntity(
            key = "CR_$key",
            title = title,
            participantsUid = uids.associateWith { true },
            thumbnail = thumbnail,
            type = type
        )
        CoroutineScope(Dispatchers.IO).launch {
            uids.forEach {
                launch {
                    userRepository.getUserDetails(it).getOrNull().let {
                        if (it != null && it.participantsChatRoomIds?.contains(newChat.key)
                                ?.not() != false
                        )
                            it.copy(
                                participantsChatRoomIds = it.participantsChatRoomIds?.plus(key)
                            )
                                .let { it1 -> userRepository.registerUser(it1) }
                    }

                }
            }
            chatRepository.createChatRoom(newChat)
            this.cancel()
        }
        return chatRepository.getChatRoom(newChat.key) ?: newChat
    }

채팅방을 설정해주고 각 유저의 채팅방 참여 목록에 각 채팅방의 아이디를 넣어주는 것도 잊지 않았다.

각 채팅방에서 유저들이 포함된 값을 보고 데이터를 가져올수도 있겠지만, 속도와 효율 가입되지 않은 채팅방에서 데이터를 보낼때 감지하기 위해 유저 각각의 아이디에 참여중인 채팅방의 id를 넣어놨다.

중간에 코드를 잘못 설정해서 메세지가 8.4만개가 쌓인 이슈가 있었다....

리스너를 잘 설정하고 나중에 큰일 날 수 있으니 조심하자,,

0개의 댓글