Language/Kotlin

[Kotlin] Socket.io 실시간 채팅 서비스 - 네이티브(AOS) 앱 프론트 (3)

aeeazip 2024. 9. 9. 14:06

목차


1. Socket.io에 대해

  • Socket.io 특징
  • Socket.io 그룹화 개념
  • Socket.io 핵심 메소드

2. 프로젝트 기본 설정

3. Express 및 Socket 설정 

4. 웹 프론트

  • 웹 프론트 구현
  • 웹 실시간 채팅 테스트

5. 네이티브 앱(AOS) 프론트

  • 앱 프론트 구현
  • 앱 실시간 채팅 테스트

6. 크로스 플랫폼(Flutter) 프론트

  • 크로스 플랫폼 프론트 구현
  • 크로스 플랫폼 실시간 채팅 테스트

 

 

 

 

 

 

대박티비 <- 다미한테 물들었네

 

 

이번 포스팅에서는 5번 앱 프론트에 중에서도 채팅방 목록 조회부터, 소켓을 활용한 채팅 수신 및 송신 과정만 다룰 예정이다. Socket.io를 활용한 서버 구축 방법이 궁금하다면 이전편을 참고하길 바란다.

https://aeeazip.tistory.com/58

 

[Node.js] Socket.io 실시간 채팅 서비스 - 서버 구축 (1)

목차1. Socket.io에 대해Socket.io 특징Socket.io 그룹화 개념Socket.io 핵심 메소드2. 프로젝트 기본 설정3. Express 및 Socket 설정 4. 웹 프론트웹 프론트 구현웹 실시간 채팅 테스트5. 앱(AOS) 프론트앱 프론트

aeeazip.tistory.com

 

 

 

 

 

 

5. 네이티브 앱(AOS) 프론트


1)  앱 프론트 구현

 

앱 프론트 구현 언어는 Kotlin, IDE는 Android Studio로 가장 최신 버전인 Koala(2024.1.1)를 사용했다.

먼저 통신을 위해 AndroidManifest.xml에 권한 설정 및 보안 설정이 필요하다.

 

▼ AndroidManifest.xml

// 권한 설정
<uses-permission android:name="android.permission.INTERNET" /> 
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

// 안드로이드 보안 설정
<application 
   	…
    android:usesCleartextTraffic="true"
    android:hardwareAccelerated="true">

 

 

서버에서 socket.io는 가장 최신 버전인 4.7.5를 사용하며, 이와 맞는 socket.io-client 버전은 2.X 버전이다.

앱 수준의 build.gradle에는 아래와 같은 설정이 필요하다.

 

① xml의 뷰와 변수에 빠르게 접근할 수 있도록 viewBinding을 활성화

② 서버 API와 통신을 위한 retrofit 의존성 주입

③ 소켓 통신을 위한 socket.io-client 의존성 주입

 

▼ build.gradle.kts (Module:app)

Android {
   …
   viewBinding {
        enable = true
    }
}

dependencies {
   …
   // Retrofit
    implementation ("com.squareup.retrofit2:retrofit:2.9.0")
    implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
    implementation ("com.github.bumptech.glide:glide:4.15.1")
    annotationProcessor("com.github.bumptech.glide:compiler:4.15.1")

    // Socket
    implementation ("io.socket:socket.io-client:2.1.1")
}

먼저 통신을 위한 Retrofit을 전역적으로 사용하기 위해 NetworkModule.kt 파일을 생성했다.

▼ NetworkModule.kt

package com.example.myapplication

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

const val BASE_URL = "http://192.168.1.200:3000/"

fun getRetrofit() : Retrofit {
    val retrofit = Retrofit.Builder().baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create()).build()

    return retrofit
}

 

 

다음은 실제로 채팅방 목록 조회 부분이다.

 

먼저 패키지 구조에 대해 설명하자면

채팅 관련 코드는 모두 chat 패키지 밑에 위치하고 있다.

 

ChatPost는 채팅 수신 및 송신 담당

ChatRoomListGet는 채팅방 목록 조회 담당

 

ChatPost, ChatRoomListGet 안의 data 패키지는 

Request, Response 등의 DTO*를 정의한 파일들의 집합이다.

 

Q. DTO란?
A. 데이터 전송 객체 DTO는 프로세스 사이에서 데이터를 전송하는 객체를 의미한다.

 

 

 

▼ chat/ChatRoomListGet/ChatRoomFragment.kt

package com.example.myapplication.chat.ChatRoomListGet

import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.myapplication.R
import com.example.myapplication.chat.ChatPost.ChatFragment
import com.example.myapplication.chat.ChatRoomListGet.data.ChatRoomList
import com.example.myapplication.databinding.FragmentChatRoomBinding

class ChatRoomFragment : Fragment(), ChatRoomListGetResult {

    private var _binding: FragmentChatRoomBinding? = null
    private val binding get() = _binding!!
    private lateinit var chatRoomAdapter: ChatRoomAdapter
    private val TAG: String = "ChatRoomFragment.kt"

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        _binding = FragmentChatRoomBinding.inflate(inflater, container, false)
        return binding.root
    }

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

        // RecyclerView 설정
        binding.chatRoomRecyclerView.layoutManager = LinearLayoutManager(requireContext())

        // API 호출
        val shared = requireContext().getSharedPreferences("userID", android.content.Context.MODE_PRIVATE)
        val userId: String = shared.getString("id", "empty").toString()
        val chatRoomListGetService = ChatRoomListGetService()
        chatRoomListGetService.setChatRoomListGetResult(this)
        chatRoomListGetService.getChatRoomList(userId) // SharedPreference에서 꺼내야 함

        // 어댑터 설정
        chatRoomAdapter = ChatRoomAdapter(arrayListOf()) { chatRoom ->
            onChatRoomSelected(chatRoom)
        }
        binding.chatRoomRecyclerView.adapter = chatRoomAdapter
    }

    // 채팅방 목록 조회 성공
    override fun getChatRoomListSuccess(respCode: Int, result: ArrayList<ChatRoomList>) {
        Log.d(TAG, "채팅방 목록 조회 성공")

        // 어댑터에 데이터 설정
        chatRoomAdapter.updateData(result)
    }

    // 채팅방 목록 조회 실패
    override fun getChatRoomListFailure(respCode: Int, respMsg: String) {
        Log.d(TAG, "채팅방 목록 조회 실패: $respMsg")
    }
    
    private fun onChatRoomSelected(chatRoom: ChatRoomList) {
        val chatFragment = ChatFragment.newInstance(chatRoom.cRoomId)
        requireActivity().supportFragmentManager.beginTransaction()
            .replace(R.id.fragment_container_view, chatFragment) // 'fragment_container'는 프래그먼트를 삽입할 컨테이너 ID
            .addToBackStack(null) // 백스택에 추가하여 뒤로가기 버튼으로 돌아올 수 있도록 설정
            .commit()
    }


    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

 

Fragment의 LifeCycle은

onCreate()onCreateView()  →  onViewCreated() →  onViewStateRestored() 순서이다.

 

ChatRoomFragment의  onViewCreated가 동작할 때 채팅방 목록 조회 API를 호출한다.

ChatRoomListGetResult 인터페이스를 구현하여, 채팅방 목록 조회 성공 시 chatRoomAdapter에 데이터를 넣어준다.

 

 

▼ chat/ChatRoomListGet/ChatRoomAdapter.kt

package com.example.myapplication.chat.ChatRoomListGet

import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.AdapterView.OnItemClickListener
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.chat.ChatRoomListGet.data.ChatRoomList
import com.example.myapplication.databinding.ItemChatRoomBinding

class ChatRoomAdapter(
    private var chatRooms: ArrayList<ChatRoomList>,
    private val onItemClickListener: (ChatRoomList) -> Unit
) : RecyclerView.Adapter<ChatRoomAdapter.ChatRoomViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatRoomViewHolder {
        val binding = ItemChatRoomBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ChatRoomViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ChatRoomViewHolder, position: Int) {
        holder.bind(chatRooms[position])
    }

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

    // 데이터 업데이트 메서드
    fun updateData(newChatRooms: ArrayList<ChatRoomList>) {
        chatRooms.clear()  // 기존 데이터 제거
        chatRooms.addAll(newChatRooms)  // 새 데이터 추가
        notifyDataSetChanged()  // RecyclerView에 변경 사항 반영
    }

    inner class ChatRoomViewHolder(private val binding: ItemChatRoomBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(chatRoom: ChatRoomList) {
            val userIdsUnion = chatRoom.createUserId + ", " + chatRoom.userList.joinToString(", ") { it.dataCode }
            binding.chatRoomIdTextView.text = "Chat Room ID: ${chatRoom.cRoomId}" // 채팅방 ID
            binding.userIdsTextView.text = "User IDs: ${userIdsUnion}"

            // 클릭 리스너
            binding.root.setOnClickListener {
                onItemClickListener(chatRoom)
            }
        }
    }
}

 

ChatRoomAdapter에서는 채팅방 목록 중 하나의 요소를 클릭했을 때 해당 채팅방에 입장할 수 있도록 ChatRoomViewHolder에 onItemClickListener를 구현해 두었다.

 

 

▼ chat/ChatRoomListGet/ChatRoomListGetRetrofit.kt

package com.example.myapplication.chat.ChatRoomListGet

import com.example.myapplication.chat.ChatRoomListGet.data.ChatRoomListGetResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface ChatRoomListGetRetrofit {
    @GET("/chatRoom")
    fun getChatRoomList(@Query("userId") userId: String) : Call<ChatRoomListGetResponse>
}

 

서버에서 정의해둔 API 양식에 맞게 

[GET] /chatRoom?userId=userId로 요청을 보내고 반환값으로 ChatRoomListGetResponse를 받아온다.

 

아래의 ChatRoomListGetResponse.kt, ChatRoomList.kt, UserInfo.kt 3개의 파일은 모두 data 패키지에 속한 DTO 파일이다.

 

 

▼ chat/ChatRoomListGet/data/ChatRoomListGetResponse.kt

package com.example.myapplication.chat.ChatRoomListGet.data

import com.google.gson.annotations.SerializedName

data class ChatRoomListGetResponse (
    @SerializedName(value = "respCode") val respCode : Int,
    @SerializedName(value = "respMsg") val respMsg : String,
    @SerializedName(value = "data") val data : ArrayList<ChatRoomList>
)

 

 

▼ chat/ChatRoomListGet/data/ChatRoomList.kt

package com.example.myapplication.chat.ChatRoomListGet.data

import com.google.gson.annotations.SerializedName

data class ChatRoomList(
    @SerializedName(value = "id") val cRoomId : Int,
    @SerializedName(value = "createUserId") val createUserId : String,
    @SerializedName(value = "list") val userList : ArrayList<UserInfo>
)

 

 

▼ chat/ChatRoomListGet/data/UserInfo.kt

package com.example.myapplication.chat.ChatRoomListGet.data

import com.google.gson.annotations.SerializedName

data class UserInfo(
    @SerializedName(value = "dataType") val dataType : String,
    @SerializedName(value = "dataCode") val dataCode : String
)

   

 

▼ chat/ChatRoomListGet/ChatRoomListGetService.kt

package com.example.myapplication.chat.ChatRoomListGet

import android.util.Log
import com.example.myapplication.chat.ChatRoomListGet.data.ChatRoomListGetResponse
import com.example.myapplication.getRetrofit
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response

class ChatRoomListGetService {
    private lateinit var chatRoomListGetResult: ChatRoomListGetResult

    fun setChatRoomListGetResult(chatRoomListGetResult: ChatRoomListGetResult) {
        this.chatRoomListGetResult = chatRoomListGetResult
    }

    fun getChatRoomList(userId: String) {
        val chatRoomListGetService = getRetrofit().create(ChatRoomListGetRetrofit::class.java)

        chatRoomListGetService.getChatRoomList(userId).enqueue(object : Callback<ChatRoomListGetResponse> {
            override fun onResponse(
                call: Call<ChatRoomListGetResponse>,
                response: Response<ChatRoomListGetResponse>
            ) {
                Log.d("GET-CHATROOMLIST-SUCCESS", response.toString())
                val response : ChatRoomListGetResponse = response.body()!!
                when(response.respCode) {
                    200 -> chatRoomListGetResult.getChatRoomListSuccess(response.respCode, response.data)
                    else -> chatRoomListGetResult.getChatRoomListFailure(response.respCode, response.respMsg)
                }
            }

            override fun onFailure(call: Call<ChatRoomListGetResponse>, t: Throwable) {
               Log.d("GET-CHATROOMLIST-FAILURE", t.toString())
            }
        })
    }

}

 

Fragment에서는 chatRoomListGetService를 호출한다. 

chatRoomListGetService의 getChatRoomList 메소드는 서버로 API 요청을 보내고

응답값의 respCode가 200일때 성공, 200이 아닌 경우 실패로 간주하여 에러 처리를 해주었다.

 

 

▼ chat/ChatRoomListGet/ChatRoomListGetResult.kt

package com.example.myapplication.chat.ChatRoomListGet

import com.example.myapplication.chat.ChatRoomListGet.data.ChatRoomList

interface ChatRoomListGetResult {
    fun getChatRoomListSuccess(respCode : Int, result : ArrayList<ChatRoomList>)
    fun getChatRoomListFailure(respCode : Int, respMsg : String)
}

  

인터페이스 구현은 Fragment에서 작성한다.

성공인 경우, 실패인 경우로 나눠 분기 처리해주면 된다.


다음은 채팅 수신 및 송신이다. (악)

 

▼ chat/ChatPost/ChatFragment.kt

package com.example.myapplication.chat.ChatPost

import ChatAdapter
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.myapplication.chat.ChatPost.data.Chat
import com.example.myapplication.chat.ChatPost.data.ChatInfo
import com.example.myapplication.chat.ChatPost.data.ChatPostRequest
import com.example.myapplication.databinding.FragmentChatBinding
import io.socket.client.IO
import io.socket.client.Socket
import io.socket.engineio.client.EngineIOException
import org.json.JSONObject
import java.net.URISyntaxException
import kotlin.properties.Delegates

class ChatFragment : Fragment(), ChatPostResult {

    private lateinit var binding: FragmentChatBinding
    private lateinit var socket: Socket
    private var chatRoomId by Delegates.notNull<Int>()
    private lateinit var userId: String
    private val TAG = "ChatFragment.kt"

    // RecyclerView와 Adapter 설정
    private lateinit var chatAdapter: ChatAdapter
    private val messages = mutableListOf<ChatInfo>()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        binding = FragmentChatBinding.inflate(inflater, container, false)
        return binding.root
    }

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

        // 채팅방 ID, 사용자 ID 추출
        val shared = requireActivity().getSharedPreferences("userID", AppCompatActivity.MODE_PRIVATE)
        userId = shared.getString("id", "empty").toString()
        chatRoomId = arguments?.getInt("CHAT_ROOM_ID") ?: 0

        // RecyclerView와 Adapter 초기화
        chatAdapter = ChatAdapter(messages, userId)
        binding.chatRecyclerView.layoutManager = LinearLayoutManager(requireContext())
        binding.chatRecyclerView.adapter = chatAdapter

        // 소켓 연결 설정
        setupSocketConnection()

        // 전송 버튼 클릭 리스너 설정
        binding.sendButton.setOnClickListener {
            val messageContent = binding.messageEditText.text.toString()
            if (messageContent.isNotEmpty()) {
                // 서버로 채팅 보내기 API 호출
                val chatPostService = ChatPostService()
                chatPostService.setChatPostResult(this)

                val msgType = "message"
                val chatPostRequest = ChatPostRequest(userId, chatRoomId.toString(), msgType, messageContent)
                chatPostService.postChat(chatRoomId, chatPostRequest)

                binding.messageEditText.text.clear()
                binding.messageEditText.requestFocus()
            }
        }
    }

    private fun setupSocketConnection() {
        try {
            // 소켓 초기화
            val options = IO.Options()
            options.path = "/socket.io"
            socket = IO.socket("http://192.168.1.200:3000/chat", options) // 서버 주소 및 네임스페이스

            // 소켓 이벤트 리스너 설정
            socket.on(Socket.EVENT_CONNECT) {
                Log.d(TAG, "Connected to server")

                // 사용자 ID와 채팅방 ID를 함께 전달 -> 채팅방 입장
                val jsonObject = JSONObject()
                jsonObject.put("userId", userId)
                jsonObject.put("roomId", chatRoomId.toString())

                socket.emit("joinRoom", jsonObject)
            }

            socket.on("newChat") { args ->
                Log.d(TAG, "Received NewChat!!!!!!!!!!!")

                val jsonString = args[0].toString()
                val jsonObject = JSONObject(jsonString)
                val sendUserId = jsonObject.getString("sendUserId")
                val msgContent = jsonObject.getString("msgContent")
                val sendTime = jsonObject.getString("sendTime")

                val chatInfo = ChatInfo(sendUserId, msgContent, sendTime)
                requireActivity().runOnUiThread {
                    messages.add(chatInfo)
                    chatAdapter.notifyItemInserted(messages.size - 1)
                    binding.chatRecyclerView.scrollToPosition(messages.size - 1)
                }
                Log.d(TAG, "Received message: $jsonString")
            }

            socket.on(Socket.EVENT_DISCONNECT) {
                Log.d(TAG, "Disconnected from server")
            }

            socket.on(Socket.EVENT_CONNECT_ERROR) { args ->
                val error = args[0] as EngineIOException
                Log.e(TAG, "Socket connection error: ${error.message}")
            }

            // 소켓 연결
            socket.connect()
        } catch (e: URISyntaxException) {
            e.printStackTrace()
            Log.e(TAG, "URI Syntax error: ${e.message}")
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        if (this::socket.isInitialized) {
            // 사용자 ID와 채팅방 ID를 함께 전달 -> 채팅방 퇴장
            val jsonObject = JSONObject()
            jsonObject.put("userId", userId)
            jsonObject.put("roomId", chatRoomId)

            socket.emit("leaveRoom", jsonObject)
            socket.disconnect()
            socket.close()
        }
    }

    override fun postChatSuccess(respCode: Int, result: Chat) {
        Log.d(TAG, "POST-CHAT-SUCCESS")
    }

    override fun postChatFailure(respCode: Int, respMsg: String) {
        Log.d(TAG, "POST-CHAT-FAIL")
    }
    companion object {
        fun newInstance(chatRoomId: Int): ChatFragment {
            val fragment = ChatFragment()
            val args = Bundle()
            args.putInt("CHAT_ROOM_ID", chatRoomId)
            fragment.arguments = args
            return fragment
        }
    }

}

 

채팅방 목록 중 하나를 선택하면 ChatFragment 화면이 띄워진다.

지금부터 소켓 연결부터 해제까지 하나하나 코드를 뜯어보자.

 

// 소켓 초기화
val options = IO.Options()
options.path = "/socket.io"
socket = IO.socket("http://192.168.1.200:3000/chat", options) // 서버 주소 및 네임스페이스

 

ChatFragment의 onViewCreated로 화면을 만들 때 setupSocketconnecetion 메소드를 호출하여 소켓 연결 설정을 해준다.  “서버 주소 + /chat”을 uri로 하여 소켓을 초기화해준다. 

 

// 소켓 이벤트 리스너 설정
socket.on(Socket.EVENT_CONNECT) {
    Log.d(TAG, "Connected to server")

    // 사용자 ID와 채팅방 ID를 함께 전달 -> 채팅방 입장
    val jsonObject = JSONObject()
    jsonObject.put("userId", userId)
    jsonObject.put("roomId", chatRoomId.toString())

    socket.emit("joinRoom", jsonObject)
}

 

로그인 후 SharedPreference에 저장해둔 userId와 intent로 전달받은 chatRoomId를 사용해 소켓이 연결되었을 때 chat 네임스페이스에 joinRoom 이벤트를 발생시킨다. 

= 즉, joinRoom 이벤트를 emit 하게 된다.

 

서버에서는 joinRoom 이벤트를 수신하여 해당 사용자의 소켓을 특정 룸에 넣어준다.

 

// 전송 버튼 클릭 리스너 설정
binding.sendButton.setOnClickListener {
    val messageContent = binding.messageEditText.text.toString()
    if (messageContent.isNotEmpty()) {
        // 서버로 채팅 보내기 API 호출
        val chatPostService = ChatPostService()
        chatPostService.setChatPostResult(this)

        val msgType = "message"
        val chatPostRequest = ChatPostRequest(userId, chatRoomId.toString(), msgType, messageContent)
        chatPostService.postChat(chatRoomId, chatPostRequest)

        binding.messageEditText.text.clear()
        binding.messageEditText.requestFocus()
    }
}

 

사용자가 채팅 내용을 입력하고 보내기 버튼을 클릭했을 때 동작하는 onClickListener로 

 

① userId(작성자Id)

② chatRoomId(채팅방Id)

③ msgType(메시지 유형)

④ messageContent(메시지 내용)

 

위의 데이터를 ChatPostRequest 객체에 담아 서버로 [POST] /chatRoom/{cRoomId}/chat 요청을 보내게 된다. 

 

서버에서는 [POST] /chatRoom/{cRoomId}/chat 해당 API를 처리하여 newChat 이벤트를 발생시킨다. 

= 즉, newChat 이벤트를 emit 하게 된다. 

 

socket.on("newChat") { args ->
    Log.d(TAG, "Received NewChat!!!!!!!!!!!")

    val jsonString = args[0].toString()
    val jsonObject = JSONObject(jsonString)
    val sendUserId = jsonObject.getString("sendUserId")
    val msgContent = jsonObject.getString("msgContent")
    val sendTime = jsonObject.getString("sendTime")

    val chatInfo = ChatInfo(sendUserId, msgContent, sendTime)
    requireActivity().runOnUiThread {
        messages.add(chatInfo)
        chatAdapter.notifyItemInserted(messages.size - 1)
        binding.chatRecyclerView.scrollToPosition(messages.size - 1)
    }
    Log.d(TAG, "Received message: $jsonString")
}

 

클라이언트는 newChat 이벤트를 수신하여 소켓을 통해 전달받은 새로운 채팅 메시지를 화면에 퍼블리싱한다. 

 

override fun onDestroyView() {
    super.onDestroyView()
    if (this::socket.isInitialized) {
        // 사용자 ID와 채팅방 ID를 함께 전달 -> 채팅방 퇴장
        val jsonObject = JSONObject()
        jsonObject.put("userId", userId)
        jsonObject.put("roomId", chatRoomId)

        socket.emit("leaveRoom", jsonObject)
        socket.disconnect()
        socket.close()
    }
}

 

사용자가 채팅방을 벗어나거나, 앱을 종료한 경우 클라이언트 측에서 leaveRoom 이벤트를 발생시킨다. 

= 즉, leaveRoom 이벤트를 emit 하게 된다.

 

서버에서는 leaveRoom 이벤트와 disconnect 이벤트를 수신하여 해당 사용자의 소켓을 룸에서 퇴장시키고 연결을 해제한다.

 

 

▼ chat/ChatPost/ChatAdapter.kt

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.chat.ChatPost.data.ChatInfo
import com.example.myapplication.databinding.ItemChatMessageBinding
import java.text.SimpleDateFormat
import java.util.Locale

class ChatAdapter(private val messages: MutableList<ChatInfo>, private val userId: String) :
    RecyclerView.Adapter<ChatAdapter.ChatViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
        val binding = ItemChatMessageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ChatViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
        holder.bind(messages[position])
    }

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

    inner class ChatViewHolder(private val binding: ItemChatMessageBinding) :
        RecyclerView.ViewHolder(binding.root) {
        fun bind(message: ChatInfo) {
            // SendTime을 Date 객체로 변환
            val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
            val outputFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
            val date = dateFormat.parse(message.sendTime)

            if (message.sendUserId == userId) {
                // 나의 메시지
                binding.myMessageLayout.visibility = View.VISIBLE
                binding.otherMessageLayout.visibility = View.GONE
                binding.myMessageTextView.text = message.msgContent
                binding.myTimestampTextView.text = outputFormat.format(date)
            } else {
                // 다른 유저의 메시지
                binding.otherMessageLayout.visibility = View.VISIBLE
                binding.myMessageLayout.visibility = View.GONE
                binding.senderIdTextView.text = message.sendUserId
                binding.messageTextView.text = message.msgContent
                binding.timestampTextView.text = outputFormat.format(date)
            }
        }
    }
}

 

ChatAdapter의 ChatViewHolder에서는

 

① Timestamp 타입의 sendTime을 Date 객체로 변환

② sendUserId를 비교해 '나'와 '타유저'의 메시지를 구분한다.

 

('나' : 오른쪽에 파란 박스로 표시 / '타유저' : 왼쪽에 회색 박스로 표시)

 

 

▼ chat/ChatPost/ChatPostRetrofit.kt

package com.example.myapplication.chat.ChatPost

import com.example.myapplication.chat.ChatPost.data.ChatPostRequest
import com.example.myapplication.chat.ChatPost.data.ChatPostResponse
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Path

interface ChatPostRetrofit {
    @POST("/chatRoom/{cRoomId}/chat")
    fun postChat(@Path("cRoomId") cRoomId : Int, @Body data : ChatPostRequest) : Call<ChatPostResponse>
}

 

서버에서 정의해둔 채팅 전송 API 양식에 맞게 

[POST] /chatRoom/{cRoomId}/chat로 요청을 보내고 반환값으로 ChatPostResponse를 받아온다.

 

아래의 ChatPostRequest.kt, ChatPostResponse.kt, Chat.kt, ChatInfo.kt 4개의 파일은 모두 data 패키지에 속한 DTO 파일이다.

 

▼ chat/ChatPost/data/ChatPostRequest.kt

package com.example.myapplication.chat.ChatPost.data

import com.google.gson.annotations.SerializedName

data class ChatPostRequest(
    @SerializedName(value = "sendUserId") val sendUserId : String,
    @SerializedName(value = "cRoomId") val cRoomId : String,
    @SerializedName(value = "msgType") val msgType : String,
    @SerializedName(value = "msgContent") val msgContent : String
)

 

 

▼ chat/ChatPost/data/ChatPostResponse.kt

package com.example.myapplication.chat.ChatPost.data

import com.google.gson.annotations.SerializedName

data class ChatPostResponse (
    @SerializedName(value = "respCode") val respCode : Int,
    @SerializedName(value = "respMsg") val respMsg : String,
    @SerializedName(value = "data") val data : Chat
)

 

 

▼ chat/ChatPost/data/Chat.kt

package com.example.myapplication.chat.ChatPost.data

import com.google.gson.annotations.SerializedName
import java.sql.Timestamp

data class Chat(
    @SerializedName(value = "msgId") val msgId : Int,
    @SerializedName(value = "sendTime") val sendTime : Timestamp
)

 

 

▼ chat/ChatPost/data/ChatInfo.kt

package com.example.myapplication.chat.ChatPost.data

import com.google.gson.annotations.SerializedName

data class ChatInfo(
    @SerializedName(value = "sendUserId") val sendUserId : String,
    @SerializedName(value = "msgContent") val msgContent : String,
    @SerializedName(value = "sendTime") val sendTime : String
)

 

 

▼ chat/ChatPost/data/ChatPostService.kt

package com.example.myapplication.chat.ChatPost

import android.util.Log
import com.example.myapplication.chat.ChatPost.data.ChatPostRequest
import com.example.myapplication.chat.ChatPost.data.ChatPostResponse
import com.example.myapplication.getRetrofit
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response

class ChatPostService {
    private lateinit var chatPostResult: ChatPostResult

    fun setChatPostResult(chatPostResult : ChatPostResult) {
        this.chatPostResult = chatPostResult
    }

    fun postChat(cRoomId : Int, message : ChatPostRequest) {
        val chatPostService = getRetrofit().create(ChatPostRetrofit::class.java)


        chatPostService.postChat(cRoomId, message).enqueue(object : Callback<ChatPostResponse> {
            override fun onResponse(
                call: Call<ChatPostResponse>,
                response: Response<ChatPostResponse>
            ) {
                Log.d("POST-CHAT-SUCCESS", response.toString())
                val response : ChatPostResponse = response.body()!!
                when(response.respCode) {
                    200 -> chatPostResult.postChatSuccess(response.respCode, response.data)
                    else -> chatPostResult.postChatFailure(response.respCode, response.respMsg)
                }
            }

            override fun onFailure(call: Call<ChatPostResponse>, t: Throwable) {
                Log.d("POST-CHAT-FAILURE", t.toString())
            }

        })
    }
}

 

Fragment에서 보내기 버튼을 클릭하면 chatPostService를 호출한다. 

chatPostService의 postChat 메소드는 서버로 API 요청을 보내고

응답값의 respCode가 200일때 성공, 200이 아닌 경우 실패로 간주하여 에러 처리를 해주었다.

 

 

▼ chat/ChatPost/data/ChatPostResult.kt

package com.example.myapplication.chat.ChatPost

import com.example.myapplication.chat.ChatPost.data.Chat

interface ChatPostResult {
    fun postChatSuccess(respCode : Int, result : Chat)
    fun postChatFailure(respCode : Int, respMsg : String)
}

 

인터페이스 구현은 Fragment에서 작성한다.

성공인 경우, 실패인 경우로 나눠 분기 처리해주면 된다.

 

 

 

 

2)  앱 실시간 채팅 테스트

 

왼쪽 : 로그인 후 부서원 목록 조회 화면 / 오른쪽 : 상단의 채팅 메뉴 선택 시 채팅방 목록 조회 화면

 

 

로그인하면 카톡처럼 친구(부서원) 목록 조회 화면이 먼저 뜬다.

상단의 채팅 메뉴를 선택하면 내가 참여중인 채팅방 목록을 확인할 수 있다.

 

 

에뮬레이터 테스트

 

웹 테스트

 

앱 - 웹에 각각 다른 사용자로 로그인해서 실시간으로 채팅을 주고 받는 모습이다.

 

실제로 테스트 했을 때

 

① 앱 - 앱

② 웹 - 웹

③ 앱 - 웹

④ 실제 기기 - 실제 기기

⑤ 실제 기기 - 앱

⑥ 실제 기기 - 웹

 

6가지 경우 모두 실시간 채팅을 주고 받는 것에 성공했다~