이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Spring AI에서 structured output을 왜 쓰는가?
  • 모델 응답을 Java DTO로 받은 뒤 어떤 검증을 해야 하는가?
  • JSON 파싱 성공과 업무 안전성은 왜 다른가?
  • invalid output이 발생했을 때 어떤 fallback을 선택할 수 있는가?

개요

Structured output은 모델 응답을 문자열이 아니라 Java record/class로 받게 해준다. 백엔드에서는 이것이 중요하다. 문자열은 화면에 보여주기 쉽지만, 서버 로직은 DTO, validation, error handling, audit log와 함께 움직이기 때문이다.

하지만 DTO로 매핑됐다고 안전한 것은 아니다. 모델은 스키마를 맞추면서도 업무적으로 위험한 값을 만들 수 있다. 따라서 Bean Validation과 업무 rule 검증이 함께 필요하다.

원리

structured output을 안전하게 쓰는 흐름은 다음과 같다.

  • 출력 DTO를 작게 설계한다.
  • 필수값, 길이, enum, 범위 제약을 Bean Validation으로 표현한다.
  • 모델 응답을 DTO로 매핑한다.
  • Bean Validation을 실행한다.
  • 업무 rule을 추가 검증한다.
  • 실패 시 retry, fallback, human review 중 하나를 선택한다.

이 흐름을 거치면 모델 응답을 서버 계약으로 다루기 쉬워진다.

실습

환불 요청 검토 DTO를 만든다.

record RefundDecision(
        @NotNull Decision decision,
        @Size(min = 10, max = 500) String reason,
        @Min(0) @Max(100) int confidence
) {
}
 
enum Decision {
    APPROVE,
    REJECT,
    ESCALATE
}

모델 응답을 DTO로 받고 검증한다.

@Service
class RefundAiReviewer {
 
    private final ChatClient chatClient;
    private final Validator validator;
 
    RefundAiReviewer(ChatClient.Builder builder, Validator validator) {
        this.chatClient = builder
                .defaultSystem("Review refund requests and return a structured decision.")
                .build();
        this.validator = validator;
    }
 
    RefundDecision review(RefundCase refundCase) {
        RefundDecision decision = chatClient.prompt()
                .user(user -> user.text("""
                        policy:
                        {policy}
 
                        customerMessage:
                        {message}
                        """)
                        .param("policy", refundCase.policyText())
                        .param("message", refundCase.customerMessage()))
                .call()
                .entity(RefundDecision.class);
 
        Set<ConstraintViolation<RefundDecision>> violations = validator.validate(decision);
        if (!violations.isEmpty()) {
            throw new InvalidAiOutputException("Invalid refund decision: " + violations);
        }
 
        if (decision.decision() == Decision.APPROVE && decision.confidence() < 80) {
            return new RefundDecision(Decision.ESCALATE, "Low confidence approval requires human review.", decision.confidence());
        }
 
        return decision;
    }
}

성공 경로는 DTO 매핑, Bean Validation, 업무 rule이 모두 통과하는 것이다.

자주 나는 오류는 DTO를 너무 크게 만드는 것이다. 필드가 많고 nested 구조가 깊을수록 실패율과 디버깅 난도가 올라간다.

운영에서는 invalid output을 샘플링해 prompt와 schema를 개선하되, 원문 저장 시 민감정보를 마스킹한다.

실전 판단

structured output은 다음 기능에 먼저 적용하기 좋다.

  • ticket 분류
  • 요약 결과 metadata 추출
  • RAG 답변의 source id 목록
  • escalation 여부 판단
  • tool 실행 전 action plan 생성

반대로 복잡한 법적 판단이나 금전 승인처럼 위험이 큰 기능은 structured output만으로 자동 처리하지 말고 human review를 둔다.

실전 팁

  • DTO는 작은 record부터 시작한다.
  • enum 이름은 모델이 헷갈리지 않게 명확히 한다.
  • reason은 사람이 검토하기 좋지만 로그에 민감정보가 들어갈 수 있다.
  • confidence는 보조 신호로만 사용한다.
  • invalid output은 error type으로 집계한다.

위험 신호!

  • 모델 JSON을 바로 DB에 저장한다.
  • DTO validation 없이 업무 로직에 넘긴다.
  • invalid output을 무한 재시도한다.
  • 모델 confidence만 보고 승인/거절한다.
  • 출력 DTO가 내부 entity와 거의 같다.

확인 질문

확인 질문

  • structured output이 필요한 이유는 무엇인가?
    • 모델 응답을 서버가 검증하고 후속 처리할 수 있는 DTO 계약으로 만들기 위해서다.
  • Bean Validation만으로 충분하지 않은 이유는 무엇인가?
    • 값의 형태는 맞아도 업무 정책과 위험 기준은 별도 검증이 필요하기 때문이다.
  • invalid output이 발생하면 무엇을 해야 하는가?
    • 제한적 retry, fallback, human review, 로그 샘플링, prompt/schema 개선 중 하나를 선택해야 한다.
  • DTO를 작게 시작해야 하는 이유는 무엇인가?
    • 스키마가 클수록 모델 출력 실패와 디버깅 비용이 커지기 때문이다.

학습 연결

참고 문서