스프링 서버 공부하는 와중 인프런에서 채팅 기능을 구현하는 강의가 있었다.
위 강의 영상인데 프론트는 Vue로 되어있지만, 이를 이용해 로컬 서버를 통한 안드로이드 채팅 화면을 구현해보려고 한다.
실제 결과물은 아래와 같다.
서로 다른 두 에뮬레이터에서 하나의 채팅 방에 들어가 채팅하는 기능이다.
사실 이 채팅 방 구현 이전에 로그인, 채팅방 만들기 등 여러 작업들이 있었지만 가장 중요한 기능인 서로 채팅하는 화면을 구현한 부분만 다뤄본다.
서버 코드가 궁금한 분은 아래 깃허브 링크를 참조하면 될 것 같다.
주요 라이브러리는 아래와 같다.
serialization
ktor
krossbow
hilt
datastore
ksp
#ktor
# ktor = "3.2.3"
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-content-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
#krossbow ="7.0.0"
krossbow-stomp-core = { module = "org.hildan.krossbow:krossbow-stomp-core", version.ref = "krossbow" }
krossbow-websocket-builtin = { module = "org.hildan.krossbow:krossbow-websocket-builtin", version.ref = "krossbow" }
krossbow-websocket-ktor = { module = "org.hildan.krossbow:krossbow-websocket-ktor", version.ref = "krossbow" }
#hilt
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt-plugin" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-plugin" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-compose" }
처음에 Ktor를 사용한 이유는 WebSocket을 구현할 때 Retrofit보다 간단하게 구현이 가능하다고 얘기를 들었기 때문에 선택하게 되었다. 하지만 실질적으로는 WebSocket이 아닌 Stomp를 사용한 채팅 구현이였는데 이게 Websocket이랑 달라 결국 krossbow 라이브러리를 이용한 Stomp를 사용하는 방식을 진행했다.
krossbow란 Stomp연결을 도와주는 라이브러리라고 생각하면 될 것 같다.
https://joffrey-bion.github.io/krossbow/
위 링크가 해당 라이브러리 사용하는 방법을 알려주는 사이트이며, Ktor 말고도 Okhttp로도 이용할 수 있는 방법을 잘 알려준다.
일단 둘 다 클라이언트와 서버 간의 실시간 양방향 통신을 가능하게하는 프로토콜이지만 약간의 차이가 있다.

WebSocket은 클라이언트와 서버 간 지속적인 연결을 유지
무거운 HTTP 메시지가 필요 없이 직접 메시지를 주고 받음으로서 서버 부하 측면에서 우숳다.

STOMP(Simple Text Oriented Messaging Protocol)은 WebSocket 위에서 동작하는 메시징 프로토콜
WebSocket과 다르게, 목적지 기반 메시지 라우팅을 지원한다.
먼저 크게 3가지 기능을 구현해야하는 인터페이스를 구현
참고로 애뮬레이터에서 로컬 서버를 연결하고 싶은 경우 아래와 같은 host를 작성해야 한다.
interface StompMessageClient {
fun getMessageStream(roomId: Long): Flow<ChatMessage>
suspend fun sendMessage(roomId: Long, message: String)
suspend fun close()
}
class DefaultStompMessageClient @Inject constructor(
private val httpClient: HttpClient,
private val sessionStorage: SessionStorage
) : StompMessageClient {
private var session: StompSession? = null
@OptIn(ExperimentalEncodingApi::class)
override fun getMessageStream(roomId: Long): Flow<ChatMessage> = flow {
Timber.e("getMessageStream connect")
try {
val wsClient = KtorWebSocketClient(httpClient)
val stompClient = StompClient(wsClient)
session = stompClient.connect(
url = "ws://10.0.2.2:8080/connect",
customStompConnectHeaders = mapOf(
"Authorization" to "Bearer ${sessionStorage.get()?.accessToken}",
"Content-Type" to "application/json"
)
)
val message = session!!.subscribe(
headers = StompSubscribeHeaders(
destination = "/topic/${roomId}", customHeaders = mapOf(
"Authorization" to "Bearer ${sessionStorage.get()?.accessToken}",
"Content-Type" to "application/json"
)
)
)
.mapNotNull {
val response = Json.decodeFromString<StompChatResponse>(it.bodyAsText)
val decodeString = Base64.decode(response.body)
Json.decodeFromString<ChatMessageResponse>(
String(
decodeString,
Charsets.UTF_8
)
).toDomain()
}
emitAll(message)
} catch (e: Exception) {
Timber.e("getMessageStream Error $e")
}
}
override suspend fun sendMessage(roomId: Long, message: String) {
val request = ChatMessageRequest(
roomId = roomId,
message = message,
senderEmail = sessionStorage.get()?.email ?: ""
)
session?.send(
headers = StompSendHeaders(
destination = "/publish/${roomId}", customHeaders = mapOf(
"Authorization" to "Bearer ${sessionStorage.get()?.accessToken}",
"Content-Type" to "application/json"
)
),
body = FrameBody.Text(Json.encodeToString(request))
)
}
override suspend fun close() {
session?.disconnect()
session = null
}
}
여기서 구현을 했을 때 krossbow를 사용하지 않고 일반 HttpClient로 Stomp로 연결을 시도하려고 했지만 제 지식으로는 불가능했다.
그 이유는 먼저 header에 accessToken을 보내야 하는 HttpClient의 WebSocket에는 그 방법을 찾을 수 없었다.(있다면 알려주시면 감사하겠습니다.)
그래서 krossbow를 이용했는데 StompSubscribeHeaders나 StompSendHeaders에는 headers라는 파라미터가 존재해 토큰값을 넣을 수 있었다.
또 다른 이유는 구독을 하는 방법이 WebSocket으로는 구현하기가 어려웠다. 이 또한 krossbow에서는 subscribe라는 함수를 통해, 심지어 flow형식을 지원하기 때문에 쉽게 구현이 가능했다.
참고로 Ktor에서 Krossbow를 사용하려면 HttpClient에 아래와 같은 install을 해줘야 한다.
HttpClient(CIO) {
install(ContentNegotiation) {
json(
json = Json {
ignoreUnknownKeys = true
}
)
}
//여기 WebSocket을 등록해야한다.
//이 방법은 Krossbow 공식 홈페이지를 보면 알려준다.
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Timber.d(message)
}
}
level = LogLevel.ALL
}
}
@HiltViewModel
class ChatDetailViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val stompMessageClient: StompMessageClient,
private val userRepository: UserRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState())
val state = _state.asStateFlow()
private val _effect = Channel<ChatDetailEffect>()
val effect = _effect.receiveAsFlow()
init {
val info = savedStateHandle.toRoute<ChatDetail>()
Timber.e("ChatDetailViewModel stompMessageClient start ${info.roomId}")
//받은 RoomId를 통해 Stream(구독)하기
stompMessageClient.getMessageStream(info.roomId)
.onEach { message ->
Timber.e("ChatDetailViewModel stompMessageClient success $message")
_state.update {
it.copy(
messages = state.value.messages + message
)
}
_effect.send(ChatDetailEffect.ScrollToBottom)
}
.catch {
Timber.e("ChatDetailViewModel stompMessageClient error : $it")
}
.launchIn(viewModelScope)
onEvent(ChatDetailEvent.GetHistoryMessage(info.roomId))
}
fun onEvent(event: ChatDetailEvent) {
when (event) {
//이전에 메세지 정보 받아오기
is ChatDetailEvent.GetHistoryMessage -> {
viewModelScope.launch {
combine(
chatRepository.historyRoomMessage(event.roomId),
userRepository.getMyUserEmail()
) { messages, email ->
ChatDetailState(
roomId = event.roomId,
email = email,
messages = messages
)
}
.catch {
Timber.e("ChatDetailViewModel onEvent historyRoomMessage error : $it")
}
.collect { chatDetailState ->
_state.update { chatDetailState }
// _effect.send(ChatDetailEffect.ScrollToBottom)
}
}
}
is ChatDetailEvent.SendMessage -> {
//메세지 전달하기
viewModelScope.launch {
stompMessageClient.sendMessage(state.value.roomId, event.message)
_state.update { it.copy(text = "") }
}
}
is ChatDetailEvent.OnValueChange -> {
_state.update { it.copy(text = event.text) }
}
}
}
//viewModel이 종료되면 stompMessageClient 종료하기
override fun onCleared() {
super.onCleared()
viewModelScope.launch {
stompMessageClient.close()
}
}
}
최근에 Spring을 이용한 다양한 강의를 들으면서 채팅 서버 구현을 듣게 되었는데 이를 안드로이드에서 활용해보니 점차 서버하고 안드로이드 개발에 대해 다양한 기능을 개발할 수 있어서 좋은 것 같다.
채팅 이외에도 다양한 기능들이 있으면 거창하진 않아도 직접 구현해보고 활용해보려고 노력해보자.