본문 바로가기
안드로이드

2026년 안드로이드 앱에 LLM을 제대로 붙이는 법

by 안드뽀개기 2026. 6. 16.
반응형

적용 대상과 전제 조건

이 글은 채팅, 코파일럿, 스트리밍 텍스트 응답 같이 LLM 출력이 주요 화면을 구성하는 안드로이드 앱을 대상으로 합니다. Kotlin 2.x, Compose 1.7 이상, Android minSdk 26 환경을 기준으로 합니다. 카메라 ML, 추천 시스템, 백그라운드 분류기 같은 케이스는 다루지 않습니다.

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("androidx.room:room-ktx:2.7.0")
    // on-device: Google AI Edge SDK (AICore)
    implementation("com.google.ai.edge.aicore:aicore:0.0.1-exp01")
}

개발자들이 자주 겪는 문제

LLM API 호출을 ViewModel에 직접 넣는 것으로 시작하는 경우가 많습니다. 처음엔 잘 동작하지만, 오프라인 상황, 스트림 중단, 민감 정보 전송, 토큰 비용 폭증 같은 문제가 차례로 터집니다. 이 시점에 코드는 이미 여러 화면에 퍼져 있고, 고치려면 전체를 다시 써야 하는 상황이 됩니다.

// 흔히 볼 수 있는 Before 코드 - ViewModel에 API 호출이 직접 노출됨
class ChatViewModel : ViewModel() {
    fun sendMessage(text: String) {
        viewModelScope.launch {
            val response = openAiClient.chat(text) // 로컬/리모트 구분 없음
            _uiState.value = ChatUiState.Success(response.content)
        }
    }
}

네트워크 없이 실행하면 바로 실패하고, 스트리밍을 붙이려면 구조를 뜯어야 합니다.


런타임 라우터: 로컬과 리모트를 갈라쳐라

앱이 직접 모델 선택 로직을 갖도록 LlmRouter를 별도 컴포넌트로 뺍니다. 라우터는 현재 네트워크 상태, 프롬프트 민감도, 디바이스 성능을 보고 어느 경로로 보낼지 결정합니다.

enum class InferenceTarget { LOCAL, REMOTE }

data class RoutingContext(
    val isOnline: Boolean,
    val isSensitive: Boolean,
    val estimatedTokens: Int
)

class LlmRouter(
    private val localEngine: LocalModelEngine,  // AICore / MediaPipe
    private val remoteClient: RemoteLlmClient   // OpenAI / Anthropic / 자체 백엔드
) {
    fun resolve(ctx: RoutingContext): InferenceTarget {
        if (ctx.isSensitive) return InferenceTarget.LOCAL
        if (!ctx.isOnline) return InferenceTarget.LOCAL
        if (ctx.estimatedTokens < 512 && localEngine.isReady()) return InferenceTarget.LOCAL
        return InferenceTarget.REMOTE
    }

    fun stream(prompt: String, ctx: RoutingContext): Flow<LlmChunk> {
        return when (resolve(ctx)) {
            InferenceTarget.LOCAL -> localEngine.stream(prompt)
            InferenceTarget.REMOTE -> remoteClient.stream(prompt)
        }
    }
}

Compose 화면은 LlmChunk만 소비하면 됩니다. 어디서 왔는지는 알 필요가 없습니다.


Compose에서 스트리밍 상태 처리

스트림이 들어오는 동안 ViewModel이 토큰을 누적해서 하나의 ChatUiState로 변환합니다. 스트림 도중 취소, 에러, 재시도 상태도 같은 sealed class로 표현합니다.

sealed interface ChatUiState {
    data object Idle : ChatUiState
    data class Streaming(val partial: String) : ChatUiState
    data class Done(val full: String) : ChatUiState
    data class Error(val cause: Throwable) : ChatUiState
}

class ChatViewModel(private val router: LlmRouter) : ViewModel() {
    private val _uiState = MutableStateFlow<ChatUiState>(ChatUiState.Idle)
    val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()

    private var streamJob: Job? = null

    fun send(text: String, ctx: RoutingContext) {
        streamJob?.cancel()
        streamJob = viewModelScope.launch {
            val sb = StringBuilder()
            router.stream(text, ctx)
                .catch { e -> _uiState.value = ChatUiState.Error(e) }
                .collect { chunk ->
                    sb.append(chunk.text)
                    _uiState.value = ChatUiState.Streaming(sb.toString())
                }
            _uiState.value = ChatUiState.Done(sb.toString())
        }
    }

    fun cancel() { streamJob?.cancel(); _uiState.value = ChatUiState.Idle }
}
@Composable
fun ChatScreen(vm: ChatViewModel = viewModel()) {
    val state by vm.uiState.collectAsStateWithLifecycle()

    when (val s = state) {
        is ChatUiState.Streaming -> StreamingText(s.partial)
        is ChatUiState.Done -> MessageBubble(s.full)
        is ChatUiState.Error -> RetryButton { vm.send(lastInput, ctx) }
        else -> {}
    }
}

컨텍스트 패킹: 토큰 낭비를 막아라

대화 히스토리 전체를 매번 보내면 토큰 비용이 빠르게 불어납니다. ContextPacker가 최근 N개 메시지와 요약본을 조합해서 전송 크기를 제한합니다.

data class PackedContext(val messages: List<Message>, val estimatedTokens: Int)

class ContextPacker(private val maxTokens: Int = 2048) {
    fun pack(history: List<Message>, summary: String?): PackedContext {
        val result = mutableListOf<Message>()
        var tokens = summary?.let { estimateTokens(it) } ?: 0

        for (msg in history.takeLast(20).reversed()) {
            val t = estimateTokens(msg.content)
            if (tokens + t > maxTokens) break
            result.add(0, msg)
            tokens += t
        }
        return PackedContext(result, tokens)
    }

    private fun estimateTokens(text: String) = (text.length / 3.5).toInt()
}

요약본이 있으면 시스템 메시지 앞에 붙이고, 최근 대화만 실제 메시지로 전송합니다.


주의사항과 한계

로컬 모델 경로는 디바이스 지원 여부를 먼저 확인해야 합니다. AICore는 모든 기기에 설치되어 있지 않고, 낮은 메모리 환경에서는 로컬 추론이 원격보다 느릴 수 있습니다. localEngine.isReady() 체크를 빠뜨리면 조용히 실패하는 케이스가 생깁니다.

스트림 취소 후 _uiState를 반드시 명시적으로 Idle로 되돌려야 합니다. streamJob?.cancel()만 하고 상태를 그대로 두면 Compose가 이전 Streaming 상태를 그대로 보여줍니다.

ContextPackerestimateTokens는 단순 근사치입니다. 실제 토크나이저 없이는 정확한 계산이 어려우므로, 안전 마진을 20% 정도 두는 것이 좋습니다.


지금 바로 시작하는 최소 단계

전체 구조를 한 번에 바꿀 필요는 없습니다. 지금 있는 ViewModel에서 API 호출 부분만 Flow<LlmChunk>를 반환하는 함수로 분리하는 것부터 시작하세요.

기존 suspend fun sendMessage() 하나를 Flow<LlmChunk>로 바꾸고, ViewModel에서 Streaming / Done / Error 세 가지 상태로 collect하도록 수정하면 됩니다. 라우터와 컨텍스트 패커는 그 다음 단계에서 붙이면 됩니다. 가장 자주 열리는 채팅 화면 파일 하나에만 먼저 적용해 보세요.

※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.

반응형