본문 바로가기
안드로이드

네트워크 없어도 AI 동작한다 — Android에서 온디바이스 추론 직접 붙이는 법

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

어떤 프로젝트에 맞는가

이 글은 다음 환경을 전제로 합니다.

  • minSdk 26 이상 (Android 8.0+)
  • Kotlin 2.0+, Coroutines 1.8+
  • MVVM 또는 MVI 아키텍처
  • 텍스트 분류, 감성 분석, 이미지 분류 등 단순 추론 기능을 앱 안에 넣고 싶은 경우

클라우드 AI API를 쓰고 있는데 오프라인 지원이 필요하거나, API 호출 비용과 레이턴시가 부담이라면 이 글이 도움이 됩니다.


클라우드 API 의존의 전형적인 문제

앱에 AI 기능을 넣을 때 가장 빠른 방법은 클라우드 API를 호출하는 것입니다. 하지만 이 방식에는 실질적인 약점이 따릅니다.

네트워크가 불안정한 환경에서는 기능 자체가 멈춥니다. 응답 시간이 수 초 단위라 UX에 영향을 주고, API 비용은 사용자가 늘수록 선형으로 올라갑니다. 텍스트 분류처럼 단순한 작업에 외부 API를 쓰는 건 오버킬인 경우가 많습니다.


LiteRT 의존성 추가

LiteRT(구 TensorFlow Lite)는 Android/iOS/임베디드 환경에 맞게 최적화한 경량 추론 런타임입니다. .tflite 모델 파일을 앱 assets 폴더에 넣고 디바이스에서 직접 실행하는 방식입니다.

// app/build.gradle.kts
dependencies {
    implementation("org.tensorflow:tensorflow-lite:2.16.1")
    implementation("org.tensorflow:tensorflow-lite-support:0.4.4")
    implementation("org.tensorflow:tensorflow-lite-metadata:0.4.4")
}

android {
    aaptOptions {
        noCompress("tflite")
    }
}

noCompress("tflite")는 빠지기 쉬운 설정입니다. 없으면 모델 파일이 APK 내에서 압축되어 메모리 매핑이 불가능해지고, 로딩 속도가 급격히 느려집니다.


모델 로더 작성

import android.content.Context
import org.tensorflow.lite.Interpreter
import java.io.FileInputStream
import java.nio.MappedByteBuffer
import java.nio.channels.FileChannel

class TfliteModelLoader(private val context: Context) {

    fun load(assetFileName: String): Interpreter {
        val buffer = loadMappedBuffer(assetFileName)
        val options = Interpreter.Options().apply {
            numThreads = 4
        }
        return Interpreter(buffer, options)
    }

    private fun loadMappedBuffer(assetFileName: String): MappedByteBuffer {
        val fd = context.assets.openFd(assetFileName)
        val inputStream = FileInputStream(fd.fileDescriptor)
        return inputStream.channel.map(
            FileChannel.MapMode.READ_ONLY,
            fd.startOffset,
            fd.declaredLength
        )
    }
}

MappedByteBuffer를 쓰는 이유는 파일 전체를 힙에 올리지 않고 OS 레벨 메모리 매핑을 활용하기 위해서입니다. 모델 크기가 수십 MB라도 앱 메모리 부담이 최소화됩니다.


텍스트 분류 추론 실행

감성 분석 모델(긍정/부정 이진 분류)을 예시로 추론 흐름 전체를 보겠습니다.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class SentimentAnalyzer(private val interpreter: Interpreter) {

    // 모델 입력: [1, 256] int32 토큰 배열
    // 모델 출력: [1, 2] float32 확률 배열 [negative, positive]

    suspend fun analyze(tokenIds: IntArray): SentimentResult = withContext(Dispatchers.Default) {
        val input = Array(1) { tokenIds.copyOf(256) }
        val output = Array(1) { FloatArray(2) }

        interpreter.run(input, output)

        val negative = output[0][0]
        val positive = output[0][1]

        SentimentResult(
            label = if (positive > negative) "POSITIVE" else "NEGATIVE",
            confidence = maxOf(positive, negative)
        )
    }
}

data class SentimentResult(val label: String, val confidence: Float)

추론은 Dispatchers.Default에서 실행합니다. LiteRT Interpreter는 스레드 안전하지 않으므로 단일 인스턴스를 여러 코루틴에서 동시에 호출하면 안 됩니다. 동시 요청이 필요하다면 Mutex로 감싸거나 Channel 기반 큐를 씁니다.


ViewModel에서 연결하기

@HiltViewModel
class ReviewViewModel @Inject constructor(
    private val analyzer: SentimentAnalyzer,
    private val tokenizer: SimpleTokenizer
) : ViewModel() {

    private val _result = MutableStateFlow<SentimentResult?>(null)
    val result: StateFlow<SentimentResult?> = _result.asStateFlow()

    fun analyzeReview(text: String) {
        viewModelScope.launch {
            _result.value = analyzer.analyze(tokenizer.encode(text))
        }
    }
}

SimpleTokenizer는 모델 학습 시 사용한 어휘 사전과 동일한 방식으로 텍스트를 정수 시퀀스로 변환하는 클래스입니다. 모델에 따라 직접 구현하거나, TFLite Support Library의 BertTokenizer를 활용할 수 있습니다.


Before / After: API 방식 vs 온디바이스 방식

Before — 클라우드 API 방식:

// 네트워크 필수, 평균 응답 1~3초, 호출당 과금
suspend fun analyze(text: String): String {
    val response = apiService.analyze(AnalyzeRequest(text))
    return response.label
}

After — LiteRT 온디바이스 방식:

// 오프라인 동작, 평균 응답 20~80ms (중급 디바이스 기준), 추가 비용 없음
suspend fun analyze(text: String): SentimentResult {
    return analyzer.analyze(tokenizer.encode(text))
}

단순 분류 모델은 중급 Android 디바이스에서 20~80ms 내외로 응답합니다. 사용자 입장에서는 "즉각적으로" 느껴지는 수준이며, 항공기 모드에서도 동일하게 동작합니다.


주의사항과 한계

모델 파일 크기가 APK 용량에 직접 더해집니다. 텍스트 분류 모델은 수 MB 수준이지만 이미지 관련 모델은 수십 MB를 넘기도 합니다. 모델이 크다면 Play Asset Delivery로 런타임에 다운로드하는 방식을 검토하세요.

Interpreter 인스턴스는 생성 비용이 높습니다. Application 스코프나 싱글톤에서 하나만 만들어 재사용해야 합니다. 매 요청마다 새로 생성하면 첫 호출 레이턴시가 수백 ms로 올라갑니다.

모델 입출력 형식은 학습 시 결정된 것과 정확히 일치해야 합니다. 텐서 shape나 데이터 타입이 맞지 않으면 IllegalArgumentException이 런타임에 발생합니다. 모델 메타데이터 파일을 함께 보관하고 shape를 상수로 명시해 두는 습관이 중요합니다.

구형 디바이스에서는 NNAPI 가속이 없어 추론이 느릴 수 있습니다. Interpreter.Options에서 useNNAPI = false로 고정하면 CPU 폴백을 보장할 수 있어 예측 가능한 성능을 얻습니다.


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

오늘 시도할 수 있는 범위는 하나입니다. app/src/main/assets/ 폴더에 TFLite 텍스트 분류 샘플 모델 하나를 넣고, TfliteModelLoaderSentimentAnalyzer 코드를 복사해서 MainActivity 버튼 클릭에 연결해 보세요.

전체 아키텍처를 바꾸지 않아도 됩니다. 모델이 실제로 디바이스에서 실행되는 것을 눈으로 확인하는 것이 목표입니다. TensorFlow 공식 저장소의 lite/examples/text_classification 경로에서 미리 학습된 샘플 모델(.tflite)과 어휘 사전 파일을 내려받아 바로 쓸 수 있습니다.

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

반응형