본문 바로가기

AI/OpenAI

Spring Boot 4에서 OpenAI Structured Outputs 구현하기: Responses API 응답을 Java DTO로 받는 방법

추천캐릭터 2026. 8. 1. 10:15
728x90

Spring Boot 4에서 OpenAI Structured Outputs 구현하기: Responses API 응답을 Java DTO로 받는 방법

메타 설명: Spring Boot 4에서 OpenAI 공식 Java SDK와 Responses API를 연결하고, Structured Outputs로 모델 응답을 Java DTO에 안전하게 매핑하는 방법을 구현합니다.
최종 확인: 2026년 7월 31일

LLM에게 “JSON으로 답해 줘”라고 요청했는데 필드가 빠지거나, 배열 대신 문자열이 오거나, enum에 없는 값이 반환된 경험이 있을 것입니다.

프롬프트만으로 JSON 형식을 요구하면 출력이 대체로 그럴듯해질 뿐, 애플리케이션이 기대하는 스키마까지 보장되지는 않습니다. JSON mode도 문법적으로 유효한 JSON은 보장하지만, 필수 필드와 타입까지 지켜 주는 기능은 아닙니다.

OpenAI의 Structured Outputs는 이 문제를 JSON Schema로 해결합니다. Spring Boot에서는 OpenAI 공식 Java SDK가 Java 클래스에서 스키마를 만들고, Responses API의 결과를 해당 클래스의 인스턴스로 변환하게 할 수 있습니다.

이 글에서는 다음 API를 구현합니다.

POST /api/ai/article-brief

요청: 기술 글 주제
응답: 제목, 요약, 키워드, 섹션을 가진 ArticleBrief DTO

먼저 확인할 2026년 7월의 중요한 변경

기존 예제 중에는 다음 스타터를 사용하는 코드가 많습니다.

com.openai:openai-java-spring-boot-starter

하지만 OpenAI 공식 저장소는 이 스타터가 Spring Boot 2.7 전용이며, 2026년 7월 27일부로 EOL이라고 명시했습니다. 마지막 버전은 4.45.0이고 이후 수정·테스트·호환성 지원을 받지 않습니다.

Spring Boot 3·4 애플리케이션의 권장 경로는 다음과 같습니다.

  1. openai-java-spring-boot-starter를 사용하지 않습니다.
  2. 프레임워크 중립적인 com.openai:openai-java에 직접 의존합니다.
  3. 애플리케이션이 OpenAIClient 빈을 명시적으로 제공합니다.

이 글도 그 방식으로 작성합니다.


Structured Outputs와 JSON mode의 차이

구분 JSON mode Structured Outputs
유효한 JSON 생성 보장 보장
필수 필드 존재 보장하지 않음 정상 완료 시 JSON Schema에 따라 보장
필드 타입 달라질 수 있음 정상 완료 시 스키마에 맞춤
enum 값 벗어날 수 있음 허용된 값으로 제한 가능
Java DTO 변환 별도 검증·재시도 필요 공식 SDK가 스키마 생성과 변환 지원

가능한 모델에서는 JSON mode보다 Structured Outputs를 우선하는 편이 안전합니다.

다만 구조화 출력은 형식의 정확성을 높이는 기능입니다. 답변 내용의 사실성까지 자동으로 검증하는 기능은 아닙니다.


1. 의존성 추가

아래 코드는 Spring Boot 4.1과 OpenAI Java SDK 4.47.0을 기준으로 확인했습니다.

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("com.openai:openai-java:4.47.0")
}

OpenAI SDK 버전은 Spring Boot가 관리하지 않으므로 명시해야 합니다. 이 글의 버전은 2026년 7월 31일 기준이며, 나중에 적용할 때는 공식 저장소의 최신 안정 버전을 다시 확인하는 것이 좋습니다.


2. API 키는 서버 환경변수로 주입

공식 Java SDK는 기본적으로 OPENAI_API_KEY 환경변수를 읽을 수 있습니다.

API 키를 다음 위치에 넣으면 안 됩니다.

  • Git에 커밋되는 application.yml
  • Java 소스 코드
  • 프론트엔드 환경변수나 브라우저 번들
  • 요청·응답 로그

API 키는 Spring Boot 서버 프로세스의 비밀 환경변수로 주입해야 합니다. 브라우저가 OpenAI API를 직접 호출하게 만들지 말고, 반드시 백엔드를 통과시키는 것이 안전합니다.


3. OpenAIClient 빈 등록

package com.example.ai.config;

import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class OpenAiConfig {

    @Bean
    OpenAIClient openAIClient() {
        return OpenAIOkHttpClient.builder()
                .fromEnv()
                .timeout(Duration.ofSeconds(30))
                .maxRetries(2)
                .build();
    }
}

여기서 중요한 설정은 두 가지입니다.

  • timeout: 외부 API가 느릴 때 애플리케이션 스레드가 무기한 기다리지 않게 합니다.
  • maxRetries: 일시적인 네트워크 오류를 제한적으로 재시도합니다.

SDK 기본 요청 제한 시간은 길 수 있으므로, 일반적인 HTTP API 안에서 호출한다면 서비스의 응답 시간 목표에 맞춰 더 짧은 값을 명시하는 편이 좋습니다.


4. 모델 응답용 DTO 설계

공식 Java SDK는 전달한 Java 클래스 구조를 바탕으로 JSON Schema를 만들 수 있습니다.

package com.example.ai.dto;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.util.List;

public final class ArticleBrief {

    @JsonPropertyDescription("ok 또는 unsupported")
    public String status;

    @JsonPropertyDescription("status가 unsupported일 때의 이유. ok이면 빈 문자열")
    public String reason;

    @JsonPropertyDescription("검색 의도가 분명한 한국어 기술 글 제목")
    public String title;

    @JsonPropertyDescription("글의 핵심 내용을 설명하는 2~3문장 요약")
    public String summary;

    @JsonPropertyDescription("중복 없는 핵심 검색 키워드 5개")
    public List<String> keywords;

    @JsonPropertyDescription("본문을 구성하는 순서가 있는 섹션 5~7개")
    public List<Section> sections;

    public static final class Section {

        @JsonPropertyDescription("섹션 제목")
        public String heading;

        @JsonPropertyDescription("이 섹션에서 설명할 핵심 내용")
        public String keyPoint;
    }
}

예제에서는 스키마 생성을 단순하게 확인하기 위해 public 필드를 사용했습니다. 실제 프로젝트에서는 AI 응답 전용 DTO를 외부 API 응답 DTO와 분리하는 편이 좋습니다.

statusreason을 둔 이유도 중요합니다. 사용자 입력이 기술 글로 만들 수 없는 내용인데 스키마에 정상 결과만 존재하면, 모델은 형식을 채우기 위해 그럴듯한 내용을 만들어 낼 수 있습니다. 처리할 수 없는 입력을 표현할 명시적인 분기를 스키마에 넣어야 합니다.

공식 SDK는 Java 클래스에서 만든 스키마를 기본적으로 로컬 검증한 뒤 요청합니다. OpenAI Structured Outputs가 지원하는 JSON Schema는 전체 표준의 일부이므로, 지나치게 깊은 중첩이나 지원하지 않는 타입을 사용하면 API 호출 전에 검증 오류가 발생할 수 있습니다.


5. 요청 DTO 검증

package com.example.ai.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ArticleBriefRequest(
        @NotBlank
        @Size(max = 200)
        String topic
) {
}

빈 문자열과 지나치게 긴 입력은 OpenAI API를 호출하기 전에 차단합니다. 입력 제한은 비용과 지연 시간을 통제하는 가장 단순한 방어선이기도 합니다.


6. Responses API 호출

package com.example.ai.service;

import com.example.ai.dto.ArticleBrief;
import com.openai.client.OpenAIClient;
import com.openai.models.responses.ResponseCreateParams;
import com.openai.models.responses.StructuredResponseCreateParams;
import org.springframework.stereotype.Service;

@Service
public class ArticleBriefService {

    private final OpenAIClient openAIClient;

    public ArticleBriefService(OpenAIClient openAIClient) {
        this.openAIClient = openAIClient;
    }

    public ArticleBrief generate(String topic) {
        String input = """
                당신은 Java와 Spring 백엔드 전문 기술 편집자다.

                다음 주제로 실무형 기술 글의 개요를 작성하라.
                주제: %s

                요구사항:
                - 검증되지 않은 버전, 가격, 출시 일정은 만들지 않는다.
                - 제목은 문제와 해결 방법이 드러나게 작성한다.
                - 키워드는 중복 없이 5개를 작성한다.
                - 본문 섹션은 5~7개로 만들고 독자가 구현할 수 있는 순서로 배열한다.
                - 기술 글로 만들기 어려운 입력이면 status를 unsupported로 설정하고
                  reason에 이유를 작성한다. 이때 title과 summary는 빈 문자열,
                  keywords와 sections는 빈 배열로 반환한다.
                """.formatted(topic);

        StructuredResponseCreateParams<ArticleBrief> params =
                ResponseCreateParams.builder()
                        .model("gpt-5.6")
                        .input(input)
                        .text(ArticleBrief.class)
                        .maxOutputTokens(2_000)
                        .store(false)
                        .build();

        return openAIClient.responses()
                .create(params)
                .output()
                .stream()
                .flatMap(item -> item.message().stream())
                .flatMap(message -> message.content().stream())
                .flatMap(content -> content.outputText().stream())
                .findFirst()
                .orElseThrow(() ->
                        new IllegalStateException("구조화된 모델 응답이 없습니다."));
    }
}

핵심은 다음 두 줄입니다.

.text(ArticleBrief.class)
.model("gpt-5.6")

text(ArticleBrief.class)를 호출하면 SDK가 ArticleBrief 구조에서 스키마를 만들고, 응답 텍스트를 같은 타입으로 변환합니다.

모델 ID는 문자열로 전달했습니다. SDK의 enum 업데이트 시점과 무관하게 공식 문서에 공개된 모델 ID를 사용할 수 있지만, 배포 시점에 계정·프로젝트에서 해당 모델을 실제로 사용할 수 있는지는 별도로 확인해야 합니다.

또한 Responses API는 응답 저장이 기본 동작일 수 있습니다. 이 예제는 민감한 기술 입력을 장기 상태로 유지할 필요가 없으므로 store(false)를 명시했습니다.


7. REST Controller 작성

package com.example.ai.controller;

import com.example.ai.dto.ArticleBrief;
import com.example.ai.dto.ArticleBriefRequest;
import com.example.ai.service.ArticleBriefService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/ai/article-brief")
public class ArticleBriefController {

    private final ArticleBriefService articleBriefService;

    public ArticleBriefController(ArticleBriefService articleBriefService) {
        this.articleBriefService = articleBriefService;
    }

    @PostMapping
    public ArticleBrief generate(
            @Valid @RequestBody ArticleBriefRequest request
    ) {
        return articleBriefService.generate(request.topic());
    }
}

요청 예시는 다음과 같습니다.

{
  "topic": "Spring Boot에서 외부 API 타임아웃과 재시도 설계"
}

정상 응답은 항상 애플리케이션이 정의한 형태를 따릅니다.

{
  "status": "ok",
  "reason": "",
  "title": "Spring Boot 외부 API 타임아웃과 재시도 설계",
  "summary": "외부 API 장애가 내부 스레드와 요청을 고갈시키는 과정을 설명하고, 제한 시간과 재시도 정책을 설계합니다.",
  "keywords": [
    "Spring Boot",
    "외부 API",
    "타임아웃",
    "재시도",
    "장애 격리"
  ],
  "sections": [
    {
      "heading": "왜 제한 시간이 필요한가",
      "keyPoint": "연결과 응답 대기가 서버 자원을 점유하는 과정을 설명한다."
    },
    {
      "heading": "연결과 응답 타임아웃 분리",
      "keyPoint": "실패 단계에 맞게 제한 시간을 나누는 기준을 정리한다."
    },
    {
      "heading": "재시도 가능한 오류 구분",
      "keyPoint": "일시 오류와 영구 오류를 나누고 무제한 재시도를 막는다."
    },
    {
      "heading": "장애 격리와 대체 응답",
      "keyPoint": "외부 장애가 내부 자원을 고갈시키지 않도록 경계를 설계한다."
    },
    {
      "heading": "관측과 검증",
      "keyPoint": "타임아웃 수, 재시도 수, 최종 실패율을 측정하고 부하 테스트한다."
    }
  ]
}

Structured Outputs와 function calling은 언제 구분할까

두 기능은 목적이 다릅니다.

목적 선택
모델의 최종 답변을 DTO 형태로 받고 싶다 Structured Outputs
모델이 주문 조회, DB 검색, 사내 API 같은 기능을 호출해야 한다 function calling
도구 실행 결과를 다시 모델에 전달해 후속 판단까지 시킨다 function calling
화면 렌더링용 카드·목록·분석 결과를 일정한 구조로 받고 싶다 Structured Outputs

모델이 우리 시스템의 기능을 실행해야 한다면 function calling을 사용하고, 모델의 최종 답변 모양을 고정하고 싶다면 Structured Outputs를 사용하면 됩니다.


운영 코드에서 반드시 추가할 예외 처리

위 코드는 핵심 흐름을 보여 주는 최소 예제입니다. 운영에서는 실패를 하나의 500 Internal Server Error로 뭉치면 안 됩니다.

최소한 다음 상황을 구분해야 합니다.

  1. 입력 검증 실패
    빈 값이나 길이 초과는 OpenAI 호출 전에 400 Bad Request로 반환합니다.

  2. 인증·권한 오류
    잘못된 API 키나 모델 접근 권한 문제는 배포 설정 오류로 분류합니다. 사용자에게 내부 키 정보를 노출하지 않습니다.

  3. 요청 한도 초과
    rate limit은 짧은 재시도, 큐잉 또는 503 Service Unavailable 정책을 적용합니다.

  4. 타임아웃·네트워크 장애
    외부 의존성 장애로 분류하고, 무제한 재시도를 금지합니다.

  5. refusal 또는 incomplete 응답
    안전상 거절과 출력 토큰 부족은 “DTO가 비었다”는 같은 오류로 처리하지 말고 별도 상태로 기록해야 합니다.

  6. 구조화 결과 없음
    모델이 메시지 대신 다른 output item을 반환할 수 있으므로 빈 결과를 명시적으로 처리합니다.

프론트엔드에는 내부 SDK 예외를 그대로 전달하지 말고, 서비스가 관리하는 에러 코드로 변환하는 편이 좋습니다.

구조화 응답 변환에 실패하면 SDK 예외 메시지에 원본 JSON이 포함될 수 있습니다. 사용자 입력이나 민감한 결과를 다루는 서비스에서는 예외 전체를 그대로 로그에 남기지 말고 필요한 정보만 비식별화해 기록해야 합니다.


테스트 전략

외부 API를 호출하는 테스트를 모든 빌드에서 실행하면 느리고 불안정하며 비용도 발생합니다.

다음처럼 경계를 분리하는 것이 좋습니다.

Controller
    ↓
ArticleBriefUseCase
    ↓
OpenAiArticleBriefGateway
    ↓
OpenAIClient
  • Controller와 서비스 테스트에서는 OpenAiArticleBriefGateway를 가짜 구현으로 대체합니다.
  • SDK 변환 코드는 소수의 통합 테스트로 검증합니다.
  • 실제 모델 품질은 일반 단위 테스트가 아니라 고정 데이터셋과 평가 기준으로 관리합니다.
  • 프롬프트나 모델을 바꿀 때는 동일 입력에 대한 성공률, 거절률, 지연 시간, 토큰 사용량을 비교합니다.

Structured Outputs는 파싱 실패를 크게 줄여 주지만, “좋은 답변인가”를 판단하는 평가까지 대신하지는 않습니다.


실무 체크리스트

  • Spring Boot 3·4에서 EOL된 openai-java-spring-boot-starter를 제거했다.
  • com.openai:openai-java를 직접 사용하고 OpenAIClient 빈을 등록했다.
  • API 키를 서버 환경변수로만 주입했다.
  • 요청 입력 길이와 빈 값을 검증한다.
  • 프롬프트가 아니라 DTO 스키마로 응답 형식을 강제한다.
  • 처리 불가능한 입력을 표현할 unsupported 분기를 만들었다.
  • 타임아웃, 제한적 재시도, 최대 출력 토큰을 설정했다.
  • 저장이 필요하지 않다면 store(false)를 명시했다.
  • refusal, incomplete, rate limit, timeout을 서로 다른 실패로 처리한다.
  • SDK를 애플리케이션 인터페이스 뒤에 감싸 테스트 가능하게 만들었다.

핵심 요약

  • “JSON으로 답해 줘”라는 프롬프트만으로는 DTO 계약을 보장할 수 없습니다.
  • JSON mode는 유효한 JSON을 만들지만, Structured Outputs는 JSON Schema 준수까지 보장합니다.
  • OpenAI 공식 Java SDK는 Java 클래스에서 스키마를 만들고 Responses API 결과를 해당 타입으로 변환할 수 있습니다.
  • Spring Boot 2 전용 OpenAI 스타터는 2026년 7월 27일 EOL입니다.
  • Spring Boot 3·4에서는 openai-java를 직접 사용하고 OpenAIClient 빈을 등록해야 합니다.
  • 형식이 정확해도 내용이 사실이라는 뜻은 아니므로 별도의 검증과 평가가 필요합니다.

함께 보면 좋은 글

공식 자료

728x90

'AI > OpenAI' 카테고리의 다른 글

GPT-5.6 Sol·Terra·Luna, 개발자가 알아야 할 변화  (0) 2026.07.09