1. 왜 바꾸나?
실시간 시세 화면을 폴링(REST)로 돌리면
- 네트워크/배터리 낭비가 크다
- 폴링 주기(예: 500–1000ms)보다 더 빠른 변화를 놓친다
- 서버 제한에도 자주 걸린다.
그렇기 때문에 실시간은 WebSocket으로 변경 하는 것이 적합하다 판단했다. 오늘은 기존 폴링 구조를 크게 흔들지 않고 WS로 업그레이드해보려 한다
2. 먼저 상태 점검부터
일단 로그를 찍어 어느정도의 성능이 나오는지 확인해본다.

위에 스샷을 보면 평균적으로 500ms 때가 나오는 것을 확인할 수 있다
3. 구조는 그대로, 연결만 바꾸자
기존 레이어를 지키되 연결 지점만 WS로 바꾼다.
- NetworkModule: WS용 OkHttpClient 제공
- DataSource(WS): connect(markets)/tickerListFlow
- Repository: DTO→Entity 매핑
- UseCase: “REST 한 번 → WS로 전환”
3.1 NetworkModule (WS 클라이언트 제공)
@Provides
@Singleton
@Named("upbitWs")
fun provideUpbitWebSocketClient(
httpLoggingInterceptor: HttpLoggingInterceptor
): OkHttpClient =
OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(httpLoggingInterceptor)
.build()
3.2 DataSource(WS)
class UpbitWebSocketManager @Inject constructor(
@Named("upbitWs") private val client: OkHttpClient,
private val moshi: Moshi
) : UpbitWebSocketManagerInterface {
companion object {
private const val TAG = "UpbitWS"
private const val ENDPOINT = "wss://api.upbit.com/websocket/v1"
private const val MAX_CODES_PER_SOCKET = 200
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _tickerListFlow = MutableStateFlow<List<UpbitTickerResponse>>(emptyList())
override val tickerListFlow: StateFlow<List<UpbitTickerResponse>> = _tickerListFlow.asStateFlow()
private val sockets = mutableMapOf<Int, WebSocket>()
private val adapter by lazy {
moshi.adapter(UpbitTickerResponse::class.java)
}
override fun connect(markets: List<String>) {
disconnect()
val codes = markets.mapNotNull { it.trim().ifEmpty { null } }
if (codes.isEmpty()) return
val chunks = codes.chunked(MAX_CODES_PER_SOCKET)
chunks.forEachIndexed { index, chunk ->
val request = Request.Builder().url(ENDPOINT).build()
val listener = object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
val payload = buildSubscribePayload(chunk)
ws.send(payload)
Log.d("$TAG[$index]", "구독 전송: $payload")
}
override fun onMessage(ws: WebSocket, text: String) {
parseAndEmit(text, index)
}
override fun onMessage(ws: WebSocket, bytes: ByteString) {
parseAndEmit(bytes.string(Charsets.UTF_8), index)
}
override fun onClosing(ws: WebSocket, code: Int, reason: String) {
ws.close(1000, null)
sockets.remove(index)
Log.d("$TAG[$index]", "closing: $reason")
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
sockets.remove(index)
Log.e("$TAG[$index]", "failure: ${t.message}", t)
}
}
val ws = client.newWebSocket(request, listener)
sockets[index] = ws
}
}
override fun disconnect() {
sockets.values.forEach { it.close(1000, "Client disconnect") }
sockets.clear()
_tickerListFlow.value = emptyList()
}
private fun buildSubscribePayload(codes: List<String>): String {
val codesJson = codes.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
return """
[
{"ticket":"jusicool-${System.currentTimeMillis()}"},
{"type":"ticker","codes":$codesJson},
{"format":"DEFAULT"}
]
""".trimIndent()
}
private fun parseAndEmit(message: String, index: Int) {
runCatching { adapter.fromJson(message) }
.onSuccess { parsed ->
if (parsed != null) {
scope.launch {
_tickerListFlow.update { current ->
val mutable = current.toMutableList()
val at = mutable.indexOfFirst { it.market == parsed.market }
if (at >= 0) mutable[at] = parsed else mutable.add(parsed)
mutable
}
}
} else {
Log.w("UpbitWS[$index]", "파싱 실패: $message")
}
}
.onFailure {
Log.e("UpbitWS[$index]", "파싱 에러: ${it.message}", it)
}
}
}
3.3 Repository(WS)
class WsUpbitRepositoryImpl @Inject constructor(
private val wsManager: UpbitWebSocketManagerInterface
) : WsUpbitRepository {
override fun observeTicker(markets: List<String>): Flow<List<AssetsCurrentPrice>> =
callbackFlow {
wsManager.connect(markets)
val job = launch {
wsManager.tickerListFlow
.map { list -> list.map(UpbitTickerResponse::toEntity) }
.collect { data ->
val result = trySend(data)
if (result.isFailure) {
Log.d("WsUpbitRepository", "Flow send 실패: $result")
}
}
}
awaitClose {
wsManager.disconnect()
job.cancel()
Log.d("WsUpbitRepository", "Flow 종료")
}
}
}
3.4 UseCase: “REST 한 번 → WS 이어받기”
class GetCurrentCryptoPriceUseCase @Inject constructor(
private val cryptoRepository: CryptoRepository,
private val wsUpbitRepository: WsUpbitRepository
) {
operator fun invoke(
markets: List<String>,
): Flow<List<AssetsCurrentPrice>> = flow {
val initial = cryptoRepository.getCurrentCryptoPrice(markets).first()
emit(initial)
emitAll(
wsUpbitRepository.observeTicker(markets).retryWhen { _, attempt ->
delay((1000L * (attempt + 1)).coerceAtMost(10_000L))
true
}
)
}
}
4. 결과
웹소켓으로 코드를 변경한 뒤 간단한 성능 로그를 남겨 보니, 차이가 한눈에 보였다.

폴링은 설정한 주기만큼의 최소 지연이 항상 깔리고, 그 주기를 줄일수록 네트워크/배터리/서버 부담이 기하급수적으로 커진다. 반면 WebSocket은 이벤트가 발생할 때만 푸시되기 때문에 gapMs가 작고 변화가 즉시 화면에 반영된다. 실제로 내 환경에서도 Logcat에서 확인한 PERF-WS가 PERF-POLL보다 더 낮고 안정적인 gap을 지속적으로 기록했다.
5. 마무리
이번에 폴링을 웹소켓으로 바꾸면서 진짜 많이 배웠다. 막연히 “주기 줄이면 더 빠르겠지?”라고 생각했는데, 로그로 gapMs를 찍어 보니까 폴링은 애초에 주기만큼의 지연이 깔려 있었다. 반대로 웹소켓은 이벤트가 생길 때 바로 들어와서 체감이 훨씬 즉각적이었고, 구조를 다 갈아엎지 않아도 연결 지점만 WS로 바꿨을 뿐인데 화면 반응이 눈에 띄게 좋아졌다. 결론적으로 “먼저 측정하고, 필요한 것만 최소로 바꾸고, 다시 확인한다”는 흐름이 성능을 올리는 가장 안전한 길이라는 걸 몸으로 배웠다. 다음에도 같은 방식으로 차근차근 개선해 볼 생각이다!@