1. Prompt란?
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로 보정