면접 질문 목록으로 가기
면접 질문실전 질문꼬리 질문

안드로이드에서 실시간 채팅 아키텍처 설계하기

skydovesJaewoong Eum (skydoves)||11분 소요

안드로이드에서 실시간 채팅 아키텍처 설계하기

실시간 채팅 화면을 구현하려면 지속적인 네트워크 연결, 로컬 영속성(persistence), 낙관적 UI 업데이트(optimistic UI update), 그리고 효율적인 리스트 렌더링을 모던 안드로이드 아키텍처 안에서 조화롭게 조율해야 합니다. 채팅 도메인은 겉보기에는 단순해 보이지만 실제로는 상당히 복잡합니다. 메시지는 전송 즉시 화면에 표시되어야 하고, 다른 참여자로부터 실시간으로 수신되어야 하며, 오프라인 상태에서도 유실 없이 보존되어야 합니다. 네트워크 분할(network partition) 상황에서도 엄격한 순서를 유지해야 합니다. 아키텍처의 기반을 올바르게 설계하는 것이 곧 읽음 확인, 타이핑 표시기, 미디어 메시지 등의 기능이 추가되어도 시스템이 유지보수 가능한 상태로 남을 수 있는지를 결정짓습니다. 면접에서도 실시간 채팅은 시스템 설계 역량을 종합적으로 평가하기에 매우 좋은 주제이므로, 각 계층의 역할과 데이터 흐름을 명확히 이해하고 계셔야 합니다.

이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.

  • WebSocket 연결이 어떻게 전이중(full-duplex) 실시간 통신을 제공하는지, 그리고 안드로이드에서 WebSocket의 생명주기를 어떻게 관리하는지 설명할 수 있습니다.
  • 로컬 Room 데이터베이스를 UI의 단일 진실 공급원(single source of truth)으로 활용하는 오프라인 우선(offline-first) 패턴을 이해할 수 있습니다.
  • 사용자 입력부터 낙관적 삽입, 서버 확인 응답(acknowledgment), 상태 동기화까지 메시지 전송의 전체 데이터 흐름을 추적할 수 있습니다.
  • 클럭 스큐(clock skew), 네트워크 지연, 동시 쓰기 상황을 처리하는 메시지 정렬 전략을 적용할 수 있습니다.
  • LazyColumn에 안정적인 키와 적절한 상태 호이스팅(state hoisting)을 적용하여 고성능 Compose UI 계층을 설계할 수 있습니다.

실시간 전송 계층: WebSocket 생명주기 관리

채팅 화면에는 지속적이면서 양방향으로 통신할 수 있는 채널이 필요합니다. HTTP 폴링(polling) 방식은 지연 시간이 발생하고 대역폭도 낭비됩니다. WebSocket은 HTTP 연결을 지속적인 TCP 소켓으로 업그레이드하여 양쪽 모두 언제든지 데이터를 송수신할 수 있도록 문제를 해결합니다. 안드로이드에서는 OkHttp가 안정적인 WebSocket 구현체를 제공합니다.

아래 매니저 클래스는 OkHttp의 WebSocket을 래핑하고, 수신되는 메시지를 SharedFlow로 노출합니다.

class ChatWebSocketManager(
    private val okHttpClient: OkHttpClient,
    private val baseUrl: String
) {
    private var webSocket: WebSocket? = null
    private val _incomingMessages = MutableSharedFlow<Message>(
        extraBufferCapacity = 64,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val incomingMessages: SharedFlow<Message> = _incomingMessages.asSharedFlow()

    private val listener = object : WebSocketListener() {
        override fun onMessage(webSocket: WebSocket, text: String) {
            _incomingMessages.tryEmit(Json.decodeFromString<Message>(text))
        }
        override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
            scheduleReconnect() // 지수 백오프를 사용한 재연결 스케줄링
        }
    }

연결 및 메시지 송수신 메서드는 소켓의 생명주기를 관리합니다.

    fun connect(conversationId: String) {
        val request = Request.Builder().url("$baseUrl/chat/$conversationId").build()
        webSocket = okHttpClient.newWebSocket(request, listener)
    }

    fun send(message: Message): Boolean {
        return webSocket?.send(Json.encodeToString(message)) ?: false
    }

    fun disconnect() {
        webSocket?.close(1000, "User left chat")
        webSocket = null
    }
}

WebSocket 연결은 채팅 화면의 생명주기에 맞게 범위를 지정해야 합니다. 재연결 로직은 일시적인 실패에 대해 지수 백오프(exponential backoff)를 적용하여 1초부터 시작해 최대 30초까지 두 배씩 늘려야 합니다. 소켓이 비활성 상태일 때 백그라운드에서 메시지를 수신하려면, Firebase Cloud Messaging(FCM)을 통해 푸시 알림으로 WebSocket을 보완할 수 있습니다. 실무에서는 앱이 백그라운드로 전환될 때 WebSocket 연결을 끊고, 포그라운드 복귀 시 재연결하는 전략이 배터리 효율 면에서 바람직합니다.

오프라인 우선 영속성: Room 활용

아키텍처에서 가장 중요한 설계 결정은 로컬 데이터베이스를 단일 진실 공급원(single source of truth)으로 삼는 것입니다. Compose UI는 네트워크에서 직접 데이터를 읽지 않습니다. 수신되는 모든 메시지는 Room에 먼저 저장되고, UI는 DAO 쿼리가 방출하는 Flow를 관찰합니다. 이를 통해 일관된 상태를 보장하는 동시에 오프라인 접근도 가능해집니다.

@Entity(tableName = "messages")
data class MessageEntity(
    @PrimaryKey val id: String,
    val conversationId: String,
    val senderId: String,
    val text: String,
    val timestamp: Long,
    val status: String, // SENDING, SENT, DELIVERED, FAILED
    val localSequence: Long // 동일 타임스탬프 시 정렬 기준이 되는 로컬 단조 증가 카운터
)

@Dao
interface MessageDao {
    @Query("SELECT * FROM messages WHERE conversationId = :id ORDER BY timestamp DESC, localSequence DESC")
    fun observeMessages(id: String): Flow<List<MessageEntity>>

    @Upsert
    suspend fun upsert(message: MessageEntity)
}

이 면접 질문은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 면접 질문의 전체 내용을 볼 수 있습니다.

구독하기
면접 질문 목록으로 가기