우와한 개발자

[Spring AI] Spring AI PromptTemplate과 스트림 호출 — SSE, Flux, EventSource

by 우와한개발자

1. Prompt란?

  • AI에게 보내는 입력 텍스트 전체

1) System Prompt

  • AI의 역할, 성격, 행동 방식을 정의한다.
  • 대화 시작 전에 AI에게 "너는 어떤 존재야" 라고 설정하는 것이다.
  • 사용자에게는 보이지 않는다.
"너는 전문 영화 평론가야. 사용자가 입력한 정보를 기반으로 영화를 추천해줘."

2) User Prompt

  • 사용자가 실제로 입력하는 질문이나 요청이다.
"30대 여성이고 최근에 본 영화는 악마를 보았다야."

3) OpenAI API 요청 구조

{
  "messages": [
    {"role": "system",    "content": "너는 전문 영화 평론가야."},
    {"role": "user",      "content": "30대 여성이고 최근에 본 영화는 악마를 보았다야."}
  ]
}
role 설명
system 시스템 프롬프트. AI 역할 정의
user 사용자 프롬프트. 사용자 입력
assistant AI 응답. 멀티턴에서 이전 답변 기록

 

2. PromptTemplate이란?

  • Spring AI에서 제공하는 프롬프트 템플릿 클래스
  • 변수 자리({변수명})를 미리 정의해두고, 실행 시점에 실제 값을 채워 Prompt 객체를 생성한다.
"당신은 영화 평론가입니다. 사용자 입력: {userInput}"
                                            ↑
                                       실행 시점에 실제 값으로 교체
  • 고정된 프롬프트 구조를 코드에서 분리할 수 있다.
  • 동일한 템플릿에 다양한 입력값을 조합해 재사용이 가능하다.
  • 시스템 프롬프트와 사용자 입력을 하나의 템플릿으로 관리할 수 있다.

 

3. 일반 호출 vs 스트림 호출

  • OpenAI API에 요청을 보내는 방식은 두 가지다.
구분 일반 호출 (.call()) 스트림 호출 (.stream())
반환 타입 String Flux<String>
응답 방식 전체 완성 후 한 번에 반환 청크 단위로 즉시 전달
클라이언트 체감 대기 후 한 번에 표시 글자가 실시간으로 표시됨
사용 상황 단순 API 응답 SSE 스트리밍, 챗봇 UI

 

4. SSE(Server-Sent Events)란?

  • 서버에서 클라이언트로 데이터를 단방향으로 실시간 전송하는 방식
  • HTTP 연결을 유지한 채로 서버가 데이터를 조금씩 밀어낸다.
구분 HTTP 일반 요청 SSE
방향 클라이언트 → 서버 → 클라이언트 서버 → 클라이언트 (단방향)
연결 응답 후 연결 종료 연결 유지하며 지속 전송
데이터 형식 JSON 등 자유 data: 텍스트\\n\\n 형식
사용 상황 일반 API 실시간 스트리밍, 알림
data: 안\\n\\n
data: 녕\\n\\n
data: 하\\n\\n
data: 세\\n\\n
data: 요\\n\\n

 

5. 스트림 호출 전체 흐름

클라이언트 (브라우저)
    ↓  GET /movie/stream?q=...  (EventSource)
Spring 서버
    ↓  chatClient.prompt().stream()
OpenAI API
    ↓  청크 단위로 응답
Spring 서버
    ↓  SSE 형식으로 변환 (text/event-stream)
클라이언트 (브라우저)
    →  onmessage 콜백으로 청크 수신, 즉시 렌더링
구간 전달 방식 비고
OpenAI → Spring 청크 단위 스트림 .stream().content()
Spring 내부 Flux<String> 버퍼링 없이 즉시 전달
Spring → 클라이언트 SSE (text/event-stream) 청크마다 data: 이벤트 전송
클라이언트 EventSource onmessage 콜백으로 수신

 

 

6. 구현

1) build.gradle

ext { set('springAiVersion', "1.1.7") }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.ai:spring-ai-starter-model-openai'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
    }
}

2) application.properties

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini

3) Service

@Service
@RequiredArgsConstructor
public class MovieService {

    private final ChatClient chatClient;
    private final String systemPrompt = """
            당신은 전문 영화 평론가입니다.
            사용자가 입력한 정보를 기반으로 영화를 추천하세요
            사용자 입력: {userInput}
            """;
    private final PromptTemplate template = new PromptTemplate(systemPrompt);

    // 일반 호출
    public String recommendMovies(String userInput) {
        Prompt prompt = template.create(Map.of("userInput", userInput));
        return chatClient.prompt(prompt)
                .call()
                .content();
    }

    // 스트림 호출
    public Flux<String> streamRecommend(String userInput) {
        Prompt prompt = template.create(Map.of("userInput", userInput));
        return chatClient.prompt(prompt)
                .stream()
                .content()
                .map(this::preserveSseLeadingSpace)
                .concatWith(Flux.just("[[END]]"));
    }

    // data: 뒤에 오는 공백은 브라우저가 자동으로 제거하므로 보정
    private String preserveSseLeadingSpace(String chunk) {
        if (chunk == null) return "";
        return chunk.startsWith(" ") ? " " + chunk : chunk;
    }
}

4) Controller

@RestController
@RequiredArgsConstructor
public class MovieController {

    private final MovieService movieService;

    // 일반 호출
    @GetMapping("/movie/recommend")
    public String recommend(@RequestParam("q") String q) {
        return movieService.recommendMovies(q);
    }

    // produces 없으면 Spring이 Flux를 JSON 배열로 모아서 한번에 응답함
    @GetMapping(value = "/movie/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> stream(@RequestParam("q") String q) {
        return movieService.streamRecommend(q)
                .map(chunk -> ServerSentEvent.builder(chunk).build());
    }
}

5) 클라이언트 (HTML)

const url = "<http://localhost:8080/movie/stream?q=>" + encodeURIComponent(q);
const es = new EventSource(url);

es.onmessage = (e) => {
    if (e.data === "[[END]]") { es.close(); return; }
    document.getElementById("result").textContent += e.data;  // 청크 즉시 렌더링
};

es.onerror = () => {
    if (es.readyState === 2) { es.close(); }
};
항목 설명
new EventSource(url) SSE 연결 객체 생성. 서버에 GET 요청을 보내고 연결을 유지한다
onmessage 서버에서 청크가 올 때마다 호출되는 콜백
onerror 연결 오류 또는 서버가 연결을 끊었을 때 호출되는 콜백
close() SSE 연결 종료
readyState 연결 상태. 0=CONNECTING, 1=OPEN, 2=CLOSED
e.data 서버에서 받은 청크 데이터 (data: 이후의 값)

 

7. 주의사항

항목 내용
produces 누락 TEXT_EVENT_STREAM_VALUE 없으면 Flux가 JSON 배열로 버퍼링되어 한번에 전송
Spring MVC 혼용 금지 webflux와 web(MVC) 함께 쓰면 Tomcat 활성화되어 스트리밍 불가
서버 확인 로그에 "Netty started" 가 찍혀야 정상. "Tomcat started" 는 문제
localhost 환경 네트워크가 거의 0ms라 청크가 한번에 온 것처럼 보일 수 있음. 정상 동작
[[END]] 신호 스트림 종료를 클라이언트에게 알리기 위한 커스텀 종료 마커
공백 청크 처리 SSE 스펙상 data: 다음 공백이 무시되므로 preserveSseLeadingSpace로 보정

블로그의 정보

우와한개발자 님의 블로그

우와한개발자

활동하기