본문 바로가기

AI 개발

Firebase AI Logic + Gemini 실전 가이드 2편 — 스트리밍, 멀티턴 채팅, 멀티모달, 구조화 출력

반응형

1편에서 기본 텍스트 생성까지 했습니다. 2편은 실제 앱에서 쓰는 패턴들입니다. 응답이 타이핑되듯 실시간으로 나오는 스트리밍, 대화 히스토리를 유지하는 멀티턴 채팅, 이미지·PDF·오디오를 넣는 멀티모달, JSON으로 구조화된 응답을 강제하는 structured output까지 Android Kotlin 기준으로 다룹니다.

[2편 핵심 요약]
→ 스트리밍: generateContentStream() → Flow<GenerateContentResponse> 수집
→ 멀티턴 채팅: startChat() → ChatSession → sendMessage() / sendMessageStream()
→ 채팅 히스토리: ChatSession이 자동 관리 — 별도 저장 불필요
→ 멀티모달 입력: 이미지(Bitmap/Uri/URL/Base64), PDF, 오디오, 영상
→ 20MB 초과 파일: Cloud Storage for Firebase 경유 필수
→ 구조화 출력: responseSchema로 JSON 스키마 강제 → 파싱 없이 바로 사용
→ 모든 SDK 메서드는 suspend function → Coroutine scope에서 호출 필수
→ 최신 BoM: firebase-bom:34.13.0 (공식 문서 기준)

 


실전 1 — 스트리밍 응답

기본 generateContent()는 전체 응답이 완성될 때까지 기다립니다. 긴 응답은 체감 대기 시간이 깁니다. generateContentStream()은 토큰이 생성되는 즉시 UI를 업데이트합니다.

// GeminiViewModel.kt — 스트리밍 구현
import com.google.firebase.Firebase
import com.google.firebase.ai.ai
import com.google.firebase.ai.type.GenerativeBackend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class GeminiViewModel : ViewModel() {

    private val _streamText = MutableStateFlow("")
    val streamText: StateFlow<String> = _streamText

    private val _isStreaming = MutableStateFlow(false)
    val isStreaming: StateFlow<Boolean> = _isStreaming

    private val model = Firebase.ai(backend = GenerativeBackend.googleAI())
        .generativeModel("gemini-3.1-flash-lite")

    fun generateStream(prompt: String) {
        viewModelScope.launch {
            _streamText.value = ""
            _isStreaming.value = true

            try {
                // generateContentStream() → Flow 반환
                model.generateContentStream(prompt).collect { chunk ->
                    // 청크 단위로 텍스트 누적
                    _streamText.value += chunk.text ?: ""
                }
            } catch (e: Exception) {
                _streamText.value = "오류: ${e.message}"
            } finally {
                _isStreaming.value = false
            }
        }
    }
}
// MainActivity.kt — 스트리밍 UI 연결
class MainActivity : AppCompatActivity() {
    private val viewModel: GeminiViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 스트리밍 텍스트 실시간 반영
        lifecycleScope.launch {
            viewModel.streamText.collect { text ->
                binding.tvResult.text = text
                // 텍스트 추가될 때마다 스크롤 최하단으로
                binding.scrollView.fullScroll(ScrollView.FOCUS_DOWN)
            }
        }

        // 스트리밍 중 버튼 비활성화
        lifecycleScope.launch {
            viewModel.isStreaming.collect { streaming ->
                binding.btnGenerate.isEnabled = !streaming
                binding.progressBar.isVisible = streaming
            }
        }

        binding.btnGenerate.setOnClickListener {
            viewModel.generateStream(binding.etPrompt.text.toString())
        }
    }
}
// Compose 버전 — 스트리밍 UI
@Composable
fun StreamingScreen(viewModel: GeminiViewModel = viewModel()) {
    val streamText by viewModel.streamText.collectAsState()
    val isStreaming by viewModel.isStreaming.collectAsState()
    var prompt by remember { mutableStateOf("") }

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = prompt,
            onValueChange = { prompt = it },
            label = { Text("프롬프트") },
            modifier = Modifier.fillMaxWidth()
        )
        Button(
            onClick = { viewModel.generateStream(prompt) },
            enabled = !isStreaming,
            modifier = Modifier.fillMaxWidth()
        ) {
            if (isStreaming) CircularProgressIndicator(modifier = Modifier.size(16.dp))
            else Text("생성")
        }
        // 스트리밍 텍스트 표시
        Text(
            text = streamText,
            modifier = Modifier
                .fillMaxWidth()
                .verticalScroll(rememberScrollState())
        )
    }
}
[generateContent vs generateContentStream]
generateContent():
→ 전체 응답 완성 후 반환
→ 짧은 응답, 구조화 출력에 적합
→ 응답 길수록 체감 대기 시간 ↑

generateContentStream():
→ 토큰 생성 즉시 청크 단위 전달
→ 긴 텍스트, 채팅 UI에 적합
→ chunk.text로 각 청크 접근
→ 전체 응답: response.text (스트리밍 완료 후)

실전 2 — 멀티턴 채팅

startChat()으로 ChatSession을 만들면 대화 히스토리를 자동 관리합니다. 매번 이전 메시지를 직접 전달할 필요 없습니다.

// ChatViewModel.kt
import com.google.firebase.ai.type.content

class ChatViewModel : ViewModel() {

    data class ChatMessage(
        val text: String,
        val isUser: Boolean,
        val isStreaming: Boolean = false
    )

    private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
    val messages: StateFlow<List<ChatMessage>> = _messages

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading

    private val model = Firebase.ai(backend = GenerativeBackend.googleAI())
        .generativeModel(
            modelName = "gemini-3.1-flash-lite",
            systemInstruction = content {
                text("당신은 친절한 AI 어시스턴트입니다. 한국어로 답변하세요.")
            }
        )

    // ChatSession 초기화 — 히스토리 자동 관리
    private val chat = model.startChat(
        history = listOf(
            // 초기 대화 히스토리 주입 (선택)
            content(role = "user") { text("안녕하세요!") },
            content(role = "model") { text("안녕하세요! 무엇을 도와드릴까요?") }
        )
    )

    fun sendMessage(userInput: String) {
        if (userInput.isBlank()) return

        viewModelScope.launch {
            // 사용자 메시지 추가
            _messages.value += ChatMessage(userInput, isUser = true)
            _isLoading.value = true

            // AI 응답 자리 확보 (스트리밍 중 업데이트용)
            _messages.value += ChatMessage("", isUser = false, isStreaming = true)

            try {
                var fullResponse = ""

                // 스트리밍 채팅 — sendMessageStream()
                // ChatSession이 히스토리 자동 관리
                chat.sendMessageStream(userInput).collect { chunk ->
                    fullResponse += chunk.text ?: ""
                    // 마지막 메시지 (AI 응답) 실시간 업데이트
                    val messages = _messages.value.toMutableList()
                    messages[messages.lastIndex] = ChatMessage(
                        fullResponse,
                        isUser = false,
                        isStreaming = true
                    )
                    _messages.value = messages
                }

                // 스트리밍 완료 — isStreaming false로 업데이트
                val messages = _messages.value.toMutableList()
                messages[messages.lastIndex] = ChatMessage(fullResponse, isUser = false)
                _messages.value = messages

            } catch (e: Exception) {
                val messages = _messages.value.toMutableList()
                messages[messages.lastIndex] = ChatMessage(
                    "오류가 발생했습니다: ${e.message}",
                    isUser = false
                )
                _messages.value = messages
            } finally {
                _isLoading.value = false
            }
        }
    }

    // 대화 히스토리 초기화
    fun clearChat() {
        _messages.value = emptyList()
        // 새 ChatSession 시작
        // chat = model.startChat() — val이라 재할당 불가
        // 실무에서는 var로 선언하거나 ViewModel 재생성
    }

    // 현재 채팅 히스토리 조회
    fun getChatHistory() = chat.history
}
// ChatScreen.kt — Compose UI
@Composable
fun ChatScreen(viewModel: ChatViewModel = viewModel()) {
    val messages by viewModel.messages.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    var input by remember { mutableStateOf("") }
    val listState = rememberLazyListState()

    // 새 메시지 추가 시 자동 스크롤
    LaunchedEffect(messages.size) {
        if (messages.isNotEmpty()) {
            listState.animateScrollToItem(messages.lastIndex)
        }
    }

    Column(modifier = Modifier.fillMaxSize()) {
        // 메시지 목록
        LazyColumn(
            state = listState,
            modifier = Modifier.weight(1f).padding(16.dp)
        ) {
            items(messages) { message ->
                ChatBubble(message)
                Spacer(modifier = Modifier.height(8.dp))
            }
        }

        // 입력창
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            OutlinedTextField(
                value = input,
                onValueChange = { input = it },
                placeholder = { Text("메시지 입력") },
                modifier = Modifier.weight(1f),
                enabled = !isLoading,
                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
                keyboardActions = KeyboardActions(
                    onSend = {
                        viewModel.sendMessage(input)
                        input = ""
                    }
                )
            )
            IconButton(
                onClick = {
                    viewModel.sendMessage(input)
                    input = ""
                },
                enabled = !isLoading && input.isNotBlank()
            ) {
                Icon(Icons.Default.Send, contentDescription = "전송")
            }
        }
    }
}

@Composable
fun ChatBubble(message: ChatViewModel.ChatMessage) {
    val alignment = if (message.isUser) Alignment.End else Alignment.Start
    val backgroundColor = if (message.isUser)
        MaterialTheme.colorScheme.primaryContainer
    else
        MaterialTheme.colorScheme.surfaceVariant

    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = alignment
    ) {
        Box(
            modifier = Modifier
                .background(backgroundColor, RoundedCornerShape(12.dp))
                .padding(12.dp)
                .widthIn(max = 280.dp)
        ) {
            Text(
                text = message.text + if (message.isStreaming) "▌" else "",
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}
[ChatSession 핵심 개념]
startChat():
→ ChatSession 반환
→ 이후 대화 히스토리 자동 관리

chat.sendMessage():
→ 단일 응답 (suspend function)

chat.sendMessageStream():
→ 스트리밍 응답 (Flow<GenerateContentResponse>)

chat.history:
→ 현재까지의 전체 대화 히스토리 접근
→ Content 객체 리스트

history 주입:
→ startChat(history = listOf(...))
→ DB에서 복원한 이전 대화 이어가기 가능

실전 3 — 멀티모달 입력

Gemini는 텍스트 외에 이미지·PDF·오디오·영상을 함께 입력받습니다.

이미지 분석

import android.graphics.Bitmap
import com.google.firebase.ai.type.content
import com.google.firebase.ai.type.inlineData

class MultimodalViewModel : ViewModel() {

    private val model = Firebase.ai(backend = GenerativeBackend.googleAI())
        .generativeModel("gemini-3.1-flash-lite")  // 멀티모달 지원 모델

    // 방법 1: Bitmap으로 직접 전달 (20MB 미만)
    fun analyzeImage(bitmap: Bitmap, prompt: String) {
        viewModelScope.launch {
            val response = model.generateContent(
                content {
                    image(bitmap)      // Bitmap 직접 전달
                    text(prompt)
                }
            )
            println(response.text)
        }
    }

    // 방법 2: Base64 인코딩 (소용량 이미지)
    fun analyzeImageBase64(imageBytes: ByteArray, prompt: String) {
        viewModelScope.launch {
            val response = model.generateContent(
                content {
                    inlineData(imageBytes, "image/jpeg")  // MIME 타입 명시
                    text(prompt)
                }
            )
            println(response.text)
        }
    }

    // 방법 3: URL로 전달
    fun analyzeImageUrl(imageUrl: String, prompt: String) {
        viewModelScope.launch {
            val response = model.generateContent(
                content {
                    fileData(imageUrl, "image/jpeg")  // URL + MIME 타입
                    text(prompt)
                }
            )
            println(response.text)
        }
    }

    // 여러 이미지 동시 분석
    fun compareImages(bitmap1: Bitmap, bitmap2: Bitmap) {
        viewModelScope.launch {
            val response = model.generateContent(
                content {
                    image(bitmap1)
                    image(bitmap2)
                    text("두 이미지의 차이점을 설명해줘")
                }
            )
            println(response.text)
        }
    }
}

PDF 분석

// PDF 분석 (Base64 인코딩)
fun analyzePdf(pdfBytes: ByteArray) {
    viewModelScope.launch {
        val response = model.generateContent(
            content {
                inlineData(pdfBytes, "application/pdf")
                text("이 PDF 문서의 핵심 내용을 요약해줘")
            }
        )
        println(response.text)
    }
}

// PDF Uri에서 읽기 (Android)
fun analyzePdfFromUri(context: Context, uri: Uri) {
    viewModelScope.launch {
        val pdfBytes = context.contentResolver
            .openInputStream(uri)
            ?.readBytes()
            ?: return@launch

        val response = model.generateContent(
            content {
                inlineData(pdfBytes, "application/pdf")
                text("이 계약서에서 주요 조항을 추출해줘")
            }
        )
        println(response.text)
    }
}

오디오 분석

// 오디오 분석
fun analyzeAudio(audioBytes: ByteArray, mimeType: String = "audio/mp3") {
    viewModelScope.launch {
        val response = model.generateContent(
            content {
                inlineData(audioBytes, mimeType)
                text("이 오디오 내용을 텍스트로 변환하고 요약해줘")
            }
        )
        println(response.text)
    }
}
[멀티모달 지원 입력 타입]
이미지: image/jpeg, image/png, image/gif, image/webp
문서:   application/pdf, text/plain, text/html, text/csv
오디오: audio/mp3, audio/wav, audio/aiff, audio/aac
영상:   video/mp4, video/mpeg, video/mov, video/webm

[파일 크기 제한]
20MB 미만: inlineData() 또는 image() 직접 전달
20MB 초과: Cloud Storage for Firebase 경유 필수
           → storage.reference.putBytes(bytes)
           → fileData(storageUri, mimeType)

실전 4 — 구조화 출력 (Structured Output)

기본 응답은 자유 형식 텍스트입니다. responseSchema를 지정하면 항상 정해진 JSON 구조로 응답하도록 강제합니다.

import com.google.firebase.ai.type.Schema
import com.google.firebase.ai.type.ResponseModality
import com.google.firebase.ai.type.generationConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

// 1. 데이터 클래스 정의
@Serializable
data class RecipeResponse(
    val name: String,
    val description: String,
    val ingredients: List<String>,
    val steps: List<String>,
    val cookingTimeMinutes: Int,
    val difficulty: String  // easy, medium, hard
)

class StructuredOutputViewModel : ViewModel() {

    // 구조화 출력 모델 — responseSchema 설정
    private val recipeModel = Firebase.ai(backend = GenerativeBackend.googleAI())
        .generativeModel(
            modelName = "gemini-3.1-flash-lite",
            generationConfig = generationConfig {
                responseMimeType = "application/json"
                responseSchema = Schema.obj(
                    properties = mapOf(
                        "name" to Schema.string("레시피 이름"),
                        "description" to Schema.string("레시피 한 줄 설명"),
                        "ingredients" to Schema.array(
                            Schema.string("재료"),
                            description = "재료 목록"
                        ),
                        "steps" to Schema.array(
                            Schema.string("단계"),
                            description = "조리 단계 목록"
                        ),
                        "cookingTimeMinutes" to Schema.integer("조리 시간(분)"),
                        "difficulty" to Schema.enumeration(
                            listOf("easy", "medium", "hard"),
                            description = "난이도"
                        )
                    ),
                    required = listOf("name", "description", "ingredients",
                                     "steps", "cookingTimeMinutes", "difficulty")
                )
            }
        )

    fun generateRecipe(ingredient: String): StateFlow<RecipeResponse?> {
        val result = MutableStateFlow<RecipeResponse?>(null)

        viewModelScope.launch {
            try {
                val response = recipeModel.generateContent(
                    "$ingredient 을 주재료로 한 레시피를 만들어줘"
                )

                // JSON 파싱 — 스키마 강제로 항상 올바른 구조
                val recipe = Json.decodeFromString<RecipeResponse>(
                    response.text ?: "{}"
                )
                result.value = recipe

            } catch (e: Exception) {
                println("오류: ${e.message}")
            }
        }

        return result
    }
}
// 더 복잡한 스키마 — 중첩 객체
private val productModel = Firebase.ai(backend = GenerativeBackend.googleAI())
    .generativeModel(
        modelName = "gemini-3.1-flash-lite",
        generationConfig = generationConfig {
            responseMimeType = "application/json"
            responseSchema = Schema.obj(
                properties = mapOf(
                    "products" to Schema.array(
                        Schema.obj(
                            properties = mapOf(
                                "name" to Schema.string("상품명"),
                                "price" to Schema.number("가격"),
                                "inStock" to Schema.boolean("재고 여부"),
                                "category" to Schema.enumeration(
                                    listOf("electronics", "clothing", "food"),
                                    description = "카테고리"
                                )
                            ),
                            required = listOf("name", "price", "inStock", "category")
                        ),
                        description = "상품 목록"
                    ),
                    "totalCount" to Schema.integer("총 상품 수"),
                    "searchQuery" to Schema.string("검색어")
                ),
                required = listOf("products", "totalCount", "searchQuery")
            )
        }
    )

// 구조화 출력 + 스트리밍 (지원 여부 모델마다 다름)
fun generateStructuredStream(prompt: String) {
    viewModelScope.launch {
        var fullJson = ""
        recipeModel.generateContentStream(prompt).collect { chunk ->
            fullJson += chunk.text ?: ""
        }
        // 스트리밍 완료 후 파싱
        val recipe = Json.decodeFromString<RecipeResponse>(fullJson)
        println(recipe)
    }
}
[Schema 타입 정리]
Schema.string()    → String
Schema.integer()   → Int/Long
Schema.number()    → Double/Float
Schema.boolean()   → Boolean
Schema.array()     → List<T>
Schema.obj()       → 중첩 객체
Schema.enumeration() → 열거형 (지정된 값만 허용)

[구조화 출력이 유용한 케이스]
→ 레시피, 상품 목록 등 데이터 카드 생성
→ 분류 태그 추출 (카테고리, 감정 등)
→ 폼 자동 채우기
→ 하위 작업 분해 (에이전트 계획 수립)
→ 다운스트림 시스템이 JSON을 기대하는 경우

실전 5 — 토큰 수 확인

// 요청 전 토큰 수 미리 확인 — 비용 예측
fun countTokens(prompt: String) {
    viewModelScope.launch {
        val response = model.countTokens(prompt)
        println("총 토큰: ${response.totalTokens}")
        println("총 청구 문자: ${response.totalBillableCharacters}")
    }
}

// 멀티모달 토큰 수 확인
fun countMultimodalTokens(bitmap: Bitmap, prompt: String) {
    viewModelScope.launch {
        val response = model.countTokens(
            content {
                image(bitmap)
                text(prompt)
            }
        )
        println("이미지 + 텍스트 토큰: ${response.totalTokens}")
    }
}

마무리

✅ 2편에서 한 것
→ 스트리밍: generateContentStream() + Flow 수집
→ 멀티턴 채팅: startChat() + sendMessageStream() + 히스토리 자동 관리
→ 멀티모달: 이미지(Bitmap/Base64/URL), PDF, 오디오 입력
→ 구조화 출력: responseSchema로 JSON 강제 → 파싱 불필요
→ 토큰 수 미리 확인: countTokens()

❌ 3편에서 다룰 것 (웹 실전)
→ JavaScript/TypeScript 동일 패턴
→ Next.js 서버사이드 vs 클라이언트사이드 선택
→ React 커스텀 훅으로 추상화
→ Firebase Hosting + Functions 통합
→ 웹에서 멀티모달 (File API, URL)

관련 글


 

반응형