๐Ÿ’ป ๊ฐœ๋ฐœ ์ผ์ง€/SpringBoot

[SpringBoot/Kotlin] SSE ํ™œ์šฉํ•œ AI Streaming Chat ๊ตฌํ˜„ (w. React) - (1) ์„œ๋ฒ„

์ ์ด 2025. 2. 11. 22:11
๋ฐ˜์‘ํ˜•

SSE ํ™œ์šฉํ•œ AI Streaming Chat ๊ตฌํ˜„ (w. React) - 1ํƒ„: ์„œ๋ฒ„ ๊ตฌํ˜„

๋ฏธ๋ฆฌ๋ณด๊ธฐ

์ „์ฒด ์ฝ”๋“œ๋Š” ์•„๋ž˜์—!

Spring Boot์™€ SSE๋ฅผ ํ™œ์šฉํ•˜์—ฌ AI Streaming Chat ์„œ๋น„์Šค๋ฅผ ๊ตฌํ˜„ํ•˜์˜€๋‹ค.

์‚ฌ์‹ค ์ฑ„ํŒ… ์ŠคํŠธ๋ฆฌ๋ฐ์„ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ๋‹ค์–‘ํ•œ ์–ธ์–ด, ๊ธฐ์ˆ ์ด ๋งŽ์ง€๋งŒ ํ˜„์—…์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์–ธ์–ด์™€ SSE๋ฅผ ๊ฒฝํ—˜ํ•˜๊ณ  ์‹ถ์–ด ํ•ด๋‹น ๊ธฐ์ˆ ์Šคํƒ์„ ํ™œ์šฉํ•˜์˜€๋‹ค.

(์ฆ‰, ์ŠคํŠธ๋ฆฌ๋ฐ ์ฑ„ํŒ…์„ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ๊ธฐ์ˆ  ์Šคํƒ์€ ๋‹ค์–‘ํ•˜๋ฏ€๋กœ ์ƒํ™ฉ๊ณผ ์กฐ๊ฑด์— ๋งž๊ฒŒ ์‚ฌ์šฉํ•˜๊ธธ ๋ฐ”๋ž€๋‹ค.)


SSE(Server-Sent-Event)๋ž€?

ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋‹จ๋ฐฉํ–ฅ ์ŠคํŠธ๋ฆฌ๋ฐ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์žˆ๋„๋ก ์ง€์›ํ•˜๋Š” ๊ธฐ์ˆ ์ด๋‹ค.

  • ๋‹จ๋ฐฉํ–ฅ ํ†ต์‹ : ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ๋งŒ ๋ฐ์ดํ„ฐ๋ฅผ ์ „์†กํ•  ์ˆ˜ ์žˆ์Œ
  • HTTP ๊ธฐ๋ฐ˜: ๊ธฐ์กด HTTP ํ”„๋กœํ† ์ฝœ์„ ์‚ฌ์šฉํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ฐฉํ™”๋ฒฝ ๋ฐ ํ”„๋ก์‹œ ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์šฉ์ดํ•จ
  • ์ž๋™ ์žฌ์—ฐ๊ฒฐ: ํด๋ผ์ด์–ธํŠธ์—์„œ SSE ์—ฐ๊ฒฐ์ด ๋Š์–ด์กŒ์„ ๊ฒฝ์šฐ ์ž๋™์œผ๋กœ ์žฌ์—ฐ๊ฒฐํ•˜๋Š” ๊ธฐ๋Šฅ ์ œ๊ณต
  • ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ์ „์†ก: JSON, XML ๋“ฑ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ํ…์ŠคํŠธ๋กœ ์ „์†ก ๊ฐ€๋Šฅ
  • ์†์‰ฌ์šด ๊ตฌํ˜„: ๋‹ค์–‘ํ•œ API๋กœ ์ œ๊ณต๋˜์–ด ๊ฐ„๋‹จํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Œ

 

Spring Boot SseEmitter

Spring Boot์—์„œ๋Š” SseEmitter ํด๋ž˜์Šค๋ฅผ ์ œ๊ณตํ•˜์—ฌ SSE๋ฅผ ์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค. (docs)

์ฃผ์š” ๋ฉ”์†Œ๋“œ

๋ฉ”์„œ๋“œ ์„ค๋ช…

send(Object data) ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ๋ฐ์ดํ„ฐ๋ฅผ ์ „์†ก
complete() SSE ์ŠคํŠธ๋ฆผ์„ ์ •์ƒ์ ์œผ๋กœ ์ข…๋ฃŒ
completeWithError(Throwable t) ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์ŠคํŠธ๋ฆผ ์ข…๋ฃŒ
onCompletion(Runnable callback) ์ŠคํŠธ๋ฆผ ์™„๋ฃŒ ์‹œ ์‹คํ–‰ํ•  ์ฝœ๋ฐฑ ์ง€์ •
onTimeout(Runnable callback) ์ŠคํŠธ๋ฆผ์ด ํƒ€์ž„์•„์›ƒ๋  ๊ฒฝ์šฐ ์‹คํ–‰ํ•  ์ฝœ๋ฐฑ ์ง€์ •

 

AI Streaming Chat with SseEmitter

OpenAiClient.kt

OpenAI API Request

OpenAI ์˜ ChatCompletion API ๋ฅผ ์œ„ํ•œ Request๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. (openai docs)

ํ•ด๋‹น ํ”„๋กœ์ ํŠธ๋Š” ์ •๊ตํ•œ ์„ค์ •์ด ํ•„์š”ํ•˜์ง€ ์•Š์•„, ํ•„์ˆ˜ ๊ฐ’๋งŒ ์ฑ„์›Œ๋„ฃ์„ ์ˆ˜ ์žˆ๋Š” DTO๋กœ ๊ตฌํ˜„ํ•˜์˜€๋‹ค.

val request = ChatCompletionRequest.build(message)

// Request DTO
data class ChatCompletionRequest(
    val model: String = "gpt-3.5-turbo",
    val messages: List<ChatCompletionMessageRequest>,
    val stream: Boolean = true, // --- (1)
) {
    data class ChatCompletionMessageRequest(
        val role: String = "user",
        val content: String,
    )

    companion object {
        fun build(message: String): ChatCompletionRequest = ChatCompletionRequest(
            messages = listOf(
                ChatCompletionMessageRequest(
                    content = message
                )
            )
        )
    }
}
  1. ์‘๋‹ต์„ Streaming์œผ๋กœ ๋ฐ›์•„์•ผํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ํ•ด๋‹น ๊ฐ’์€ true๋กœ ์„ค์ •ํ•˜์ž. ๋งŒ์•ฝ, ํ•œ๋ฒˆ์— ์‘๋‹ต์„ ๋ฐ›๊ณ  ์‹ถ๋‹ค๋ฉด ํ•„๋“œ ๊ฐ’์„ ์„ค์ •ํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ false๋กœ ์„ค์ •ํ•˜๋ฉด๋œ๋‹ค.

 

WebClient

์šฐ์„ , OpenAI์—์„œ ChatCompletion API์—์„œ ์–ด๋–ป๊ฒŒ ์‘๋‹ต์ด ์˜ค๋Š”์ง€ ํ™•์ธํ•ด๋ณด์ž.

์ด๋ฅผ ์œ„ํ•ด, ์‘๋‹ต ํŒŒ์‹ฑ์— ์ฃผ์˜๋ฅผ ๊ธฐ์šธ์—ฌ์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค.

๊ทธ๋Ÿผ ์•„๋ž˜ WebClient ์ฝ”๋“œ๋ฅผ ๋ณด์ž.

fun chatCompletion(message: String): SseEmitter {
    val request = ChatCompletionRequest.build(message)

    return SseEmitter().also { emitter ->
        WebClient.create()
            .post()
            .uri("<https://api.openai.com/v1/chat/completions>")
            .contentType(MediaType.APPLICATION_JSON)
            .header("Authorization", "Bearer $token")
            .body(BodyInserters.fromValue(request))
            .exchangeToFlux { response -> response.bodyToFlux() } // --- (1)
            .doOnNext { data -> // --- (2)
                if (data.equals("[DONE]")) {
                    emitter.complete()
                } else {
                    objectMapper.readValue(data, ChatCompletionResponse::class.java).getContent()
                        ?.let { emitter.send("\\"$it\\"") }
                }
            }
            .doOnComplete(emitter::complete)
            .doOnError(emitter::completeWithError)
            .subscribe()
    }
}
  1. `exchangeToFlux`: ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ์‘๋‹ต์„ Flux<String>์œผ๋กœ ๋ณ€ํ™˜
    1. `Flux`: 0๊ฐœ ์ด์ƒ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋น„๋™๊ธฐ์ ์œผ๋กœ ์ŠคํŠธ๋ฆฌ๋ฐํ•  ์ˆ˜ ์žˆ๋Š” Reactor์˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
    2. `bodyToFlux<String>`() : ์—ฐ์†๋œ ์‘๋‹ต Chunk๋ฅผ ํ•œ ์ค„์”ฉ `Flux`๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋น„๋™๊ธฐ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ŠคํŠธ๋ฆฌ๋ฐ ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•จ
  2. `doOnNext`: String์œผ๋กœ ์„ ๋ณ€ํ™˜ ๋œ ๋ฐ์ดํ„ฐ๋กœ ์•Œ๋งž์€ ์ฒ˜๋ฆฌ๋ฅผ ์ง„ํ–‰
    1. `"[DONE]"` : ์œ„ ์‹ค์ œ open ai ์‘๋‹ต์—์„œ ๋ณด์•˜๋“ฏ์ด ์œ„ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค๋ฉด ์ŠคํŠธ๋ฆฌ๋ฐ ์ข…๋ฃŒ๋ฅผ ์˜๋ฏธํ•จ
      1. `emitter.complete()`: SSE ์ŠคํŠธ๋ฆผ์„ ์ •์ƒ์ ์œผ๋กœ ์ข…๋ฃŒ์‹œํ‚ด
    2. ๊ทธ ์™ธ: json ํ˜•์‹์„ ChatCompletionResponse๋กœ ํŒŒ์‹ฑํ•จ
      1. `emitter.send(it)` : ๋ณ€ํ™˜๋œ Response ๋‚ด content๋ฅผ ๊ฐ€์ ธ์™€ SSE ์ŠคํŠธ๋ฆผ์œผ๋กœ ์ „์†ก

 

ChatController.kt

ํด๋ผ์ด์–ธํŠธ๋กœ SseEmitter๋กœ ์ „์†กํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜์ž.

Chat ์‘๋‹ต์€ ์ผ์ •ํ•œ Json ํฌ๋งท์œผ๋กœ ๋‚ด๋ ค์˜ค์ง€๋งŒ, ์‘๋‹ต์ด ์ข…๋ฃŒ๋˜์—ˆ์„ ๊ฒฝ์šฐ “[DONE]” ํ…์ŠคํŠธ๋กœ ๋‚ด๋ ค์˜ค๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. (docs์—๋„ ๋ช…์‹œ๋˜์–ด์žˆ์Œ)

@GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun chat(@RequestParam("query") message: String): SseEmitter {
    return openAiClient.chatCompletion(message)
}
  • `MediaType.TEXT_EVENT_STREAM_VALUE`
    • ํ•ด๋‹น MediaType์„ ์‚ฌ์šฉํ•˜๋ฉด, HTTP์‘๋‹ต์˜ Content-Type์ด text/event-stream์œผ๋กœ ์„ค์ •
      • ์ด๋Š” SSE ๋ฐฉ์‹์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ŠคํŠธ๋ฆฌ๋ฐํ•  ๊ฒƒ์„ ๋‚˜ํƒ€๋ƒ„
  • `SseEmitter` : ์„œ๋ฒ„์—์„œ ์ด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฉด, ํด๋ผ์ด์–ธํŠธ์—์„œ๋Š” `EventSource API`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‘๋‹ต ์ˆ˜์‹ 

 

๊ฒฐ๊ณผ

ํ•ด๋‹น API๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด, SSE ์‘๋‹ต์œผ๋กœ OpenAI๋กœ ๋ฐ›์€ ์‘๋‹ต ํ•œ๊ธ€์ž์”ฉ์„ Client๋กœ ๋‚ด๋ ค์ฃผ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

๋‹ค์Œ ๊ธ€์—์„œ๋Š” ์ด EventSource๋ฅผ ๋ฐ›์•„ Chat ํ™”๋ฉด์„ ๊ทธ๋ฆฌ๋Š” React ํ”„๋กœ์ ํŠธ์— ๋Œ€ํ•ด ์ž‘์„ฑํ•˜๊ฒ ๋‹ค.

(์‚ฌ์‹ค ํ”„๋ก ํŠธ ์ฝ”๋“œ๋Š” ๋‹ค GPT๊ฐ€ ์ž‘์„ฑํ•ด์คฌ๋‹ค ๐Ÿ˜‡)


์ „์ฒด ์ฝ”๋“œ

 

openai-chat/openai at master · jeongum/openai-chat

Contribute to jeongum/openai-chat development by creating an account on GitHub.

github.com

 

๋ฐ˜์‘ํ˜•