Theory
중급 2-3시간 이론 + 실습

Spring 16: API 문서화

Swagger/OpenAPI 완전 가이드

SwaggerOpenAPIAPI DocsSpringdoc

1. API 문서화 개념

1.1 OpenAPI Specification (OAS) 개요

OpenAPI Specification(OAS)은 REST API를 설명하기 위한 언어 중립적인 표준 규격입니다. 이전에는 Swagger Specification으로 알려져 있었으며, 현재는 Linux Foundation의 OpenAPI Initiative에서 관리하고 있습니다.

OpenAPI의 주요 특징:
  • 표준화: 업계 표준으로 널리 채택된 API 명세 형식
  • 언어 중립성: 특정 프로그래밍 언어에 종속되지 않음
  • 기계 판독 가능: JSON 또는 YAML 형식으로 작성
  • 도구 생태계: 다양한 코드 생성 및 문서화 도구 지원

OpenAPI 3.0 기본 구조

openapi: 3.0.3
info:
  title: E-Commerce API
  description: 온라인 쇼핑몰 API 서비스
  version: 1.0.0
  contact:
    name: API Support
    email: support@example.com
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://api.example.com/v1
    description: Production server
  - url: https://staging-api.example.com/v1
    description: Staging server

paths:
  /products:
    get:
      summary: 상품 목록 조회
      description: 등록된 상품들의 목록을 페이징하여 반환합니다.
      parameters:
        - name: page
          in: query
          description: 페이지 번호
          required: false
          schema:
            type: integer
            default: 1
            minimum: 1
        - name: size
          in: query
          description: 페이지 크기
          required: false
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
      responses:
        '200':
          description: 성공적으로 상품 목록을 반환
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  pagination:
                    $ref: '#/components/schemas/Pagination'

components:
  schemas:
    Product:
      type: object
      required:
        - id
        - name
        - price
      properties:
        id:
          type: integer
          format: int64
          example: 1
        name:
          type: string
          example: "스마트폰"
        price:
          type: number
          format: double
          example: 899000.00
    
    Pagination:
      type: object
      properties:
        page:
          type: integer
          example: 1
        size:
          type: integer
          example: 20
        totalElements:
          type: integer
          example: 150
        totalPages:
          type: integer
          example: 8

1.2 Swagger 도구 생태계

Swagger는 OpenAPI Specification을 구현하고 활용하기 위한 도구들의 집합입니다. API 설계, 문서화, 테스트, 코드 생성 등 API 개발 라이프사이클 전반을 지원합니다.

Swagger Editor

OpenAPI 명세를 작성하고 편집할 수 있는 웹 기반 에디터

  • 실시간 문법 검증
  • 자동 완성 기능
  • 미리보기 제공

Swagger UI

OpenAPI 명세를 시각적으로 표현하는 인터랙티브 문서

  • API 탐색 인터페이스
  • 실시간 API 테스트
  • 요청/응답 예제

Swagger Codegen

OpenAPI 명세로부터 클라이언트 SDK와 서버 스텁 생성

  • 40+ 언어 지원
  • 클라이언트 라이브러리 생성
  • 서버 스켈레톤 코드 생성

Swagger Inspector

기존 API를 테스트하고 OpenAPI 명세를 생성

  • API 호출 테스트
  • 자동 명세 생성
  • 응답 검증

1.3 API 문서화의 필요성과 이점

개발 생산성 향상

  • 개발자 간 소통 개선: 명확한 API 계약으로 프론트엔드-백엔드 개발자 간 협업 효율성 증대
  • 학습 곡선 단축: 새로운 팀원이 API를 빠르게 이해하고 활용 가능
  • 개발 시간 단축: API 사용법을 찾기 위한 시간 절약
  • 병렬 개발 지원: API 명세를 기반으로 클라이언트와 서버를 동시에 개발

품질 및 유지보수성 개선

  • 일관성 보장: 표준화된 문서 형식으로 API 일관성 유지
  • 오류 감소: 명확한 스키마 정의로 데이터 타입 오류 방지
  • 테스트 지원: 문서화된 예제를 통한 테스트 케이스 작성 용이
  • 버전 관리: API 변경 사항 추적 및 하위 호환성 관리

비즈니스 가치 창출

  • 외부 개발자 유치: 잘 문서화된 API로 개발자 생태계 구축
  • 파트너십 강화: 명확한 API 문서로 B2B 통합 촉진
  • 고객 만족도 향상: 사용하기 쉬운 API로 개발자 경험 개선
  • 시장 진입 가속화: 빠른 통합을 통한 제품 출시 시간 단축

1.4 문서화 없는 API의 문제점

실제 사례: 문서화 부족으로 인한 문제들

케이스 1: 데이터 타입 불일치

프론트엔드에서 문자열로 전송한 날짜를 백엔드에서 Date 객체로 파싱하려다 오류 발생

케이스 2: 필수 파라미터 누락

API 호출 시 필수 헤더나 파라미터를 누락하여 400 Bad Request 오류 반복 발생

케이스 3: 응답 구조 변경

서버 응답 구조 변경 후 클라이언트 코드 수정 누락으로 런타임 오류 발생

문서화 부족의 비용

  • 개발자 간 커뮤니케이션 비용 증가 (평균 30% 시간 소모)
  • API 사용법 파악을 위한 코드 분석 시간 (신규 개발자 기준 2-3일)
  • 잘못된 API 사용으로 인한 버그 수정 비용
  • 고객 지원 요청 증가 및 대응 비용
  • 파트너사 통합 지연으로 인한 기회비용

1.5 Spring Boot와 OpenAPI 통합의 장점

자동화된 문서 생성

  • 어노테이션 기반 자동 문서화
  • 코드와 문서의 동기화
  • 런타임 스키마 생성
  • 실시간 문서 업데이트

개발자 친화적

  • 기존 Spring 어노테이션 활용
  • 최소한의 추가 설정
  • IDE 지원 및 자동완성
  • 타입 안전성 보장

Spring Boot + OpenAPI 통합 예제

// 기존 Spring Boot Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        // 구현 로직
    }
}

// OpenAPI 어노테이션 추가 후
@RestController
@RequestMapping("/api/users")
@Tag(name = "User", description = "사용자 관리 API")
public class UserController {
    
    @GetMapping("/{id}")
    @Operation(summary = "사용자 조회", description = "ID로 사용자 정보를 조회합니다.")
    @ApiResponse(responseCode = "200", description = "조회 성공")
    @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음")
    public ResponseEntity<User> getUser(
        @Parameter(description = "사용자 ID") @PathVariable Long id) {
        // 구현 로직
    }
}

2. SpringDoc 설정

학습 목표: SpringDoc OpenAPI 라이브러리를 설정하고 Swagger UI를 구성하는 방법을 학습합니다.

2.1 SpringDoc OpenAPI 소개

SpringDoc OpenAPI는 Spring Boot 애플리케이션에서 OpenAPI 3 명세를 자동으로 생성하는 라이브러리입니다. 기존의 springfox-swagger를 대체하며, Spring Boot 3.x와 완벽하게 호환됩니다.

SpringDoc의 주요 장점

  • OpenAPI 3.0 완전 지원
  • Spring Boot 3.x 및 Spring 6 호환
  • 최소한의 설정으로 즉시 사용 가능
  • Spring WebFlux 지원
  • Spring Security 통합
  • 활발한 커뮤니티 지원
  • 자동 스키마 생성
  • 다양한 커스터마이징 옵션

2.2 의존성 추가

Maven 설정

<!-- pom.xml -->
<dependencies>
    <!-- SpringDoc OpenAPI UI -->
    <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
        <version>2.3.0</version>
    </dependency>
    
    <!-- Spring Boot Validation (선택사항) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    
    <!-- Spring Security (보안 설정 시 필요) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

Gradle 설정

// build.gradle
dependencies {
    // SpringDoc OpenAPI UI
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
    
    // Spring Boot Validation (선택사항)
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    
    // Spring Security (보안 설정 시 필요)
    implementation 'org.springframework.boot:spring-boot-starter-security'
}

2.3 기본 설정

application.yml 설정

# application.yml
springdoc:
  # OpenAPI 문서 경로 설정
  api-docs:
    path: /api-docs
    enabled: true
  
  # Swagger UI 설정
  swagger-ui:
    path: /swagger-ui.html
    enabled: true
    try-it-out-enabled: true
    operations-sorter: method
    tags-sorter: alpha
    display-request-duration: true
    doc-expansion: none
    
  # 패키지 스캔 설정
  packages-to-scan: com.example.api
  
  # 경로 매칭 설정
  paths-to-match: /api/**
  
  # 기본 정보 설정
  info:
    title: "E-Commerce API"
    description: "온라인 쇼핑몰 REST API 서비스"
    version: "1.0.0"
    contact:
      name: "개발팀"
      email: "dev@example.com"
      url: "https://example.com"
    license:
      name: "MIT License"
      url: "https://opensource.org/licenses/MIT"

# 서버 정보
server:
  port: 8080
  servlet:
    context-path: /

Java Configuration

@Configuration
@EnableWebMvc
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("E-Commerce API")
                        .version("1.0.0")
                        .description("온라인 쇼핑몰 REST API 서비스")
                        .contact(new Contact()
                                .name("개발팀")
                                .email("dev@example.com")
                                .url("https://example.com"))
                        .license(new License()
                                .name("MIT License")
                                .url("https://opensource.org/licenses/MIT")))
                .servers(Arrays.asList(
                        new Server()
                                .url("https://api.example.com")
                                .description("Production Server"),
                        new Server()
                                .url("https://staging-api.example.com")
                                .description("Staging Server"),
                        new Server()
                                .url("http://localhost:8080")
                                .description("Local Development Server")
                ))
                .externalDocs(new ExternalDocumentation()
                        .description("API 가이드 문서")
                        .url("https://docs.example.com/api-guide"));
    }

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

    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi.builder()
                .group("admin")
                .displayName("Admin API")
                .pathsToMatch("/api/admin/**")
                .build();
    }

    @Bean
    public GroupedOpenApi userApi() {
        return GroupedOpenApi.builder()
                .group("user")
                .displayName("User API")
                .pathsToMatch("/api/users/**")
                .build();
    }
}

2.4 Swagger UI 커스터마이징

UI 테마 및 레이아웃 설정

# application.yml - Swagger UI 고급 설정
springdoc:
  swagger-ui:
    # UI 경로 설정
    path: /swagger-ui.html
    config-url: /api-docs/swagger-config
    
    # 레이아웃 설정
    layout: StandaloneLayout
    deep-linking: true
    display-operation-id: false
    default-models-expand-depth: 1
    default-model-expand-depth: 1
    default-model-rendering: example
    
    # 필터 및 정렬
    filter: true
    operations-sorter: method
    tags-sorter: alpha
    
    # 기능 활성화/비활성화
    try-it-out-enabled: true
    request-duration: true
    validator-url: none
    
    # OAuth 설정 (필요시)
    oauth:
      client-id: your-client-id
      client-secret: your-client-secret
      realm: your-realm
      app-name: your-app-name
      scope-separator: " "
      additional-query-string-params: {}
    
    # 커스텀 CSS/JS
    custom-css-url: /custom/swagger-ui.css
    custom-js-url: /custom/swagger-ui.js

커스텀 CSS 적용

/* src/main/resources/static/custom/swagger-ui.css */
.swagger-ui .topbar {
    background-color: #2c3e50;
}

.swagger-ui .topbar .download-url-wrapper .select-label {
    color: white;
}

.swagger-ui .info .title {
    color: #2c3e50;
}

.swagger-ui .scheme-container {
    background: #ecf0f1;
    border-radius: 4px;
    padding: 10px;
}

.swagger-ui .opblock.opblock-post {
    border-color: #27ae60;
    background: rgba(39, 174, 96, 0.1);
}

.swagger-ui .opblock.opblock-get {
    border-color: #3498db;
    background: rgba(52, 152, 219, 0.1);
}

.swagger-ui .opblock.opblock-put {
    border-color: #f39c12;
    background: rgba(243, 156, 18, 0.1);
}

.swagger-ui .opblock.opblock-delete {
    border-color: #e74c3c;
    background: rgba(231, 76, 60, 0.1);
}

2.5 환경별 설정 관리

개발 환경 (application-dev.yml)

springdoc:
  swagger-ui:
    enabled: true
    try-it-out-enabled: true
  api-docs:
    enabled: true
logging:
  level:
    org.springdoc: DEBUG

운영 환경 (application-prod.yml)

springdoc:
  swagger-ui:
    enabled: false
  api-docs:
    enabled: false
# 보안상 운영에서는 비활성화

💡 실무 팁: 의존성 추가 후 애플리케이션을 실행하면 자동으로http://localhost:8080/swagger-ui.html에서 Swagger UI에 접근할 수 있습니다. OpenAPI JSON은/api-docs에서 확인 가능합니다.

3. API 어노테이션

학습 목표: SpringDoc의 핵심 어노테이션들을 활용하여 API를 상세하게 문서화하는 방법을 학습합니다.

3.1 @Operation - API 메서드 문서화

@Operation 어노테이션은 개별 API 엔드포인트의 동작을 설명하는 가장 중요한 어노테이션입니다. 메서드의 목적, 동작 방식, 주의사항 등을 상세히 기술할 수 있습니다.

기본 사용법

@RestController
@RequestMapping("/api/users")
@Tag(name = "User Management", description = "사용자 관리 API")
public class UserController {

    @Operation(
        summary = "사용자 정보 조회",
        description = "사용자 ID를 통해 특정 사용자의 상세 정보를 조회합니다. " +
                     "존재하지 않는 사용자 ID인 경우 404 오류를 반환합니다.",
        operationId = "getUserById",
        tags = {"User Management"}
    )
    @ApiResponses(value = {
        @ApiResponse(
            responseCode = "200", 
            description = "사용자 조회 성공",
            content = @Content(
                mediaType = "application/json",
                schema = @Schema(implementation = UserResponse.class)
            )
        ),
        @ApiResponse(
            responseCode = "404", 
            description = "사용자를 찾을 수 없음"
        )
    })
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(
        @Parameter(
            description = "조회할 사용자의 고유 ID", 
            required = true,
            example = "1"
        ) 
        @PathVariable Long id
    ) {
        UserResponse user = userService.findById(id);
        return ResponseEntity.ok(user);
    }
}

@Operation 주요 속성

필수 속성
  • summary: 간단한 요약 (50자 이내 권장)
  • description: 상세한 설명
선택 속성
  • operationId: 고유 식별자
  • tags: 그룹화 태그
  • deprecated: 사용 중단 표시

3.2 @Parameter - 매개변수 문서화

@Parameter 어노테이션은 API 메서드의 매개변수를 상세히 문서화합니다. 경로 변수, 쿼리 파라미터, 헤더, 쿠키 등 모든 종류의 매개변수에 사용할 수 있습니다.

다양한 매개변수 타입 문서화

@RestController
@RequestMapping("/api/products")
public class ProductController {

    @Operation(summary = "상품 목록 조회")
    @GetMapping
    public ResponseEntity<PagedResponse<Product>> getProducts(
        @Parameter(
            description = "페이지 번호 (1부터 시작)", 
            example = "1",
            schema = @Schema(type = "integer", minimum = "1")
        )
        @RequestParam(defaultValue = "1") int page,
        
        @Parameter(
            description = "페이지 크기 (최대 100)", 
            example = "20",
            schema = @Schema(type = "integer", maximum = "100")
        )
        @RequestParam(defaultValue = "20") int size,
        
        @Parameter(
            description = "상품명 검색 키워드", 
            example = "스마트폰",
            required = false
        )
        @RequestParam(required = false) String keyword,
        
        @Parameter(
            description = "클라이언트 언어 설정", 
            example = "ko-KR",
            in = ParameterIn.HEADER
        )
        @RequestHeader(value = "Accept-Language") String language
    ) {
        return ResponseEntity.ok(
            productService.getProducts(page, size, keyword, language)
        );
    }
}

3.3 @ApiResponse - 응답 문서화

@ApiResponse 어노테이션은 API의 가능한 응답들을 문서화합니다. 성공 응답뿐만 아니라 다양한 오류 상황에 대한 응답도 명시해야 합니다.

포괄적인 응답 문서화

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Operation(summary = "주문 생성")
    @ApiResponses(value = {
        @ApiResponse(
            responseCode = "201",
            description = "주문 생성 성공",
            content = @Content(
                mediaType = "application/json",
                schema = @Schema(implementation = OrderResponse.class),
                examples = @ExampleObject(
                    name = "성공 예제",
                    value = """
                    {
                        "orderId": "ORD-2024-001",
                        "status": "PENDING",
                        "totalAmount": 150000,
                        "createdAt": "2024-01-15T10:30:00Z"
                    }
                    """
                )
            )
        ),
        @ApiResponse(
            responseCode = "400",
            description = "잘못된 요청 데이터",
            content = @Content(
                mediaType = "application/json",
                schema = @Schema(implementation = ErrorResponse.class)
            )
        ),
        @ApiResponse(
            responseCode = "422",
            description = "비즈니스 로직 오류",
            content = @Content(
                mediaType = "application/json",
                examples = @ExampleObject(
                    name = "재고 부족",
                    value = """
                    {
                        "error": "INSUFFICIENT_STOCK",
                        "message": "상품 재고가 부족합니다.",
                        "productId": 1,
                        "availableQuantity": 2
                    }
                    """
                )
            )
        )
    })
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
        @RequestBody @Valid OrderCreateRequest request
    ) {
        OrderResponse order = orderService.createOrder(request);
        return ResponseEntity.status(201).body(order);
    }
}

3.4 @Schema - 데이터 모델 문서화

@Schema 어노테이션은 데이터 모델의 구조와 제약사항을 문서화합니다. DTO 클래스와 필드에 적용하여 상세한 스키마 정보를 제공할 수 있습니다.

DTO 클래스 문서화

@Schema(
    name = "UserCreateRequest",
    description = "사용자 생성 요청 데이터"
)
public class UserCreateRequest {

    @Schema(
        description = "사용자 이름",
        example = "홍길동",
        requiredMode = Schema.RequiredMode.REQUIRED,
        minLength = 2,
        maxLength = 50
    )
    @NotBlank(message = "이름은 필수입니다.")
    @Size(min = 2, max = 50)
    private String name;

    @Schema(
        description = "이메일 주소",
        example = "hong@example.com",
        requiredMode = Schema.RequiredMode.REQUIRED,
        format = "email"
    )
    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "올바른 이메일 형식이 아닙니다.")
    private String email;

    @Schema(
        description = "나이",
        example = "30",
        minimum = "18",
        maximum = "120"
    )
    @Min(value = 18, message = "나이는 18세 이상이어야 합니다.")
    @Max(value = 120, message = "나이는 120세 이하여야 합니다.")
    private Integer age;

    @Schema(
        description = "전화번호",
        example = "010-1234-5678",
        pattern = "^010-\\d{4}-\\d{4}$"
    )
    @Pattern(regexp = "^010-\\d{4}-\\d{4}$")
    private String phoneNumber;

    // getters and setters...
}

@Schema(description = "사용자 응답 데이터")
public class UserResponse {

    @Schema(description = "사용자 ID", example = "1")
    private Long id;

    @Schema(description = "사용자 이름", example = "홍길동")
    private String name;

    @Schema(description = "이메일 주소", example = "hong@example.com")
    private String email;

    @Schema(description = "계정 생성일", example = "2024-01-15T10:30:00Z")
    private LocalDateTime createdAt;

    @Schema(description = "계정 상태", example = "ACTIVE")
    private UserStatus status;

    // getters and setters...
}

3.5 실무 활용 팁

Best Practices

  • 모든 public API에 @Operation 적용
  • 예상 가능한 모든 응답 코드 문서화
  • 실제 사용 예제 제공
  • 비즈니스 로직 오류 상황 명시
  • deprecated API 명확히 표시

주의사항

  • 과도한 문서화로 코드 가독성 저해 방지
  • 예제 데이터의 개인정보 노출 주의
  • 실제 구현과 문서의 불일치 방지
  • 민감한 API의 과도한 노출 주의
  • 버전 업데이트 시 문서 동기화

💡 실무 팁: 어노테이션 작성 시 개발자의 관점이 아닌 API 사용자의 관점에서 작성하세요. "무엇을 하는가"보다는 "왜 사용하는가"와 "어떻게 사용하는가"에 초점을 맞추면 더 유용한 문서가 됩니다.

4. 모델 문서화

학습 목표: DTO 스키마 문서화, 예제 값 설정, 유효성 검증 문서화 방법을 학습합니다.

4.1 DTO 스키마 문서화

DTO(Data Transfer Object) 클래스는 API의 요청과 응답 데이터 구조를 정의합니다. @Schema 어노테이션을 활용하여 각 필드의 의미와 제약사항을 명확히 문서화할 수 있습니다.

기본 DTO 문서화

@Schema(
    name = "ProductCreateRequest",
    description = "상품 생성 요청 데이터"
)
public class ProductCreateRequest {

    @Schema(
        description = "상품명",
        example = "iPhone 15 Pro",
        requiredMode = Schema.RequiredMode.REQUIRED,
        minLength = 1,
        maxLength = 100
    )
    @NotBlank(message = "상품명은 필수입니다.")
    @Size(min = 1, max = 100)
    private String name;

    @Schema(
        description = "상품 가격 (원)",
        example = "1200000",
        requiredMode = Schema.RequiredMode.REQUIRED,
        minimum = "0"
    )
    @NotNull(message = "가격은 필수입니다.")
    @DecimalMin(value = "0")
    private BigDecimal price;

    @Schema(
        description = "카테고리 ID",
        example = "1",
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @NotNull(message = "카테고리는 필수입니다.")
    @Positive
    private Long categoryId;

    // getters and setters...
}

4.2 예제 값 설정

API 문서의 가독성을 높이기 위해 실제적이고 의미 있는 예제 값을 제공해야 합니다. @ExampleObject를 활용하여 다양한 시나리오의 예제를 제공할 수 있습니다.

다양한 예제 시나리오

@Schema(description = "주문 생성 요청")
public class OrderCreateRequest {

    @Schema(
        description = "주문 항목 목록",
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @NotEmpty(message = "주문 항목은 최소 1개 이상이어야 합니다.")
    @Valid
    private List<OrderItem> items;

    @Schema(
        description = "배송 주소",
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @NotNull(message = "배송 주소는 필수입니다.")
    @Valid
    private DeliveryAddress deliveryAddress;

    @Schema(
        description = "할인 쿠폰 코드",
        example = "WELCOME20"
    )
    private String couponCode;

    // getters and setters...
}

// Controller에서 예제 사용
@Operation(summary = "주문 생성")
@ApiResponse(
    responseCode = "201",
    description = "주문 생성 성공",
    content = @Content(
        mediaType = "application/json",
        schema = @Schema(implementation = OrderResponse.class),
        examples = {
            @ExampleObject(
                name = "일반 주문",
                description = "할인 없는 일반 주문",
                value = """
                {
                    "orderId": "ORD-2024-001",
                    "status": "PENDING",
                    "totalAmount": 150000,
                    "items": [
                        {
                            "productId": 1,
                            "productName": "iPhone 15",
                            "quantity": 1,
                            "unitPrice": 150000,
                            "totalPrice": 150000
                        }
                    ],
                    "deliveryAddress": {
                        "recipient": "홍길동",
                        "phone": "010-1234-5678",
                        "address": "서울시 강남구 테헤란로 123",
                        "zipCode": "06234"
                    },
                    "createdAt": "2024-01-15T10:30:00Z"
                }
                """
            ),
            @ExampleObject(
                name = "할인 적용 주문",
                description = "쿠폰 할인이 적용된 주문",
                value = """
                {
                    "orderId": "ORD-2024-002",
                    "status": "PENDING",
                    "originalAmount": 200000,
                    "discountAmount": 40000,
                    "totalAmount": 160000,
                    "couponCode": "WELCOME20",
                    "items": [
                        {
                            "productId": 1,
                            "productName": "iPhone 15",
                            "quantity": 1,
                            "unitPrice": 150000,
                            "totalPrice": 150000
                        },
                        {
                            "productId": 2,
                            "productName": "AirPods Pro",
                            "quantity": 1,
                            "unitPrice": 50000,
                            "totalPrice": 50000
                        }
                    ],
                    "createdAt": "2024-01-15T10:30:00Z"
                }
                """
            )
        }
    )
)

4.3 유효성 검증 문서화

Bean Validation 어노테이션과 @Schema를 함께 사용하여 데이터 유효성 규칙을 문서에 자동으로 반영할 수 있습니다.

유효성 검증 통합 문서화

@Schema(description = "사용자 등록 요청")
public class UserRegistrationRequest {

    @Schema(
        description = "사용자 이름 (한글, 영문만 허용)",
        example = "홍길동",
        requiredMode = Schema.RequiredMode.REQUIRED,
        minLength = 2,
        maxLength = 50,
        pattern = "^[가-힣a-zA-Z\\s]+$"
    )
    @NotBlank(message = "이름은 필수입니다.")
    @Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다.")
    @Pattern(
        regexp = "^[가-힣a-zA-Z\\s]+$", 
        message = "이름은 한글, 영문, 공백만 허용됩니다."
    )
    private String name;

    @Schema(
        description = "이메일 주소",
        example = "user@example.com",
        requiredMode = Schema.RequiredMode.REQUIRED,
        format = "email",
        maxLength = 100
    )
    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "올바른 이메일 형식이 아닙니다.")
    @Size(max = 100, message = "이메일은 100자를 초과할 수 없습니다.")
    private String email;

    @Schema(
        description = "비밀번호 (8-20자, 영문+숫자+특수문자 조합)",
        example = "Password123!",
        requiredMode = Schema.RequiredMode.REQUIRED,
        minLength = 8,
        maxLength = 20,
        pattern = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$"
    )
    @NotBlank(message = "비밀번호는 필수입니다.")
    @Size(min = 8, max = 20, message = "비밀번호는 8-20자 사이여야 합니다.")
    @Pattern(
        regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
        message = "비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다."
    )
    private String password;

    @Schema(
        description = "나이",
        example = "25",
        minimum = "14",
        maximum = "120",
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @NotNull(message = "나이는 필수입니다.")
    @Min(value = 14, message = "나이는 14세 이상이어야 합니다.")
    @Max(value = 120, message = "나이는 120세 이하여야 합니다.")
    private Integer age;

    @Schema(
        description = "전화번호 (010-XXXX-XXXX 형식)",
        example = "010-1234-5678",
        pattern = "^010-\\d{4}-\\d{4}$"
    )
    @Pattern(
        regexp = "^010-\\d{4}-\\d{4}$",
        message = "전화번호는 010-XXXX-XXXX 형식이어야 합니다."
    )
    private String phoneNumber;

    @Schema(
        description = "개인정보 처리방침 동의",
        example = "true",
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @NotNull(message = "개인정보 처리방침 동의는 필수입니다.")
    @AssertTrue(message = "개인정보 처리방침에 동의해야 합니다.")
    private Boolean privacyAgreement;

    // getters and setters...
}

4.4 Enum 타입 문서화

Enum 타입은 허용되는 값들을 명확히 제한하므로 API 문서에서 중요한 역할을 합니다. @Schema의 allowableValues 속성을 활용하여 가능한 값들을 명시할 수 있습니다.

Enum 문서화 예제

@Schema(description = "주문 상태")
public enum OrderStatus {
    @Schema(description = "주문 대기")
    PENDING("주문 대기"),
    
    @Schema(description = "결제 완료")
    PAID("결제 완료"),
    
    @Schema(description = "배송 준비중")
    PREPARING("배송 준비중"),
    
    @Schema(description = "배송중")
    SHIPPING("배송중"),
    
    @Schema(description = "배송 완료")
    DELIVERED("배송 완료"),
    
    @Schema(description = "주문 취소")
    CANCELLED("주문 취소");

    private final String description;

    OrderStatus(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }
}

// DTO에서 Enum 사용
@Schema(description = "주문 정보")
public class OrderResponse {

    @Schema(
        description = "주문 상태",
        example = "PENDING",
        allowableValues = {"PENDING", "PAID", "PREPARING", "SHIPPING", "DELIVERED", "CANCELLED"}
    )
    private OrderStatus status;

    @Schema(
        description = "우선순위",
        example = "HIGH",
        implementation = Priority.class
    )
    private Priority priority;

    // getters and setters...
}

💡 실무 팁: 유효성 검증 어노테이션의 메시지와 @Schema의 description을 일관성 있게 작성하면 개발자와 API 사용자 모두에게 명확한 가이드를 제공할 수 있습니다.

5. 보안 설정

학습 목표: JWT 인증 문서화, SecurityScheme 설정, 권한별 API 접근 제어 문서화 방법을 학습합니다.

5.1 JWT 인증 문서화

JWT(JSON Web Token) 기반 인증 시스템을 Swagger UI에서 테스트할 수 있도록 SecurityScheme을 설정하고 각 API의 보안 요구사항을 문서화합니다.

JWT SecurityScheme 설정

@Configuration
public class OpenApiSecurityConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("E-Commerce API")
                        .version("1.0.0")
                        .description("JWT 인증을 사용하는 온라인 쇼핑몰 API"))
                .addSecurityItem(new SecurityRequirement()
                        .addList("Bearer Authentication"))
                .components(new Components()
                        .addSecuritySchemes("Bearer Authentication", 
                                createAPIKeyScheme()));
    }

    private SecurityScheme createAPIKeyScheme() {
        return new SecurityScheme()
                .type(SecurityScheme.Type.HTTP)
                .bearerFormat("JWT")
                .scheme("bearer")
                .description("JWT 토큰을 입력하세요. 'Bearer ' 접두사는 자동으로 추가됩니다.")
                .name("Authorization")
                .in(SecurityScheme.In.HEADER);
    }

    // OAuth2 설정 (선택사항)
    private SecurityScheme createOAuth2Scheme() {
        return new SecurityScheme()
                .type(SecurityScheme.Type.OAUTH2)
                .description("OAuth2 인증")
                .flows(new OAuthFlows()
                        .authorizationCode(new OAuthFlow()
                                .authorizationUrl("https://auth.example.com/oauth/authorize")
                                .tokenUrl("https://auth.example.com/oauth/token")
                                .scopes(new Scopes()
                                        .addString("read", "읽기 권한")
                                        .addString("write", "쓰기 권한")
                                        .addString("admin", "관리자 권한"))));
    }
}

5.2 API별 보안 요구사항 설정

각 API 엔드포인트마다 필요한 인증 레벨과 권한을 @SecurityRequirement 어노테이션으로 명시합니다. 공개 API, 인증 필요 API, 특정 권한 필요 API를 구분하여 문서화할 수 있습니다.

권한별 API 문서화

@RestController
@RequestMapping("/api/products")
@Tag(name = "Product Management", description = "상품 관리 API")
public class ProductController {

    // 공개 API - 인증 불필요
    @Operation(
        summary = "상품 목록 조회",
        description = "모든 사용자가 접근 가능한 상품 목록 조회 API"
    )
    @GetMapping
    public ResponseEntity<PagedResponse<Product>> getProducts(
        @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "1") int page,
        @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size
    ) {
        return ResponseEntity.ok(productService.getProducts(page, size));
    }

    // 인증 필요 API
    @Operation(
        summary = "상품 상세 조회",
        description = "로그인한 사용자만 접근 가능한 상품 상세 정보 조회"
    )
    @SecurityRequirement(name = "Bearer Authentication")
    @GetMapping("/{id}")
    public ResponseEntity<ProductDetail> getProduct(
        @Parameter(description = "상품 ID") @PathVariable Long id
    ) {
        return ResponseEntity.ok(productService.getProductDetail(id));
    }

    // 관리자 권한 필요 API
    @Operation(
        summary = "상품 생성",
        description = "관리자 권한이 필요한 상품 생성 API"
    )
    @SecurityRequirement(name = "Bearer Authentication")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "201", description = "상품 생성 성공"),
        @ApiResponse(responseCode = "401", description = "인증 실패"),
        @ApiResponse(responseCode = "403", description = "권한 부족 - 관리자 권한 필요")
    })
    @PreAuthorize("hasRole('ADMIN')")
    @PostMapping
    public ResponseEntity<Product> createProduct(
        @RequestBody @Valid ProductCreateRequest request
    ) {
        Product product = productService.createProduct(request);
        return ResponseEntity.status(201).body(product);
    }

    // 소유자 또는 관리자 권한 필요 API
    @Operation(
        summary = "상품 수정",
        description = "상품 소유자 또는 관리자만 수정 가능"
    )
    @SecurityRequirement(name = "Bearer Authentication")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "상품 수정 성공"),
        @ApiResponse(responseCode = "401", description = "인증 실패"),
        @ApiResponse(responseCode = "403", description = "권한 부족 - 소유자 또는 관리자 권한 필요"),
        @ApiResponse(responseCode = "404", description = "상품을 찾을 수 없음")
    })
    @PreAuthorize("hasRole('ADMIN') or @productService.isOwner(#id, authentication.name)")
    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(
        @Parameter(description = "상품 ID") @PathVariable Long id,
        @RequestBody @Valid ProductUpdateRequest request
    ) {
        Product product = productService.updateProduct(id, request);
        return ResponseEntity.ok(product);
    }
}

5.3 인증 관련 API 문서화

로그인, 회원가입, 토큰 갱신 등 인증 관련 API들은 특별한 주의를 기울여 문서화해야 합니다. 보안 정보의 노출을 방지하면서도 사용법을 명확히 안내해야 합니다.

인증 API 문서화

@RestController
@RequestMapping("/api/auth")
@Tag(name = "Authentication", description = "인증 관련 API")
public class AuthController {

    @Operation(
        summary = "사용자 로그인",
        description = "이메일과 비밀번호로 로그인하여 JWT 토큰을 발급받습니다."
    )
    @ApiResponses(value = {
        @ApiResponse(
            responseCode = "200",
            description = "로그인 성공",
            content = @Content(
                mediaType = "application/json",
                schema = @Schema(implementation = LoginResponse.class),
                examples = @ExampleObject(
                    name = "로그인 성공",
                    value = """
                    {
                        "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
                        "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
                        "tokenType": "Bearer",
                        "expiresIn": 3600,
                        "user": {
                            "id": 1,
                            "email": "user@example.com",
                            "name": "홍길동",
                            "role": "USER"
                        }
                    }
                    """
                )
            )
        ),
        @ApiResponse(
            responseCode = "401",
            description = "로그인 실패",
            content = @Content(
                mediaType = "application/json",
                examples = @ExampleObject(
                    name = "로그인 실패",
                    value = """
                    {
                        "error": "INVALID_CREDENTIALS",
                        "message": "이메일 또는 비밀번호가 올바르지 않습니다.",
                        "timestamp": "2024-01-15T10:30:00Z"
                    }
                    """
                )
            )
        ),
        @ApiResponse(
            responseCode = "423",
            description = "계정 잠김",
            content = @Content(
                mediaType = "application/json",
                examples = @ExampleObject(
                    name = "계정 잠김",
                    value = """
                    {
                        "error": "ACCOUNT_LOCKED",
                        "message": "로그인 시도 횟수 초과로 계정이 잠겼습니다.",
                        "lockUntil": "2024-01-15T11:30:00Z",
                        "timestamp": "2024-01-15T10:30:00Z"
                    }
                    """
                )
            )
        )
    })
    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(
        @RequestBody @Valid LoginRequest request
    ) {
        LoginResponse response = authService.login(request);
        return ResponseEntity.ok(response);
    }

    @Operation(
        summary = "토큰 갱신",
        description = "Refresh Token을 사용하여 새로운 Access Token을 발급받습니다."
    )
    @ApiResponses(value = {
        @ApiResponse(
            responseCode = "200",
            description = "토큰 갱신 성공",
            content = @Content(
                mediaType = "application/json",
                schema = @Schema(implementation = TokenRefreshResponse.class)
            )
        ),
        @ApiResponse(
            responseCode = "401",
            description = "유효하지 않은 Refresh Token",
            content = @Content(
                mediaType = "application/json",
                examples = @ExampleObject(
                    value = """
                    {
                        "error": "INVALID_REFRESH_TOKEN",
                        "message": "유효하지 않은 리프레시 토큰입니다.",
                        "timestamp": "2024-01-15T10:30:00Z"
                    }
                    """
                )
            )
        )
    })
    @PostMapping("/refresh")
    public ResponseEntity<TokenRefreshResponse> refreshToken(
        @RequestBody @Valid TokenRefreshRequest request
    ) {
        TokenRefreshResponse response = authService.refreshToken(request);
        return ResponseEntity.ok(response);
    }

    @Operation(
        summary = "로그아웃",
        description = "현재 사용자를 로그아웃하고 토큰을 무효화합니다."
    )
    @SecurityRequirement(name = "Bearer Authentication")
    @ApiResponse(responseCode = "200", description = "로그아웃 성공")
    @ApiResponse(responseCode = "401", description = "인증 실패")
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(HttpServletRequest request) {
        String token = extractTokenFromRequest(request);
        authService.logout(token);
        return ResponseEntity.ok().build();
    }
}

5.4 보안 관련 DTO 문서화

인증 관련 요청과 응답 DTO는 보안에 민감한 정보를 포함하므로 적절한 수준의 정보만 노출하도록 주의깊게 문서화해야 합니다.

보안 DTO 문서화

@Schema(description = "로그인 요청")
public class LoginRequest {

    @Schema(
        description = "이메일 주소",
        example = "user@example.com",
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "올바른 이메일 형식이 아닙니다.")
    private String email;

    @Schema(
        description = "비밀번호",
        example = "password123",
        requiredMode = Schema.RequiredMode.REQUIRED,
        format = "password"
    )
    @NotBlank(message = "비밀번호는 필수입니다.")
    private String password;

    @Schema(
        description = "로그인 상태 유지 여부",
        example = "false",
        defaultValue = "false"
    )
    private Boolean rememberMe = false;

    // getters and setters...
}

@Schema(description = "로그인 응답")
public class LoginResponse {

    @Schema(
        description = "액세스 토큰",
        example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    )
    private String accessToken;

    @Schema(
        description = "리프레시 토큰",
        example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    )
    private String refreshToken;

    @Schema(
        description = "토큰 타입",
        example = "Bearer",
        defaultValue = "Bearer"
    )
    private String tokenType = "Bearer";

    @Schema(
        description = "토큰 만료 시간 (초)",
        example = "3600"
    )
    private Long expiresIn;

    @Schema(description = "사용자 정보")
    private UserInfo user;

    // getters and setters...
}

@Schema(description = "사용자 정보 (민감 정보 제외)")
public class UserInfo {

    @Schema(description = "사용자 ID", example = "1")
    private Long id;

    @Schema(description = "이메일", example = "user@example.com")
    private String email;

    @Schema(description = "이름", example = "홍길동")
    private String name;

    @Schema(description = "역할", example = "USER")
    private String role;

    @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
    private String profileImageUrl;

    // 비밀번호, 내부 ID 등 민감한 정보는 제외

    // getters and setters...
}

5.5 보안 고려사항

보안 주의사항

  • 운영 환경에서 Swagger UI 비활성화
  • 예제에 실제 토큰이나 비밀번호 사용 금지
  • 민감한 API 엔드포인트 문서화 제한
  • 내부 시스템 정보 노출 방지
  • 에러 메시지에서 시스템 정보 노출 주의

보안 Best Practices

  • 환경별 문서화 설정 분리
  • 적절한 권한 레벨 문서화
  • 토큰 만료 시간 명시
  • 에러 응답 표준화
  • HTTPS 사용 권장 명시

💡 실무 팁: 보안 설정은 개발 환경과 운영 환경을 명확히 구분하여 설정하세요. 개발 시에는 편의성을 위해 Swagger UI를 활성화하되, 운영 환경에서는 반드시 비활성화해야 합니다.

6. 고급 설정

학습 목표: API 그룹화, 버전 관리, Swagger UI 커스터마이징 등 고급 설정 방법을 학습합니다.

6.1 API 그룹화

대규모 API 프로젝트에서는 관련된 API들을 논리적으로 그룹화하여 문서의 가독성을 높이고 관리를 용이하게 할 수 있습니다.

GroupedOpenApi 설정

@Configuration
public class OpenApiGroupConfig {

    @Bean
    public GroupedOpenApi publicApi() {
        return GroupedOpenApi.builder()
                .group("public")
                .displayName("Public APIs")
                .pathsToMatch("/api/public/**")
                .addOperationCustomizer((operation, handlerMethod) -> {
                    operation.addTagsItem("Public");
                    return operation;
                })
                .build();
    }

    @Bean
    public GroupedOpenApi userApi() {
        return GroupedOpenApi.builder()
                .group("user")
                .displayName("User Management")
                .pathsToMatch("/api/users/**", "/api/auth/**")
                .packagesToScan("com.example.controller.user")
                .addOperationCustomizer((operation, handlerMethod) -> {
                    // 사용자 관련 API에 공통 태그 추가
                    operation.addTagsItem("User Management");
                    return operation;
                })
                .build();
    }

    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi.builder()
                .group("admin")
                .displayName("Admin APIs")
                .pathsToMatch("/api/admin/**")
                .packagesToScan("com.example.controller.admin")
                .addOpenApiCustomizer(openApi -> {
                    // 관리자 API에 보안 요구사항 추가
                    openApi.addSecurityItem(new SecurityRequirement()
                            .addList("Bearer Authentication"));
                    return openApi;
                })
                .build();
    }

    @Bean
    public GroupedOpenApi productApi() {
        return GroupedOpenApi.builder()
                .group("product")
                .displayName("Product Management")
                .pathsToMatch("/api/products/**", "/api/categories/**")
                .addOperationCustomizer((operation, handlerMethod) -> {
                    // 상품 관련 API 커스터마이징
                    if (handlerMethod.getMethod().isAnnotationPresent(PreAuthorize.class)) {
                        operation.addTagsItem("Requires Authentication");
                    }
                    return operation;
                })
                .build();
    }

    @Bean
    public GroupedOpenApi orderApi() {
        return GroupedOpenApi.builder()
                .group("order")
                .displayName("Order & Payment")
                .pathsToMatch("/api/orders/**", "/api/payments/**")
                .packagesToScan("com.example.controller.order", "com.example.controller.payment")
                .build();
    }

    // 내부 API (개발자용)
    @Bean
    @Profile("dev")
    public GroupedOpenApi internalApi() {
        return GroupedOpenApi.builder()
                .group("internal")
                .displayName("Internal APIs (Dev Only)")
                .pathsToMatch("/api/internal/**")
                .addOperationCustomizer((operation, handlerMethod) -> {
                    operation.addTagsItem("Internal");
                    operation.setDescription(
                        "⚠️ 내부 개발용 API입니다. 운영 환경에서는 사용할 수 없습니다."
                    );
                    return operation;
                })
                .build();
    }
}

6.2 API 버전 관리

API 버전 관리는 하위 호환성을 유지하면서 새로운 기능을 추가할 때 필수적입니다. SpringDoc을 활용하여 여러 버전의 API를 동시에 문서화할 수 있습니다.

버전별 API 문서화

@Configuration
public class ApiVersionConfig {

    @Bean
    public GroupedOpenApi apiV1() {
        return GroupedOpenApi.builder()
                .group("v1")
                .displayName("API v1.0 (Deprecated)")
                .pathsToMatch("/api/v1/**")
                .addOpenApiCustomizer(openApi -> {
                    openApi.info(new Info()
                            .title("E-Commerce API v1.0")
                            .version("1.0.0")
                            .description("⚠️ **사용 중단 예정** - 2024년 12월 31일부터 지원 중단\n\n" +
                                       "새로운 개발에는 v2 API를 사용해주세요.")
                            .contact(new Contact()
                                    .name("API Support")
                                    .email("api-support@example.com"))
                            .license(new License()
                                    .name("MIT")
                                    .url("https://opensource.org/licenses/MIT")));
                    return openApi;
                })
                .addOperationCustomizer((operation, handlerMethod) -> {
                    // v1 API에 deprecated 표시
                    operation.setDeprecated(true);
                    if (operation.getDescription() != null) {
                        operation.setDescription(
                            "⚠️ **Deprecated**: " + operation.getDescription() + 
                            "\n\n새로운 v2 API를 사용해주세요."
                        );
                    }
                    return operation;
                })
                .build();
    }

    @Bean
    public GroupedOpenApi apiV2() {
        return GroupedOpenApi.builder()
                .group("v2")
                .displayName("API v2.0 (Current)")
                .pathsToMatch("/api/v2/**")
                .addOpenApiCustomizer(openApi -> {
                    openApi.info(new Info()
                            .title("E-Commerce API v2.0")
                            .version("2.0.0")
                            .description("현재 권장되는 API 버전입니다.\n\n" +
                                       "**주요 개선사항:**\n" +
                                       "- 향상된 성능\n" +
                                       "- 더 나은 오류 처리\n" +
                                       "- 확장된 기능\n" +
                                       "- GraphQL 지원")
                            .contact(new Contact()
                                    .name("API Support")
                                    .email("api-support@example.com")));
                    return openApi;
                })
                .build();
    }

    @Bean
    @Profile("dev")
    public GroupedOpenApi apiV3Beta() {
        return GroupedOpenApi.builder()
                .group("v3-beta")
                .displayName("API v3.0 (Beta)")
                .pathsToMatch("/api/v3/**")
                .addOpenApiCustomizer(openApi -> {
                    openApi.info(new Info()
                            .title("E-Commerce API v3.0 Beta")
                            .version("3.0.0-beta")
                            .description("🚧 **베타 버전** - 개발 중인 차세대 API\n\n" +
                                       "**새로운 기능:**\n" +
                                       "- 실시간 알림\n" +
                                       "- 고급 검색\n" +
                                       "- AI 추천 시스템\n\n" +
                                       "⚠️ 베타 버전이므로 운영 환경에서 사용하지 마세요."));
                    return openApi;
                })
                .addOperationCustomizer((operation, handlerMethod) -> {
                    operation.addTagsItem("Beta");
                    return operation;
                })
                .build();
    }
}

6.3 Swagger UI 커스터마이징

Swagger UI의 외관과 동작을 조직의 브랜딩에 맞게 커스터마이징하고, 사용자 경험을 개선할 수 있습니다.

UI 테마 커스터마이징

# application.yml - 고급 Swagger UI 설정
springdoc:
  swagger-ui:
    # 기본 설정
    path: /swagger-ui.html
    enabled: true
    
    # 레이아웃 및 표시 설정
    layout: StandaloneLayout
    deep-linking: true
    display-operation-id: false
    default-models-expand-depth: 1
    default-model-expand-depth: 1
    default-model-rendering: example
    
    # 필터 및 정렬
    filter: true
    operations-sorter: method
    tags-sorter: alpha
    
    # 기능 설정
    try-it-out-enabled: true
    request-duration: true
    validator-url: none
    show-extensions: true
    show-common-extensions: true
    
    # 커스텀 리소스
    custom-css-url: /swagger-ui/custom.css
    custom-js-url: /swagger-ui/custom.js
    
    # 브랜딩
    doc-expansion: none
    persist-authorization: true
    
    # OAuth 설정
    oauth:
      client-id: swagger-ui
      client-secret: swagger-ui-secret
      realm: swagger-ui-realm
      app-name: E-Commerce API
      scope-separator: " "
      use-pkce-with-authorization-code-grant: true

커스텀 CSS 스타일링

/* src/main/resources/static/swagger-ui/custom.css */

/* 전체 테마 색상 */
:root {
  --primary-color: #2c3e50;
  --secondary-color: #3498db;
  --success-color: #27ae60;
  --warning-color: #f39c12;
  --danger-color: #e74c3c;
  --light-bg: #ecf0f1;
}

/* 헤더 스타일링 */
.swagger-ui .topbar {
  background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
  border-bottom: 3px solid var(--secondary-color);
}

.swagger-ui .topbar .download-url-wrapper {
  display: none; /* URL 입력 박스 숨기기 */
}

/* 로고 추가 */
.swagger-ui .topbar::before {
  content: "";
  background-image: url('/images/company-logo.png');
  background-size: contain;
  background-repeat: no-repeat;
  width: 120px;
  height: 40px;
  display: inline-block;
  margin-right: 20px;
}

/* API 정보 섹션 */
.swagger-ui .info {
  margin: 30px 0;
  padding: 20px;
  background: var(--light-bg);
  border-radius: 8px;
  border-left: 4px solid var(--secondary-color);
}

.swagger-ui .info .title {
  color: var(--primary-color);
  font-size: 2.5em;
  font-weight: bold;
}

.swagger-ui .info .description {
  font-size: 1.1em;
  line-height: 1.6;
}

/* HTTP 메서드별 색상 */
.swagger-ui .opblock.opblock-get {
  border-color: var(--secondary-color);
  background: rgba(52, 152, 219, 0.1);
}

.swagger-ui .opblock.opblock-post {
  border-color: var(--success-color);
  background: rgba(39, 174, 96, 0.1);
}

.swagger-ui .opblock.opblock-put {
  border-color: var(--warning-color);
  background: rgba(243, 156, 18, 0.1);
}

.swagger-ui .opblock.opblock-delete {
  border-color: var(--danger-color);
  background: rgba(231, 76, 60, 0.1);
}

/* 태그 그룹 스타일링 */
.swagger-ui .opblock-tag {
  border-bottom: 2px solid #ddd;
  padding: 15px 0;
  margin: 20px 0;
}

.swagger-ui .opblock-tag small {
  background: var(--secondary-color);
  color: white;
  padding: 4px 8px;
  border-radius: 12px;
  font-size: 0.8em;
  margin-left: 10px;
}

/* 응답 코드 색상 */
.swagger-ui .responses-inner h4 {
  font-size: 1.1em;
  font-weight: bold;
}

.swagger-ui .response-col_status {
  font-weight: bold;
}

.swagger-ui .response-col_status[data-code^="2"] {
  color: var(--success-color);
}

.swagger-ui .response-col_status[data-code^="4"] {
  color: var(--warning-color);
}

.swagger-ui .response-col_status[data-code^="5"] {
  color: var(--danger-color);
}

/* 스키마 섹션 */
.swagger-ui .model-box {
  background: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 6px;
}

.swagger-ui .model-title {
  background: var(--primary-color);
  color: white;
  padding: 10px 15px;
  margin: 0;
  border-radius: 6px 6px 0 0;
}

/* 버튼 스타일링 */
.swagger-ui .btn {
  border-radius: 6px;
  font-weight: 500;
  transition: all 0.3s ease;
}

.swagger-ui .btn.execute {
  background: var(--secondary-color);
  border-color: var(--secondary-color);
}

.swagger-ui .btn.execute:hover {
  background: #2980b9;
  transform: translateY(-1px);
  box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}

/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {
  .swagger-ui {
    filter: invert(1) hue-rotate(180deg);
  }
  
  .swagger-ui img {
    filter: invert(1) hue-rotate(180deg);
  }
}

커스텀 JavaScript 기능

/* src/main/resources/static/swagger-ui/custom.js */

// Swagger UI 로드 완료 후 실행
window.addEventListener('DOMContentLoaded', function() {
  
  // 커스텀 헤더 추가
  function addCustomHeader() {
    const topbar = document.querySelector('.swagger-ui .topbar');
    if (topbar && !document.querySelector('.custom-header')) {
      const customHeader = document.createElement('div');
      customHeader.className = 'custom-header';
      customHeader.innerHTML = `
        <div style="background: #2c3e50; color: white; padding: 10px; text-align: center;">
          <strong>🚀 E-Commerce API Documentation</strong>
          <span style="margin-left: 20px;">환경: ${getEnvironment()}</span>
          <span style="margin-left: 20px;">버전: v2.0.0</span>
        </div>
      `;
      topbar.parentNode.insertBefore(customHeader, topbar);
    }
  }

  // 환경 정보 표시
  function getEnvironment() {
    const hostname = window.location.hostname;
    if (hostname.includes('localhost') || hostname.includes('127.0.0.1')) {
      return '개발환경';
    } else if (hostname.includes('staging')) {
      return '스테이징';
    } else {
      return '운영환경';
    }
  }

  // API 응답 시간 측정
  function measureApiResponseTime() {
    const originalFetch = window.fetch;
    window.fetch = function(...args) {
      const startTime = performance.now();
      return originalFetch.apply(this, args).then(response => {
        const endTime = performance.now();
        const duration = Math.round(endTime - startTime);
        console.log(`API 응답 시간: ${duration}ms - ${args[0]}`);
        return response;
      });
    };
  }

  // 자주 사용하는 예제 데이터 자동 입력
  function addQuickFillButtons() {
    setTimeout(() => {
      const tryItOutButtons = document.querySelectorAll('.try-out__btn');
      tryItOutButtons.forEach(button => {
        if (button.textContent.includes('Try it out')) {
          button.addEventListener('click', () => {
            setTimeout(() => {
              addExampleDataButtons();
            }, 500);
          });
        }
      });
    }, 2000);
  }

  function addExampleDataButtons() {
    const textareas = document.querySelectorAll('.body-param__text');
    textareas.forEach(textarea => {
      if (!textarea.nextElementSibling?.classList.contains('example-buttons')) {
        const buttonContainer = document.createElement('div');
        buttonContainer.className = 'example-buttons';
        buttonContainer.style.marginTop = '10px';
        
        const exampleButton = document.createElement('button');
        exampleButton.textContent = '예제 데이터 입력';
        exampleButton.className = 'btn btn-sm';
        exampleButton.style.background = '#3498db';
        exampleButton.style.color = 'white';
        exampleButton.style.border = 'none';
        exampleButton.style.padding = '5px 10px';
        exampleButton.style.borderRadius = '4px';
        exampleButton.style.cursor = 'pointer';
        
        exampleButton.addEventListener('click', (e) => {
          e.preventDefault();
          fillExampleData(textarea);
        });
        
        buttonContainer.appendChild(exampleButton);
        textarea.parentNode.insertBefore(buttonContainer, textarea.nextSibling);
      }
    });
  }

  function fillExampleData(textarea) {
    const operationId = textarea.closest('.opblock')?.querySelector('.opblock-summary-operation-id')?.textContent;
    
    const examples = {
      'createUser': JSON.stringify({
        name: "홍길동",
        email: "hong@example.com",
        age: 30,
        phoneNumber: "010-1234-5678"
      }, null, 2),
      'createProduct': JSON.stringify({
        name: "iPhone 15 Pro",
        description: "최신 아이폰 모델",
        price: 1200000,
        categoryId: 1
      }, null, 2),
      'createOrder': JSON.stringify({
        items: [
          {
            productId: 1,
            quantity: 2
          }
        ],
        deliveryAddress: {
          recipient: "홍길동",
          phone: "010-1234-5678",
          address: "서울시 강남구 테헤란로 123",
          zipCode: "06234"
        }
      }, null, 2)
    };
    
    const exampleData = examples[operationId] || JSON.stringify({
      message: "예제 데이터가 없습니다."
    }, null, 2);
    
    textarea.value = exampleData;
    textarea.dispatchEvent(new Event('input', { bubbles: true }));
  }

  // 초기화 함수 실행
  addCustomHeader();
  measureApiResponseTime();
  addQuickFillButtons();
  
  // 페이지 변경 감지 (SPA 대응)
  let currentUrl = window.location.href;
  setInterval(() => {
    if (window.location.href !== currentUrl) {
      currentUrl = window.location.href;
      setTimeout(() => {
        addCustomHeader();
        addQuickFillButtons();
      }, 1000);
    }
  }, 1000);
});

💡 실무 팁: 고급 설정을 적용할 때는 단계적으로 진행하세요. 먼저 기본 그룹화부터 시작하고, 점진적으로 커스터마이징을 추가하면 문제 발생 시 원인을 쉽게 파악할 수 있습니다.

7. 정리 - API 문서화 베스트 프랙티스

학습 목표: API 문서화의 베스트 프랙티스를 이해하고 실전에서 활용할 수 있는 가이드를 제공합니다.

7.1 API 문서화 베스트 프랙티스

문서 작성 원칙

  • 명확성: 모호한 표현 대신 구체적이고 명확한 설명
  • 완전성: 모든 필수 정보와 예외 상황 포함
  • 일관성: 용어와 형식의 통일성 유지
  • 실용성: 실제 사용 가능한 예제와 시나리오 제공
  • 최신성: 코드 변경 시 문서 동시 업데이트

사용자 중심 접근

  • 개발자 관점: API 사용자의 입장에서 문서 작성
  • 학습 곡선: 초보자도 이해할 수 있는 설명
  • 검색 가능성: 키워드 기반 검색 최적화
  • 시각적 구성: 읽기 쉬운 레이아웃과 구조
  • 피드백 반영: 사용자 의견을 통한 지속적 개선

7.2 실전 구현 가이드

단계별 구현 로드맵

1단계: 기본 설정 (1-2일)
  • SpringDoc 의존성 추가
  • 기본 OpenAPI 설정
  • Swagger UI 활성화
  • 기본 API 정보 설정
2단계: 핵심 API 문서화 (3-5일)
  • 주요 Controller에 @Operation 적용
  • 요청/응답 DTO에 @Schema 적용
  • 기본적인 예제 값 설정
  • 에러 응답 문서화
3단계: 보안 및 고급 기능 (2-3일)
  • JWT 인증 설정
  • 권한별 API 접근 제어
  • API 그룹화
  • 환경별 설정 분리
4단계: 최적화 및 커스터마이징 (2-4일)
  • UI 커스터마이징
  • 버전 관리 설정
  • 성능 최적화
  • CI/CD 통합

7.3 팀 협업을 위한 가이드라인

문서화 표준 정의

// 팀 내 문서화 표준 예시

/**
 * API 문서화 가이드라인
 * 
 * 1. Controller 클래스
 *    - @Tag: 기능별 그룹명 (예: "User Management", "Product Catalog")
 *    - description: 해당 API 그룹의 역할과 책임 명시
 * 
 * 2. API 메서드
 *    - @Operation: summary(간단명료), description(상세설명)
 *    - @ApiResponse: 모든 가능한 HTTP 상태 코드 문서화
 *    - @Parameter: 모든 매개변수에 설명과 예제 추가
 * 
 * 3. DTO 클래스
 *    - @Schema: 클래스와 모든 필드에 적용
 *    - example: 실제 사용 가능한 예제 값
 *    - 유효성 검증 어노테이션과 일치하는 제약사항
 * 
 * 4. 네이밍 컨벤션
 *    - 한국어: 사용자 대상 설명
 *    - 영어: 기술적 용어 및 필드명
 *    - 일관된 용어 사용 (용어집 참조)
 */

@RestController
@RequestMapping("/api/v2/users")
@Tag(name = "User Management", description = "사용자 계정 관리 및 프로필 API")
@Validated
public class UserController {

    @Operation(
        summary = "사용자 목록 조회",
        description = "페이징을 지원하는 사용자 목록 조회 API입니다. " +
                     "관리자는 모든 사용자를, 일반 사용자는 공개 프로필만 조회할 수 있습니다."
    )
    @ApiResponses({
        @ApiResponse(
            responseCode = "200", 
            description = "조회 성공",
            content = @Content(schema = @Schema(implementation = PagedUserResponse.class))
        ),
        @ApiResponse(responseCode = "401", description = "인증 실패"),
        @ApiResponse(responseCode = "403", description = "권한 부족")
    })
    @SecurityRequirement(name = "Bearer Authentication")
    @GetMapping
    public ResponseEntity<PagedUserResponse> getUsers(
        @Parameter(description = "페이지 번호 (1부터 시작)", example = "1")
        @RequestParam(defaultValue = "1") @Min(1) int page,
        
        @Parameter(description = "페이지 크기 (최대 100)", example = "20")
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
        
        @Parameter(description = "검색 키워드 (이름 또는 이메일)", example = "홍길동")
        @RequestParam(required = false) String keyword
    ) {
        // 구현 로직
    }
}

7.4 품질 관리 및 유지보수

자동화된 품질 검사

  • CI/CD 통합: 빌드 시 문서 생성 및 검증
  • 링크 검증: 깨진 링크 자동 탐지
  • 스키마 검증: OpenAPI 명세 유효성 검사
  • 커버리지 측정: 문서화되지 않은 API 탐지
  • 버전 호환성: API 변경 영향도 분석

지속적인 개선

  • 사용자 피드백: 정기적인 개발자 설문조사
  • 사용 통계: API 호출 패턴 분석
  • 문서 업데이트: 정기적인 리뷰 및 개선
  • 교육 프로그램: 팀 내 문서화 교육
  • 베스트 프랙티스: 성공 사례 공유

7.5 문제 해결 가이드

자주 발생하는 문제와 해결책

문제: Swagger UI가 로드되지 않음

원인: 의존성 누락, 경로 설정 오류, Spring Security 차단

해결: 의존성 확인, application.yml 설정 검토, Security 설정에서 Swagger 경로 허용

문제: API 문서에 일부 엔드포인트가 표시되지 않음

원인: 패키지 스캔 범위 제한, @Hidden 어노테이션 사용

해결: packages-to-scan 설정 확인, @Hidden 어노테이션 제거

문제: JWT 토큰 인증이 작동하지 않음

원인: SecurityScheme 설정 오류, 토큰 형식 불일치

해결: Bearer 토큰 형식 확인, SecurityRequirement 설정 검토

문제: 한글 인코딩 문제

원인: 문자 인코딩 설정 누락

해결: application.yml에 server.servlet.encoding.charset=UTF-8 설정

7.6 성공적인 API 문서화를 위한 체크리스트

📋 기본 설정 체크리스트

  • SpringDoc 의존성 추가
  • 기본 OpenAPI 정보 설정
  • Swagger UI 접근 확인
  • 환경별 설정 분리
  • 보안 설정 적용

📝 문서화 품질 체크리스트

  • 모든 public API에 @Operation 적용
  • DTO 필드에 @Schema 적용
  • 실제 사용 가능한 예제 제공
  • 에러 응답 문서화
  • 권한 요구사항 명시

🎯 마무리

API 문서화는 단순한 기술 문서가 아닌 개발자 경험(DX)을 결정하는 핵심 요소입니다. 잘 작성된 API 문서는 개발 생산성을 높이고, 팀 간 협업을 원활하게 하며, 외부 개발자들의 API 채택률을 크게 향상시킵니다.

핵심 포인트: 사용자 관점에서 작성하고, 지속적으로 업데이트하며, 팀 전체가 일관된 표준을 따르는 것이 성공적인 API 문서화의 비결입니다.