WebSocket 멀티 모듈에서 사용해보기
이번 게시글에서는 WebSocket에 대해 알아보고 멀티 모듈 환경에서 WebSocket을 사용해보려고 합니다.
WebSocket이란?
WebSocket은 웹 환경에서 실시간, 양방향 통신을 지원하는 프로토콜입니다. 한 번 연결을 맺으면, 서버와 클라이언트가 끊임없이 데이터를 주고받을 수 있습니다.
WebSocket 연결이 생성되면 TCP 소켓 기반으로 통신이 시작됩니다.
소켓이란 네트워크에서 데이터를 주고받은 두 장치 간의 종단점을 의미합니다. 한 번 연결을 맺으면, 서버와 클라이언트가 끊임없이 데이터를 주고받을 수 있습니다.
WebSocket 통신은 아래와 같은 과정으로 진행됩니다.
- WebSocket Handshake
- TCP 기반 통신
- 소켓 종료
WebSocket Handshake
WebSocket을 통해 TCP 소켓 기반 통신을 하려면, 먼저 손을 잡는(Handshake) 과정이 필요합니다.
WebSocket은 TCP 연결 전에, HTTP 통신을 사용하여 서버에게 요청을 보냅니다. 서버가 이를 수락하면 HTTP에서 WebSocket 프로토콜로 업그레이드가 이뤄집니다. 이제는 HTTP는 더 이상 사용하지 않고 WebSocket을 사용하려 양방향 통신을 하게 됩니다.
- 초기 연결(Handshake) - HTTP 사용
- 연결 이후 - TCP 위에서 WebSocket 프로토콜 사용
안드로이드에서 HTTP 통신하면 가장 먼저 떠오르는 것은 Okhttp죠. WebSocket도 마찬가지로 Handshake를 위해서는 OkHttp를 사용합니다. 자세한 사용법은 아래에서 살펴보겠습니다.
마지막으로, 통신을 완료했다면 이를 종료해줘야 합니다. 이 과정 또한 아래에서 살펴보겠습니다.
멀티 모듈에서의 WebSocket의 사용
WebSocket을 멀티 모듈에서 어떻게 사용해야하는지 정해진 것은 없다고 알고 있습니다. 그렇기에 제 방법대로 사용을 해보겠습니다.
WebSocketManager
먼저 정의해야할 것은 WebSocket을 관리하는 WebSocketManager를 정의하는 것입니다. WebSocket을 여러 번 사용하지 않는다면, 이 과정을 생략하여도 괜찮지만 저는 앵간하면 Manager를 자기 입맛대로 정의해 놓고 사용하는 것을 추천합니다.
제가 정의한 WebSocketManager는 서버로부터 Json 데이터를 받는다고 가정하고, 다음과 같은 스택을 사용합니다.
- Kotlin Serialization
- Coroutine Flow
아래는 제가 정의한 WebSocketManager입니다.
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import javax.inject.Inject
class WebSocketManager @Inject constructor(
val client: OkHttpClient,
val json: Json
) {
var webSocket: WebSocket? = null
inline fun <reified R> get(
url: String,
crossinline onError: () -> Unit
): Flow<R> = callbackFlow {
val listener = object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val data = json.decodeFromString<R>(text)
trySend(data)
} catch (e: Exception) {
onError()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
onError()
}
}
val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, listener)
awaitClose {
webSocket?.close(1000, null)
}
}.conflate()
fun sendMessage(message: String) {
Logger.d("sendMessage: $message")
webSocket?.send(message)
}
}
위의 클래스는 아래와 같은 특징을 가집니다.
- callbackFlow 활용: WebSocket은 콜백 기반으로 동작하기 때문에 Flow로 활용하기 위해 사용
- awaitClose: awaitClose는 해당 Flow를 생성하는 코루틴이 종료될 때, 실행되기에 WebSocket의 Close 작업을 매우 편리하게 함
- Serialization: Serialization을 통해 WebSocket 통신으로부터 받는 데이터를 역직렬화함, if. 만약 역직렬화가 필요하지 않으시다면, 제네릭과 관련된 내용을 제거하신 다음에 사용하시면 됩니다.
- conflate: conflate를 통해 과부하 상황에서 최신값을 남길 수 있게 함
WebSocketManager에 필요한 OkHttp와 Json은 @Provides를 통해 주입해주면 됩니다. 아마 Retrofit을 사용하고 계시면, 별도로 정의할 필요는 없습니다. (addInterceptor는 없어도 됩니다)
@Module
@InstallIn(SingletonComponent::class)
internal object RetrofitModule {
@Provides
@Singleton
fun provideOkhttpClient(): OkHttpClient =
OkHttpClient
.Builder()
.addInterceptor(HttpNetworkLogger())
.build()
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
encodeDefaults = true
}
@Provides
@Singleton
fun provideConverterFactory(json: Json): Converter.Factory = json.asConverterFactory("application/json".toMediaType())
}
WebSocketManager의 위치
아마 WebSocketManager를 어디에 두어야할지 고민이 있으시다면, 저는 아래와 같이 2가지로 고민했기에 참고하셔서 각자 원하는 위치에 두시면 될 것 같습니다.
data
모듈안에 정의
- 별도의 모듈로 정의하고
data
모듈에서 의존 (ex.websocket 모듈)
Repository에서 사용
이젠 Repository에서 힘들게 만든, WebSocketManager를 사용하면 됩니다. 일반적으로는 아래의 형태를 가집니다
class MessageRepositoryImpl @Inject constructor(
private val webSocketManager: WebSocketManager
) : MessageRepository {
override fun getMessage(): Flow<String> {
return webSocketManager.get<String>(
url = URL,
onError = {}
)
}
override fun sendMessage(message: String) {
webSocketManager.sendMessage(message)
}
companion object {
private const val URL = "ws://example.com/websocket"
}
}
interface MessageRepository {
fun getMessage(): Flow<String>
fun sendMessage(message: String)
}
ViewModel에서 사용
아래와 같이 매우 편리하게 사용할 수 있습니다.
@HiltViewModel
class MessageViewModel @Inject constructor(
private val messageRepository: MessageRepository
) : ViewModel() {
val messages: StateFlow<String> = messageRepository.getMessage()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ""
)
private val _inputMessage = MutableStateFlow("")
val inputMessage: StateFlow<String> = _inputMessage.asStateFlow()
fun updateInputMessage(message: String) {
_inputMessage.value = message
}
fun sendMessage() {
val message = _inputMessage.value.trim()
if (message.isNotEmpty()) {
messageRepository.sendMessage(message)
_inputMessage.value = ""
}
}
}