목차
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가지 경우 모두 실시간 채팅을 주고 받는 것에 성공했다~
목차
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가지 경우 모두 실시간 채팅을 주고 받는 것에 성공했다~