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를 사전에 차단하라. 이 세 가지를 갖추면 프론트엔드와의 협업 비용을 획기적으로 줄일 수 있다.