SpringDoc OpenAPI 실무 가이드

SpringDoc OpenAPI란? — API 문서 자동화의 표준

API 문서를 수동으로 관리하면 코드와 문서가 반드시 괴리된다. SpringDoc OpenAPI는 Spring Boot의 컨트롤러 코드에서 OpenAPI 3.0/3.1 스펙을 자동 생성하고, Swagger UI로 시각화한다. 기존 SpringFox(Swagger 2)의 후속으로, Spring Boot 3.x와 Jakarta EE를 네이티브 지원한다.

단순히 Swagger UI를 띄우는 것은 쉽다. 이 글에서는 @Schema·@Operation으로 정밀한 문서를 작성하고, 인증 헤더 자동 주입, 환경별 서버 URL 분리, 응답 예시 커스터마이징, 그리고 Spring Boot Profiles과 연동한 운영 환경 비활성화까지 실무 패턴을 총정리한다.

1. 기본 설정 — 의존성 하나로 Swagger UI 활성화

<!-- pom.xml -->
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>

의존성 추가만으로 다음 URL이 활성화된다:

  • /swagger-ui.html → Swagger UI (인터랙티브 문서)
  • /v3/api-docs → OpenAPI JSON 스펙
  • /v3/api-docs.yaml → OpenAPI YAML 스펙
# application.yml
springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha                    # 태그 알파벳 정렬
    operations-sorter: method             # HTTP 메서드 순 정렬
    display-request-duration: true        # 요청 소요 시간 표시
    filter: true                          # 검색 필터 활성화
  default-produces-media-type: application/json
  default-consumes-media-type: application/json

2. OpenAPI 메타데이터 — @OpenAPIDefinition과 Bean 설정

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI(
            @Value("${spring.application.name}") String appName,
            @Value("${app.version:1.0.0}") String appVersion) {
        return new OpenAPI()
            .info(new Info()
                .title(appName + " API")
                .version(appVersion)
                .description("주문 서비스 REST API 문서")
                .contact(new Contact()
                    .name("Backend Team")
                    .email("backend@example.com"))
                .license(new License()
                    .name("MIT")
                    .url("https://opensource.org/licenses/MIT")))
            .externalDocs(new ExternalDocumentation()
                .description("Confluence 설계 문서")
                .url("https://wiki.example.com/order-service"))
            // 환경별 서버 URL
            .servers(List.of(
                new Server().url("https://api.example.com").description("Production"),
                new Server().url("https://staging-api.example.com").description("Staging"),
                new Server().url("http://localhost:8080").description("Local")
            ));
    }
}

3. @Operation·@ApiResponse — 엔드포인트 문서화

@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "주문 생성·조회·취소 API")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @Operation(
        summary = "주문 생성",
        description = "새 주문을 생성합니다. 재고 확인 후 결제를 진행합니다."
    )
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "주문 생성 성공",
            content = @Content(schema = @Schema(implementation = OrderResponse.class))),
        @ApiResponse(responseCode = "400", description = "잘못된 요청",
            content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
        @ApiResponse(responseCode = "409", description = "재고 부족",
            content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
        @ApiResponse(responseCode = "500", description = "서버 오류",
            content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
    })
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        OrderResponse response = orderService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @Operation(summary = "주문 단건 조회")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "조회 성공"),
        @ApiResponse(responseCode = "404", description = "주문 없음")
    })
    @GetMapping("/{orderId}")
    public OrderResponse getOrder(
            @Parameter(description = "주문 ID", example = "ord_abc123")
            @PathVariable String orderId) {
        return orderService.findById(orderId);
    }

    @Operation(summary = "주문 목록 조회 (페이징)")
    @GetMapping
    public Page<OrderSummaryResponse> listOrders(
            @Parameter(description = "페이지 번호 (0부터)", example = "0")
            @RequestParam(defaultValue = "0") int page,
            @Parameter(description = "페이지 크기", example = "20")
            @RequestParam(defaultValue = "20") int size,
            @Parameter(description = "주문 상태 필터",
                       schema = @Schema(allowableValues = {"CREATED","CONFIRMED","SHIPPED","DELIVERED"}))
            @RequestParam(required = false) String status) {
        return orderService.findAll(page, size, status);
    }

    @Operation(summary = "주문 취소")
    @DeleteMapping("/{orderId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void cancelOrder(
            @PathVariable String orderId,
            @Valid @RequestBody CancelOrderRequest request) {
        orderService.cancel(orderId, request);
    }
}

4. @Schema — DTO 필드 문서화와 검증 연동

@Schema는 DTO의 각 필드에 설명, 예시, 제약 조건을 문서화한다. jakarta.validation 어노테이션과 함께 쓰면 검증 규칙이 OpenAPI 스펙에 자동 반영된다.

@Schema(description = "주문 생성 요청")
public record CreateOrderRequest(

    @Schema(description = "고객 ID", example = "cust_abc123", requiredMode = REQUIRED)
    @NotBlank(message = "고객 ID는 필수입니다")
    String customerId,

    @Schema(description = "주문 상품 목록", minLength = 1)
    @NotEmpty(message = "최소 1개 상품이 필요합니다")
    @Valid
    List<OrderItemRequest> items,

    @Schema(description = "통화 코드", example = "KRW",
            allowableValues = {"KRW", "USD", "JPY"}, defaultValue = "KRW")
    @Pattern(regexp = "^(KRW|USD|JPY)$")
    String currency,

    @Schema(description = "배송 메모", example = "문 앞에 놓아주세요", maxLength = 200)
    @Size(max = 200)
    String shippingNote

) {}

@Schema(description = "주문 상품")
public record OrderItemRequest(

    @Schema(description = "상품 ID", example = "prod_xyz789")
    @NotBlank
    String productId,

    @Schema(description = "수량", example = "2", minimum = "1", maximum = "100")
    @Min(1) @Max(100)
    int quantity

) {}

@Schema(description = "주문 응답")
public record OrderResponse(

    @Schema(description = "주문 ID", example = "ord_abc123")
    String orderId,

    @Schema(description = "주문 상태", example = "CREATED")
    OrderStatus status,

    @Schema(description = "총 금액 (원)", example = "45000")
    long totalAmount,

    @Schema(description = "주문 생성 시각", example = "2026-02-23T15:00:00Z")
    Instant createdAt,

    @Schema(description = "주문 상품 목록")
    List<OrderItemResponse> items

) {}

// Enum 문서화
@Schema(description = "주문 상태", enumAsRef = true)
public enum OrderStatus {
    @Schema(description = "주문 생성됨")
    CREATED,
    @Schema(description = "주문 확인됨")
    CONFIRMED,
    @Schema(description = "배송 중")
    SHIPPED,
    @Schema(description = "배송 완료")
    DELIVERED,
    @Schema(description = "주문 취소됨")
    CANCELLED
}

5. 인증 설정 — SecurityScheme과 JWT 자동 주입

Swagger UI에서 “Authorize” 버튼을 눌러 JWT 토큰을 입력하면, 이후 모든 요청에 Authorization: Bearer {token} 헤더가 자동 추가된다.

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info().title("Order API").version("1.0"))
            // Security Scheme 정의
            .components(new Components()
                .addSecuritySchemes("bearerAuth",
                    new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")
                        .description("JWT 토큰을 입력하세요 (Bearer 접두사 불필요)")))
            // 전역 Security 요구사항 — 모든 API에 적용
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
    }
}
// 특정 엔드포인트에서 인증 제외 (회원가입, 로그인 등)
@Operation(summary = "로그인", security = {})    // 빈 배열 → 인증 불필요
@PostMapping("/auth/login")
public TokenResponse login(@RequestBody LoginRequest request) {
    return authService.login(request);
}

// 특정 엔드포인트에 다른 인증 방식 적용
@Operation(summary = "관리자 전용",
    security = @SecurityRequirement(name = "bearerAuth"))
@GetMapping("/admin/dashboard")
public DashboardResponse adminDashboard() { ... }

6. API 그룹 분리 — GroupedOpenApi

서비스가 커지면 하나의 Swagger UI에 모든 API를 넣기 어렵다. GroupedOpenApi로 도메인별 문서를 분리할 수 있다.

@Configuration
public class OpenApiGroupConfig {

    @Bean
    public GroupedOpenApi publicApi() {
        return GroupedOpenApi.builder()
            .group("public")
            .displayName("Public API (v1)")
            .pathsToMatch("/api/v1/**")
            .pathsToExclude("/api/v1/admin/**")
            .build();
    }

    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi.builder()
            .group("admin")
            .displayName("Admin API")
            .pathsToMatch("/api/v1/admin/**")
            .addOpenApiCustomizer(openApi ->
                openApi.info(new Info()
                    .title("Admin API")
                    .description("관리자 전용 API")))
            .build();
    }

    @Bean
    public GroupedOpenApi internalApi() {
        return GroupedOpenApi.builder()
            .group("internal")
            .displayName("Internal API")
            .pathsToMatch("/internal/**")
            .build();
    }
}

Swagger UI 상단 드롭다운에서 그룹을 선택할 수 있다.

7. 응답 예시 커스터마이징 — @ExampleObject

@Operation(summary = "주문 생성")
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "성공",
        content = @Content(
            mediaType = "application/json",
            schema = @Schema(implementation = OrderResponse.class),
            examples = {
                @ExampleObject(name = "일반 주문",
                    summary = "일반 상품 주문 예시",
                    value = """
                    {
                      "orderId": "ord_abc123",
                      "status": "CREATED",
                      "totalAmount": 45000,
                      "createdAt": "2026-02-23T15:00:00Z",
                      "items": [
                        {"productId": "prod_001", "quantity": 2, "price": 22500}
                      ]
                    }
                    """),
                @ExampleObject(name = "해외 배송 주문",
                    summary = "USD 결제 + 해외 배송",
                    value = """
                    {
                      "orderId": "ord_xyz789",
                      "status": "CREATED",
                      "totalAmount": 150,
                      "currency": "USD",
                      "createdAt": "2026-02-23T15:00:00Z"
                    }
                    """)
            })),
    @ApiResponse(responseCode = "400", description = "유효성 검증 실패",
        content = @Content(
            schema = @Schema(implementation = ErrorResponse.class),
            examples = @ExampleObject(value = """
            {
              "code": "VALIDATION_ERROR",
              "message": "요청 데이터가 유효하지 않습니다",
              "errors": [
                {"field": "items", "message": "최소 1개 상품이 필요합니다"}
              ]
            }
            """)))
})
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
    // ...
}

8. 운영 환경 비활성화 — Profile 기반 제어

Swagger UI는 개발·스테이징에서는 필수지만, 프로덕션에서는 보안 위험이다. API 구조가 외부에 노출되고 인증 없이 호출할 수 있다.

# application-prod.yml — 프로덕션에서 비활성화
springdoc:
  api-docs:
    enabled: false
  swagger-ui:
    enabled: false
// 또는 Profile 기반 조건부 Bean
@Configuration
@Profile("!prod")    // prod가 아닐 때만 활성화
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() { ... }

    @Bean
    public GroupedOpenApi publicApi() { ... }
}
// Spring Security와 연동 — Swagger 경로 허용
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // Swagger UI 경로 허용 (개발 환경)
                .requestMatchers(
                    "/swagger-ui/**",
                    "/swagger-ui.html",
                    "/v3/api-docs/**",
                    "/v3/api-docs.yaml"
                ).permitAll()
                // 나머지는 인증 필요
                .anyRequest().authenticated()
            )
            .build();
    }
}

9. 고급 커스터마이징 — OperationCustomizer와 OpenApiCustomizer

// 모든 API에 공통 에러 응답 자동 추가
@Component
public class GlobalResponseCustomizer implements OperationCustomizer {

    @Override
    public Operation customize(Operation operation, HandlerMethod handlerMethod) {
        // 401, 500 응답을 모든 엔드포인트에 자동 추가
        ApiResponses responses = operation.getResponses();

        if (!responses.containsKey("401")) {
            responses.addApiResponse("401",
                new ApiResponse().description("인증 실패"));
        }
        if (!responses.containsKey("500")) {
            responses.addApiResponse("500",
                new ApiResponse().description("서버 내부 오류"));
        }

        return operation;
    }
}

// 전역 헤더 파라미터 추가
@Component
public class GlobalHeaderCustomizer implements OperationCustomizer {

    @Override
    public Operation customize(Operation operation, HandlerMethod handlerMethod) {
        operation.addParametersItem(
            new Parameter()
                .in("header")
                .name("X-Request-ID")
                .description("요청 추적 ID")
                .required(false)
                .schema(new StringSchema().example("req_abc123"))
        );
        return operation;
    }
}

// 특정 어노테이션이 있는 엔드포인트 문서에서 숨기기
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Hidden                                 // SpringDoc에서 제외
public @interface InternalOnly {}

// 또는 프로그래밍 방식으로 필터링
@Bean
public OpenApiCustomizer hideInternalApis() {
    return openApi -> {
        openApi.getPaths().entrySet()
            .removeIf(entry -> entry.getKey().startsWith("/internal/"));
    };
}

10. 테스트 — OpenAPI 스펙 검증

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OpenApiSpecTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void OpenAPI_스펙이_정상_생성된다() {
        ResponseEntity<String> response =
            restTemplate.getForEntity("/v3/api-docs", String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        // JSON 파싱하여 구조 검증
        JsonNode root = new ObjectMapper().readTree(response.getBody());
        assertThat(root.get("openapi").asText()).startsWith("3.");
        assertThat(root.get("info").get("title").asText()).isNotBlank();
        assertThat(root.get("paths")).isNotEmpty();
    }

    @Test
    void 주문_생성_API_스펙이_올바르다() {
        ResponseEntity<String> response =
            restTemplate.getForEntity("/v3/api-docs", String.class);

        JsonNode paths = new ObjectMapper().readTree(response.getBody()).get("paths");
        JsonNode createOrder = paths.get("/api/v1/orders").get("post");

        assertThat(createOrder.get("summary").asText()).isEqualTo("주문 생성");
        assertThat(createOrder.get("responses").has("201")).isTrue();
        assertThat(createOrder.get("responses").has("400")).isTrue();
    }

    // OpenAPI 스펙 파일로 저장 (CI에서 변경 감지용)
    @Test
    void OpenAPI_스펙_스냅샷_저장() throws Exception {
        ResponseEntity<String> response =
            restTemplate.getForEntity("/v3/api-docs.yaml", String.class);

        Files.writeString(
            Path.of("src/test/resources/openapi-spec.yaml"),
            response.getBody()
        );
    }
}

11. 운영 체크리스트

항목 권장 설정 위반 시 증상
프로덕션 비활성화 springdoc.enabled=false 또는 @Profile(“!prod”) API 구조·엔드포인트 외부 노출
인증 설정 SecurityScheme + 전역 SecurityRequirement Swagger UI에서 인증된 API 테스트 불가
DTO 문서화 @Schema(description, example) 필수 필드 작성 프론트엔드가 필드 의미를 추측
에러 응답 @ApiResponse로 4xx/5xx 응답 명시 클라이언트가 에러 처리 누락
API 그룹 분리 GroupedOpenApi로 도메인별 분리 수백 개 API가 한 페이지에 섞임
스펙 검증 CI에서 OpenAPI 스펙 변경 감지 테스트 Breaking change를 모르고 배포

마무리 — 좋은 문서는 코드에서 자동 생성된다

SpringDoc OpenAPI는 Spring Boot의 설정 체계와 자연스럽게 통합되어, 코드가 곧 문서가 되는 환경을 만든다. @Schema로 DTO를 문서화하고, @Operation·@ApiResponse로 엔드포인트를 명세하며, SecurityScheme으로 인증을 통합하라.

핵심은 세 가지다. 첫째, 프로덕션에서는 반드시 비활성화하라. 둘째, @Schema(example)@ExampleObject실제 사용 가능한 예시를 제공하라. 셋째, CI에서 OpenAPI 스펙 변경을 감지하여 Breaking Change를 사전에 차단하라. 이 세 가지를 갖추면 프론트엔드와의 협업 비용을 획기적으로 줄일 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux