OpenAPI·Spring AI — 메뉴판과 사내 AI 어시스턴트

2026-05-02AWS SAA-C03 스터디

Spring Boot 3 핵심 정리 시리즈 12편. OpenAPI 3.0 명세를 SpringDoc으로 자동 생성하고, Rest Assured + Swagger Validator로 API 계약을 검증하고, Spring AI로 ChatClient·PromptTemplate·RAG 패턴까지 — 메뉴판 표준 양식과 사내 AI 어시스턴트 비유로 풀어쓴 12편.

📚 Spring Boot 3 핵심 정리 · 12편 / 14편 — 메뉴판과 사내 AI 어시스턴트

이 글은 Spring Boot 3 핵심 정리 시리즈의 열두 번째 편입니다. 11편에서 게이트웨이·Build Tools로 마이크로서비스 인프라를 단단히 했다면, 이번 12편은 API의 "겉면" 두 가지를 풉니다 — API를 외부에 어떻게 설명(OpenAPI 명세)할지, 그리고 그 API에 AI 어시스턴트를 어떻게 끼워(Spring AI) 넣을지.

본문 흐름은 회사 비유를 따라 풀어 가요. OpenAPI는 "메뉴판 표준 양식" 입니다. 음식점 메뉴판에 가격·재료·알레르기 정보가 정해진 칸에 들어가는 것처럼, OpenAPI는 모든 API의 엔드포인트·파라미터·응답을 표준 양식에 채워 넣어요. 사람도 기계도 한 번에 이해할 수 있게요. 그리고 Spring AI는 "사내 AI 어시스턴트" — 회사의 정해진 절차로 신청하면 AI가 답을 해 주는 헬프데스크 같은 존재예요.

왜 OpenAPI·Spring AI가 처음엔 어렵게 느껴질까요

이유는 네 가지예요.

첫째, OpenAPI와 OpenAI를 헷갈립니다. 이름이 한 글자 차이라 자주 혼동되는데 — OpenAPI(API 명세 표준)와 OpenAI(AI 모델 회사)는 완전히 다른 개념이에요. OpenAPI는 API 문서를, OpenAI는 GPT 모델을 가리킵니다.

둘째, Swagger·OpenAPI·SpringDoc·SpringFox 단어가 줄지어 나옵니다. 어느 게 표준이고 어느 게 라이브러리인지 한 번에 안 잡혀요.

셋째, 명세 우선(Spec-First) vs 코드 우선(Code-First) 둘 중 어느 쪽인지가 어려워요. 둘 다 장단점이 있어서 처음엔 결정이 안 섭니다.

넷째, Spring AI의 ChatClient·PromptTemplate·RAG가 처음 보면 추상적이에요. "이걸로 정확히 뭘 만들 수 있는가"가 안 보입니다.

해결법은 한 가지예요. OpenAPI = "메뉴판 표준 양식" / Spring AI = "사내 AI 어시스턴트" 로 잡고, 둘 다 기존 Spring 패턴(자동 구성·DI·DTO)을 그대로 활용한다는 사실을 머리에 박으면 갑자기 명확해집니다.

OpenAPI 표준 — 메뉴판 표준 양식

OpenAPI는 RESTful API를 기계가 읽을 수 있는 형식으로 설명하는 개방형 표준이에요. 2010년 Tony Tam이 만든 Swagger 프로젝트에서 시작해 — 2015년 SmartBear Software가 인수한 뒤 OpenAPI Initiative로 이관됐고, Swagger 2.0이 OpenAPI 2.0으로 개명됐어요. 현재 사용되는 OpenAPI 3.0은 Swagger라는 이름이 완전히 빠진 표준 버전입니다.

핵심 역할 네 가지:

  • API 엔드포인트·파라미터·요청/응답 형식을 구조적으로 문서화
  • 사람이 읽을 수 있는 API 문서(Swagger UI) 자동 생성
  • 다양한 언어의 클라이언트/서버 코드 자동 생성
  • API 제공자와 소비자 사이의 계약(Contract) 역할

회사 비유로 — 메뉴판이 없으면 손님이 매번 종업원에게 "오늘 뭐 있어요?" 물어봐야 해요. 종업원마다 다르게 대답하면 혼란이고요. OpenAPI 명세 한 장이 표준 메뉴판이라 — 손님(API 소비자)도 종업원(API 제공자)도 같은 정보를 같은 형식으로 보게 돼요.

Code-First vs Spec-First — 두 가지 개발 방식

OpenAPI는 두 가지 개발 접근으로 풀어요.

코드 우선(Code-First) — 소스 코드에 어노테이션을 박아서 OpenAPI 명세를 자동 생성. 구현과 문서가 항상 동기화되는 장점이 있지만, 명세가 최소한의 정보만 담을 수 있고 Breaking Change 감지가 어려워요.

명세 우선(Spec-First) — OpenAPI 명세를 먼저 작성하고 코드를 구현. API 계약을 팀의 단일 진실 공급원으로 삼아 제공자와 소비자가 동시에 개발할 수 있어요. 더 풍부하고 상세한 문서를 작성할 수 있고, Breaking Change를 사전에 감지합니다.

특성코드 우선명세 우선
명세 생성코드에서 자동사람이 직접 작성
명세 풍부도최소화풍부 (예시·설명 다수)
Breaking Change 감지어려움명세 검증으로 감지
병렬 개발어려움가능
단일 진실 공급원코드명세 파일
초기 비용낮음높음 (설계 시간)
장기 유지보수어려움용이

여기서 시험 함정이 하나 있어요. 장기 운영하는 팀일수록 명세 우선이 유리합니다. 코드 우선으로 시작했다가 나중에 명세 우선으로 갈아타기 어려우니, 새 프로젝트라면 처음부터 명세 우선을 고려해 볼 만해요.

SpringDoc — 자동 OpenAPI 생성기

SpringDoc은 Spring 프로젝트의 소스 코드를 리플렉션으로 분석해 OpenAPI 3.0 명세를 자동 생성하는 라이브러리예요. Spring Framework 6 / Spring Boot 3에는 반드시 SpringDoc v2를 써야 합니다.

<!-- Spring MVC용 -->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.2.0</version>
</dependency>

<!-- Spring WebFlux용 -->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
    <version>2.2.0</version>
</dependency>

의존성 추가 후 앱을 실행하면 자동으로 세 엔드포인트가 활성화돼요.

엔드포인트역할
/swagger-ui.htmlSwagger UI — 시각적으로 API 확인·테스트
/v3/api-docsJSON 형식 OpenAPI 명세
/v3/api-docs.yamlYAML 형식 OpenAPI 명세

API 정보 커스터마이징

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Product API")
                        .version("1.0.0")
                        .description("Spring Boot Product REST API")
                        .termsOfService("http://example.com/terms")
                        .contact(new Contact()
                                .name("API Support")
                                .email("support@example.com"))
                        .license(new License()
                                .name("Apache 2.0")
                                .url("http://springdoc.org")))
                .externalDocs(new ExternalDocumentation()
                        .description("SpringDoc Wiki")
                        .url("https://springdoc.org/v2"));
    }
}

컨트롤러에 어노테이션 — 코드 우선 방식

@Tag(name = "Product", description = "상품 관리 API")
@RestController
@RequestMapping(ProductController.PRODUCT_PATH)
public class ProductController {

    public static final String PRODUCT_PATH = "/api/v1/product";
    public static final String PRODUCT_PATH_ID = PRODUCT_PATH + "/{productId}";

    @Operation(summary = "상품 목록 조회", description = "모든 상품 목록을 반환합니다")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "성공",
                content = @Content(array = @ArraySchema(
                        schema = @Schema(implementation = ProductDTO.class)))),
        @ApiResponse(responseCode = "401", description = "인증 실패")
    })
    @GetMapping
    public List<ProductDTO> listProducts() {
        return productService.listProducts();
    }

    @Operation(summary = "상품 단건 조회")
    @Parameter(name = "productId", description = "상품 ID", required = true)
    @GetMapping("/{productId}")
    public ProductDTO getProductById(@PathVariable("productId") UUID productId) {
        return productService.getProductById(productId)
                .orElseThrow(NotFoundException::new);
    }
}

Spring Security와 SpringDoc 통합

Spring Security가 적용된 프로젝트에서는 Swagger UI 엔드포인트를 명시적으로 허용해야 해요.

@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/v3/api-docs/**",   // ** 와일드카드 필수!
                    "/swagger-ui/**",    // ** 와일드카드 필수!
                    "/swagger-ui.html"
                ).permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

여기서 정말 중요한 시험 함정 — /swagger-ui/에서 / 와일드카드가 없으면 Swagger UI가 필요한 CSS/JS 리소스를 로드하지 못해 깨진 UI가 표시돼요. 메인 페이지(/swagger-ui.html)만 허용하고 정적 리소스를 막은 형태가 가장 흔한 실수.

> 한 줄 정리 — SpringDoc v2 + Spring Boot 3 + 자동 생성 = /swagger-ui.html. Security와 같이 쓰면 /swagger-ui/·/v3/api-docs/ 와일드카드 필수.

Rest Assured — API 명세 검증 자동화

Atlassian의 Swagger Request Validator는 실제 API 구현이 OpenAPI 명세와 일치하는지 자동 검증하는 도구예요. 명세와 구현이 어긋나면 테스트 단계에서 잡힙니다.

이 라이브러리는 Rest Assured와 통합해서 사용해요. Rest Assured 자체가 BDD 스타일(given-when-then)로 API 테스트를 작성하는 인기 라이브러리고, 거기에 명세 검증 필터를 끼우는 거예요.

<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
</dependency>

<!-- Swagger Validator + Rest Assured 통합 -->
<dependency>
    <groupId>com.atlassian.oai</groupId>
    <artifactId>swagger-request-validator-rest-assured</artifactId>
    <version>2.32.0</version>   <!-- Parent POM 미관리 — 버전 명시 필수! -->
    <scope>test</scope>
</dependency>

기본 통합 테스트 설정

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProductControllerTest {

    @LocalServerPort
    Integer localPort;

    @BeforeEach
    void setUp() {
        RestAssured.baseURI = "http://localhost";
        RestAssured.port = localPort;
    }

    @Test
    void testListProducts() {
        given()
            .contentType(ContentType.JSON)
        .when()
            .get(ProductController.PRODUCT_PATH)
        .then()
            .statusCode(200)
            .body("size()", greaterThan(0))
            .body("[0].productName", notNullValue());
    }

    @Test
    void testGetProductById() {
        ProductDTO newProduct = ProductDTO.builder()
                .productName("Sample Product")
                .category("Books")
                .upc("123456")
                .price(new BigDecimal("9.99"))
                .build();

        // 생성 후 Location 헤더에서 ID 추출
        String location = given()
                .contentType(ContentType.JSON)
                .body(newProduct)
            .when()
                .post(ProductController.PRODUCT_PATH)
            .then()
                .statusCode(201)
                .extract()
                .header("Location");

        // 생성된 리소스 조회
        given()
        .when()
            .get(location)
        .then()
            .statusCode(200)
            .body("productName", equalTo("Sample Product"));
    }
}

OpenAPI 명세 검증 테스트

명세 검증 필터(OpenApiValidationFilter)를 Rest Assured 요청에 끼우면, 응답이 명세를 벗어나면 테스트가 실패해요.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class ProductControllerOpenApiTest {

    @LocalServerPort
    Integer localPort;

    @BeforeEach
    void setUp() {
        RestAssured.baseURI = "http://localhost";
        RestAssured.port = localPort;
    }

    @Test
    void testListProductsMatchesOpenApiSpec() {
        given()
            .filter(new OpenApiValidationFilter(
                    "http://localhost:" + localPort + "/v3/api-docs"))
            .contentType(ContentType.JSON)
        .when()
            .get(ProductController.PRODUCT_PATH)
        .then()
            .statusCode(200);
        // 응답이 명세를 준수하는지 자동 검증
    }
}

여기서 시험 함정 — @SpringBootTestwebEnvironment = RANDOM_PORT를 안 박으면 CI/CD 환경에서 포트 8080이 이미 사용 중이라 실패합니다. @LocalServerPort로 동적 포트를 받고 RestAssured.port에 설정하는 패턴이 정석.

또 하나 — swagger-request-validator-rest-assured는 Spring Boot Parent POM이 버전을 관리하지 않아 명시적으로 적어야 해요. 버전 빠뜨리면 빌드 실패.

명세·구현 검증의 자세한 사양은 SpringDoc 공식 사이트OpenAPI Specification에서 볼 수 있어요. Spring AI 부분은 Spring AI 공식 문서에서 확인하실 수 있고요.

> 한 줄 정리 — Rest Assured = BDD 스타일 API 테스트 / OpenApiValidationFilter = 응답이 명세 준수 여부 자동 검증.

Spring AI — 사내 AI 어시스턴트

이제 새 영역으로 넘어갑니다. Spring AI는 Spring 생태계에 AI 기능을 통합하기 위해 추가된 프로젝트예요. Java 개발자가 다양한 LLM(대규모 언어 모델 — OpenAI·Azure·Anthropic 등)을 추상화된 인터페이스로 일관되게 다룰 수 있도록 해 줍니다.

회사 비유로 — Spring AI는 사내 AI 어시스턴트 헬프데스크입니다. 직원이 "이런 질문에 답해 줘"라고 신청서(Prompt)를 내면, 헬프데스크가 적절한 AI 모델(GPT·Claude 등)에 전달하고, 응답을 표준 양식으로 돌려줘요. 어느 모델을 쓰는지는 사내 정책(설정)으로 정하고, 직원은 같은 신청서 양식만 쓰면 됩니다.

핵심 컴포넌트 4가지

컴포넌트역할
ChatClientAI와 대화하는 핵심 인터페이스
PromptTemplate{변수} 자리표시자가 있는 동적 프롬프트
MessageUserMessage·SystemMessage·AssistantMessage
ChatResponseAI 응답 + 메타데이터(토큰 사용량 등)

프로젝트 설정

<!-- Spring AI BOM으로 버전 일관성 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>0.8.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- OpenAI starter (Azure·Vertex AI 등도 같은 패턴) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    </dependency>
</dependencies>
# API 키는 환경 변수로 관리 (소스 코드에 직접 박지 X)
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-3.5-turbo
spring.ai.openai.chat.options.max-tokens=500
spring.ai.openai.chat.options.temperature=0.7

기본 AI 서비스 구현

Java record로 DTO를 깔끔하게 정의하고, Spring AI가 자동 구성하는 ChatClient.Builder를 주입받아 씁니다.

public record Question(String question) {}
public record Answer(String answer) {}

public interface AssistantService {
    String getAnswer(String question);
    Answer getAnswer(Question question);
}

@Service
public class AssistantServiceImpl implements AssistantService {

    private final ChatClient chatClient;

    public AssistantServiceImpl(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @Override
    public String getAnswer(String question) {
        ChatResponse response = chatClient.prompt()
                .user(question)
                .call()
                .chatResponse();
        return response.getResult().getOutput().getContent();
    }

    @Override
    public Answer getAnswer(Question question) {
        // PromptTemplate으로 구조화된 프롬프트
        PromptTemplate template = new PromptTemplate("""
                당신은 도움이 되는 AI 어시스턴트입니다.
                다음 질문에 한국어로 상세하게 답변해주세요.
                
                질문: {question}
                """);

        Prompt prompt = template.create(
                Map.of("question", question.question())
        );

        ChatResponse response = chatClient.prompt(prompt)
                .call()
                .chatResponse();

        return new Answer(response.getResult().getOutput().getContent());
    }
}

@RestController
@RequestMapping("/api/v1")
public class QuestionController {

    private final AssistantService assistantService;

    public QuestionController(AssistantService assistantService) {
        this.assistantService = assistantService;
    }

    @GetMapping("/question")
    public Answer askQuestion(@RequestParam("question") String question) {
        return new Answer(assistantService.getAnswer(question));
    }

    @PostMapping("/ask")
    public Answer askQuestion(@RequestBody Question question) {
        return assistantService.getAnswer(question);
    }
}

좋은 프롬프트의 5가지 구성

프롬프트 엔지니어링은 AI에서 원하는 결과를 얻기 위해 입력을 구조화하는 기술이에요. 좋은 프롬프트는 보통 다섯 요소로 구성돼요.

  1. 역할(Role) — "당신은 백엔드 전문가입니다"
  2. 맥락(Context) — 관련 배경 정보 제공
  3. 지시(Instruction) — 수행할 작업 명확히
  4. 형식(Format) — JSON·목록·표 등 응답 형식
  5. 예시(Example) — Few-shot으로 품질 향상

시스템 메시지로 역할 부여

@Service
public class AdvancedAssistantService {

    private final ChatClient chatClient;

    public AdvancedAssistantService(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String getExpertAnswer(String topic, String question) {
        String systemPrompt = """
                당신은 {topic} 분야의 전문가입니다.
                다음 지침을 반드시 따르세요:
                1. 정확하고 신뢰할 수 있는 정보만 제공하세요
                2. 불확실한 내용은 명확히 표시하세요
                3. 전문 용어는 간단한 설명과 함께 사용하세요
                """;

        PromptTemplate systemTemplate = new PromptTemplate(systemPrompt);
        Message systemMessage = systemTemplate.createMessage(Map.of("topic", topic));

        PromptTemplate userTemplate = new PromptTemplate("{question}");
        Message userMessage = userTemplate.createMessage(Map.of("question", question));

        Prompt prompt = new Prompt(List.of(systemMessage, userMessage));

        return chatClient.prompt(prompt).call().content();
    }
}

Few-shot 프롬프트 — 예시로 품질 끌어올리기

응답 형식을 정확히 맞추고 싶으면 예시 몇 개를 프롬프트에 포함합니다.

public String classifyCategory(String description) {
    String template = """
            다음 예시를 참고하여 상품 카테고리를 분류하세요:
            
            설명: "공기청정기, HEPA 필터, 8평형"
            분류: 가전
            
            설명: "기능성 베개, 메모리폼"
            분류: 침구
            
            설명: "휴대용 LED 스탠드, USB 충전"
            분류: 조명
            
            설명: "{description}"
            분류:
            """;

    return chatClient.prompt()
            .user(template.replace("{description}", description))
            .call()
            .content()
            .trim();
}

RAG — 외부 지식으로 답변 강화하기

RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 AI 모델의 한계 — 학습 데이터의 시간적 제한·특정 도메인 지식 부족 — 를 극복하는 패턴이에요. 외부 지식 베이스에서 관련 문서를 검색(Retrieval) 해서 프롬프트에 컨텍스트로 포함시키면, AI가 최신 또는 도메인 특화 정보를 기반으로 답을 생성(Generation) 합니다.

RAG 흐름 — 5단계

  1. 사용자 질문을 임베딩(벡터)로 변환
  2. 벡터 데이터베이스에서 유사 문서 검색
  3. 검색된 문서를 컨텍스트로 프롬프트에 포함
  4. AI 모델에 강화된 프롬프트 전달
  5. AI가 컨텍스트 기반으로 응답 생성
@Configuration
public class RagConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingClient embeddingClient) {
        // 인메모리 벡터 스토어 (프로덕션에서는 PgVector·Pinecone·Weaviate 등)
        return new SimpleVectorStore(embeddingClient);
    }
}

@Service
public class DocumentService {

    private final VectorStore vectorStore;

    public DocumentService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void loadDocuments(List<String> texts) {
        List<Document> docs = texts.stream()
                .map(text -> new Document(text))
                .collect(Collectors.toList());

        vectorStore.add(docs);   // 임베딩 후 저장
    }

    public List<Document> searchSimilarDocuments(String query, int topK) {
        return vectorStore.similaritySearch(
                SearchRequest.query(query).withTopK(topK)
        );
    }
}

@Service
public class RagService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public RagService(ChatClient.Builder builder, VectorStore vectorStore) {
        this.chatClient = builder.build();
        this.vectorStore = vectorStore;
    }

    public String answerWithContext(String question) {
        // 1. 유사 문서 검색 (상위 3개만 — 토큰 비용 절감)
        List<Document> relevantDocs = vectorStore.similaritySearch(
                SearchRequest.query(question).withTopK(3)
        );

        // 2. 검색된 문서를 컨텍스트로 변환
        String context = relevantDocs.stream()
                .map(Document::getContent)
                .collect(Collectors.joining("\n\n---\n\n"));

        // 3. RAG 프롬프트 구성
        String ragTemplate = """
                다음 컨텍스트를 기반으로 질문에 답변하세요.
                컨텍스트에 없는 정보는 모른다고 답변하세요.
                
                컨텍스트:
                {context}
                
                질문: {question}
                
                답변:
                """;

        PromptTemplate template = new PromptTemplate(ragTemplate);
        Prompt prompt = template.create(Map.of(
                "context", context,
                "question", question
        ));

        // 4. AI 호출
        return chatClient.prompt(prompt).call().content();
    }
}

RAG vs Fine-tuning

특성RAGFine-tuning
구현 복잡도중간높음
지식 업데이트즉시 가능재학습 필요
비용검색 비용학습 비용 (매우 높음)
최신 정보지원학습 시점 제한
투명성참조 문서 확인 가능블랙박스
적합 상황동적 지식·도메인 특화특정 스타일·형식 학습

여기서 시험 함정이 하나 있어요. 대부분의 도메인 특화 챗봇은 Fine-tuning이 아니라 RAG가 맞습니다. Fine-tuning은 학습 비용이 매우 높고, 지식이 바뀔 때마다 재학습이 필요해요. 반면 RAG는 벡터 스토어의 문서만 갱신하면 즉시 반영됩니다.

Spring RestClient — RestTemplate의 현대적 후계자

Spring AI 컨트롤러가 외부 AI API를 호출할 때나, 일반 마이크로서비스가 다른 서비스를 호출할 때 HTTP 클라이언트가 필요해요. Spring Framework 6.1에 RestClient가 새로 등장했어요. 이게 정확히 어디에 위치하느냐 — RestTemplate의 안정성을 유지하면서 WebClient처럼 fluent API를 제공하는 동기식 클라이언트.

@Service
public class ProductClientImpl {

    private final RestClient restClient;

    public ProductClientImpl(RestClient.Builder builder) {
        this.restClient = builder
                .baseUrl("http://localhost:8080")
                .build();
    }

    // GET — 단건
    public ProductDTO getProductById(UUID id) {
        return restClient.get()
                .uri("/api/v1/product/{id}", id)
                .retrieve()
                .body(ProductDTO.class);
    }

    // GET — 목록 (제네릭 타입)
    public List<ProductDTO> listProducts() {
        return restClient.get()
                .uri("/api/v1/product")
                .retrieve()
                .body(new ParameterizedTypeReference<List<ProductDTO>>() {});
    }

    // POST — 생성, Location 헤더 → 리소스 조회
    public ProductDTO createProduct(ProductDTO newProduct) {
        ResponseEntity<Void> response = restClient.post()
                .uri("/api/v1/product")
                .contentType(MediaType.APPLICATION_JSON)
                .body(newProduct)
                .retrieve()
                .toBodilessEntity();   // 본문 없는 응답 (201)

        URI location = response.getHeaders().getLocation();

        return restClient.get()
                .uri(location)
                .retrieve()
                .body(ProductDTO.class);
    }

    // 4xx/5xx 명시적 처리
    public Optional<ProductDTO> getProductByIdSafe(UUID id) {
        try {
            return Optional.ofNullable(
                    restClient.get()
                            .uri("/api/v1/product/{id}", id)
                            .retrieve()
                            .onStatus(HttpStatusCode::is4xxClientError,
                                    (req, res) -> {
                                        throw new NotFoundException("Product not found: " + id);
                                    })
                            .body(ProductDTO.class)
            );
        } catch (NotFoundException e) {
            return Optional.empty();
        }
    }
}

세 클라이언트 위치 한 번 더 정리.

클라이언트블로킹API 스타일Spring 버전권장 상황
RestTemplate블로킹메서드 기반 (구식)3+레거시 코드
RestClient블로킹Fluent (현대적)6.1+새 MVC 앱
WebClient논블로킹Fluent5+WebFlux 앱

여기서 정말 중요한 시험 함정 — .retrieve().body(ProductDTO.class)만 박으면 4xx/5xx 응답도 정상 처리 시도하다 파싱 오류가 나요. .onStatus(...)로 명시적 오류 처리가 정석.

> 한 줄 정리 — RestClient = 블로킹 + fluent. RestTemplate 레거시 + WebClient 리액티브 사이의 새 정답. .onStatus(...) 잊지 말기.

보안·비용·테스트 자주 만나는 함정

1. SpringFox(구) ↔ SpringDoc v2(신) 혼동

Spring Framework 6에선 SpringDoc v2만 동작. SpringFox는 2020년 이후 업데이트 없음.

2. Swagger UI가 깨져 보임

Spring Security에서 /swagger-ui/·/v3/api-docs/ 와일드카드 누락. 정적 리소스가 막혀서 그래요.

3. swagger-request-validator 버전 미지정

Parent POM이 관리 안 함. 명시 필수 (2.32.0 등).

4. AI API 키 하드코딩

spring.ai.openai.api-key=sk-proj-xxxxx 형태로 직접 박으면 Git 커밋 시 노출. 반드시 ${OPENAI_API_KEY} 환경 변수.

5. AI 토큰 비용 무제한

// 비싼 예 — 모든 문서 컨텍스트로
String context = allDocuments.stream()
        .map(Document::getContent)
        .collect(Collectors.joining());

// 비용 효율적 — 상위 3개만
List<Document> top = vectorStore.similaritySearch(
        SearchRequest.query(question).withTopK(3)
);

max-tokens 설정 + RAG topK 최소화 + 개발 시 저렴한 모델(gpt-3.5-turbo).

6. 프롬프트 인젝션 미방어

사용자 입력을 검증 없이 프롬프트에 박으면 "이전 지시 무시하고…" 같은 인젝션 공격 가능. PromptTemplate{변수} 영역에 sanitize된 입력을 넣어야 해요.

7. AI 테스트에서 실제 API 매번 호출

비용 폭주. 단위 테스트는 Mock, 통합 테스트만 실제 API. JUnit 태그(@Tag("integration"))로 분리.

8. AI 응답이 항상 올바른 JSON이라고 가정

AI가 마크다운 코드 블록(``json … `)으로 감싸 보낼 수도 있어요. 파싱 전 replaceAll("``json\\n?", "") 등으로 정리하고 재시도 로직 둬야 안전.

9. RestClient에서 4xx/5xx 무시

.onStatus() 없으면 오류 응답도 정상 흐름으로 가서 파싱 오류 발생.

10. @LocalServerPort 안 쓰고 고정 포트

CI 빌드 서버에서 8080 충돌. webEnvironment = RANDOM_PORT + @LocalServerPort 패턴.

핵심 압축 노트 — 시험 직전 한 번 더

여기까지가 12편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • OpenAPI ≠ OpenAI (명세 표준 vs AI 모델 회사)
  • OpenAPI 3.0 = Swagger의 최신 버전 (Swagger라는 이름 빠짐)
  • 두 방식 = 코드 우선(빠름·동기화) vs 명세 우선(풍부·장기 유지보수↑)
  • 장기 운영팀 → 명세 우선 추천
  • SpringDoc v2 = Spring Framework 6+ 필수 (SpringFox는 2020 멈춤)
  • 자동 노출 = /swagger-ui.html + /v3/api-docs + /v3/api-docs.yaml
  • Spring Security와 같이 쓸 때 /swagger-ui/·/v3/api-docs/ 와일드카드 필수
  • Rest Assured = BDD 스타일 (given-when-then)
  • OpenApiValidationFilter = 응답 명세 준수 자동 검증
  • @SpringBootTest(webEnvironment=RANDOM_PORT) + @LocalServerPort 패턴
  • swagger-request-validator-rest-assured 버전 명시 필수
  • @TestConfiguration + @ActiveProfiles("test") 로 테스트용 Security 분리
  • Spring AI = 여러 AI 모델 추상화 (OpenAI·Azure·Vertex 등)
  • 핵심 4종 = ChatClient / PromptTemplate / Message / ChatResponse
  • 호출 패턴 = chatClient.prompt().user("...").call().content()
  • 좋은 프롬프트 = 역할·맥락·지시·형식·예시 (5요소)
  • Few-shot = 예시 몇 개 박아 응답 품질 향상
  • System Message + User Message 조합으로 역할 부여
  • API 키는 환경 변수 (${OPENAI_API_KEY}) — 절대 하드코딩 X
  • RAG 5단계 = 임베딩 → 벡터 검색 → 컨텍스트 강화 → AI 호출 → 응답
  • RAG vs Fine-tuning — 동적 지식은 RAG가 정답
  • 벡터 스토어 = SimpleVectorStore(개발) / PgVector·Pinecone(프로덕션)
  • 토큰 비용 절감 = max-tokens + RAG topK=3 + 저렴한 모델
  • Spring RestClient (Spring 6.1+) = 블로킹 + fluent
  • 세 클라이언트 정리 — RestTemplate(레거시) / RestClient(새 MVC) / WebClient(WebFlux)
  • RestClient에서 .onStatus()로 4xx/5xx 명시적 처리 필수
  • AI 응답 JSON 파싱 — 마크다운 블록 정리 + 재시도 로직
  • AI 단위 테스트 = Mock / 통합 테스트 = 실제 API + JUnit 태그 분리

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!