Spring AI

구조화 출력

LLM의 답은 보통 자연어 문자열이다. 하지만 애플리케이션에서 쓰려면 String이 아니라 Java 객체여야 한다. Spring AI의 구조화 출력은 모델에 "이런 스키마로 답해"라고 지시하고, 돌아온 JSON을 자동으로 객체에 매핑까지 해 준다.

이 편에서 만드는 것

/structured/recipe(단일 객체) · /structured/movies(리스트) · /structured/facts(Map) 세 개의 엔드포인트.

flowchart LR
    U["요청"] --> CC["ChatClient<br/>.entity(Recipe.class)"]
    CC -->|① 스키마 지시 자동 첨부| LLM
    LLM -->|② JSON 응답| CC
    CC -->|③ JSON → 객체 파싱| O["Recipe 객체"]

1. 단일 객체 매핑 — .entity(Class)

.call() 뒤에 .content() 대신 .entity(타입) 을 쓰면, Spring AI가 (1) 해당 타입의 JSON 스키마를 프롬프트에 자동으로 덧붙이고, (2) 응답 JSON을 그 타입으로 파싱해 돌려준다.

public Recipe recipe(String dish) {
    return chatClient.prompt()
            .user(dish + " 요리의 레시피를 알려줘.")
            .call()
            .entity(Recipe.class);   // ← 스키마 지시 + 파싱을 자동 처리
}

// 매핑 대상은 평범한 record면 충분하다
public record Recipe(String name, List<String> ingredients, List<String> steps) {}
curl -G "http://localhost:8080/structured/recipe" --data-urlencode "dish=계란말이"
{
  "name": "계란말이",
  "ingredients": ["계란 4개", "소금 1/2 작은술", "후추 약간", "식용유"],
  "steps": ["계란을 풀어 소금·후추로 간한다.", "팬에 부어 익으면 말아 준다.", "...써서 담아낸다."]
}

record가 곧 스키마

필드 이름·타입이 그대로 LLM에게 전달할 스키마가 된다. 중첩 리스트(List<String>)도 자연스럽게 채워진다. 별도 어노테이션 없이 동작한다.


2. 리스트 매핑 — ParameterizedTypeReference

List<Movie> 같은 제네릭 타입은 타입 소거 때문에 List.class로 줄 수 없다. 대신 ParameterizedTypeReference로 정확한 타입을 알려 준다.

public List<Movie> recommendMovies(String genre) {
    return chatClient.prompt()
            .user(genre + " 장르 영화 3편을 추천해줘.")
            .call()
            .entity(new ParameterizedTypeReference<List<Movie>>() {});
}

public record Movie(String title, String director, int year) {}
curl -G "http://localhost:8080/structured/movies" --data-urlencode "genre=SF"
[
  {"title": "Dune", "director": "Denis Villeneuve", "year": 2021},
  {"title": "Inception", "director": "Christopher Nolan", "year": 2010},
  {"title": "Blade Runner 2049", "director": "Denis Villeneuve", "year": 2017}
]

3. 자유 구조 — Map

스키마를 미리 정하기 어려운 경우 Map<String, Object>로 받으면 LLM이 적절한 key-value를 채운다.

public Map<String, Object> facts(String topic) {
    return chatClient.prompt()
            .user(topic + "에 대한 핵심 정보를 key-value로 알려줘.")
            .call()
            .entity(new ParameterizedTypeReference<Map<String, Object>>() {});
}
curl -G "http://localhost:8080/structured/facts" --data-urlencode "topic=대한민국"
{ "capital": "서울", "population": 51780579, "currency": "원", "official_language": "한국어" }

정리

받고 싶은 타입

쓰는 법

단일 객체

.entity(Recipe.class)

리스트/제네릭

.entity(new ParameterizedTypeReference<List<Movie>>() {})

자유 구조

.entity(new ParameterizedTypeReference<Map<String,Object>>() {})

LLM은 사실을 틀릴 수 있다

구조화 출력은 형식을 보장할 뿐 내용의 정확성까지 보장하지 않는다. 위 예시에서 영화 감독을 틀리게 답할 수도 있다(환각). 사실이 중요한 도메인이라면 다음 편의 Tool Calling이나 RAG로 실제 데이터를 물려줘야 한다.

➡️ 다음 편: Tool Calling

마지막 수정 2026년 10월 22일