API 문서가 코드와 어긋나는 순간, 문서는 없느니만 못한 것이 된다. 클라이언트 개발자는 문서를 믿고 구현했는데 실제 서버는 다르게 동작하기 때문이다. 그리고 이 어긋남은 대개 같은 정보를 두 군데에 적어놓고 한 쪽만 고쳤을 때 생긴다.

이 글은 계층별 DTO 네이밍 표준에서 정리한 Controller 계층 DTO(XxxRequest, XxxResponse, HttpApiResponse<T>)를 springdoc-openapi로 문서화할 때의 규칙을 정리한다. springdoc-openapi는 스프링 프로젝트의 Controller 코드를 읽어서 OpenAPI 문서를 자동으로 만들어 주는 라이브러리다. 핵심 전제는 하나다 — 하나의 정보는 한 곳에만 적는다.


원칙 1. 모든 필드에 설명과 예시를 붙인다

필드 이름만으로 의미가 명확해 보여도 예외를 두지 않는다. “명확하다”는 기준이 사람마다 다르고, 예외를 허용하는 순간 어디까지 적어야 하는지 매번 판단해야 하기 때문이다.

// ❌ 설명도 예시도 없다
@Getter
public class CreateOrderRequest {

    private final String productName;
    private final Integer quantity;

    // 생성자 생략
}

@Getter는 애너테이션이다 — @로 시작해 클래스에 붙이는 표시이고, Lombok이 이걸 보고 모든 필드에 대해 get필드명() 조회 메서드를 자동으로 만들어 준다(@Getter 참고). 이 코드 자체는 평범한 DTO이고, 문제는 필드에 붙은 설명이 없다는 것이다. productName이 상품 코드인지 표시용 이름인지, quantity의 단위가 개수인지 박스 수인지 문서만 봐서는 알 수 없다.

// ✅ 모든 필드에 description과 example
@Getter
public class CreateOrderRequest {

    @Schema(description = "주문할 상품의 표시명", example = "무선 마우스")
    private final String productName;

    @Schema(description = "주문 수량 (낱개 기준)", example = "3")
    private final Integer quantity;

    // 생성자 생략
}

@Schema(description = "...", example = "...")처럼 괄호 안에 이름 = 값을 여러 개 적는 문법은, 그 애너테이션에 세부 정보를 함께 넘기는 방식이다. @Schema는 실행 중 동작을 바꾸지 않는다 — springdoc이 문서를 만들 때만 읽어서 화면에 보여줄 설명과 예시 값으로 쓴다.

Swagger UI는 앞서 본 OpenAPI 문서를 사람이 읽기 좋은 화면으로 보여주는 웹 페이지이고, 그 화면에는 값을 채운 채로 그 자리에서 API를 직접 호출해 보는 “Try it out” 버튼이 있다. example을 강제하는 이유는 이 버튼이 example 값을 채운 채로 바로 동작하기 때문이다. 예시 값이 없으면 string, 0 같은 기본값이 채워져서, 테스트해보려면 값을 일일이 손으로 채워야 한다. 응답 DTO도 마찬가지다.

@Getter
public class OrderResponse {

    @Schema(description = "생성된 주문 ID", example = "1024")
    private final Long orderId;

    @Schema(description = "주문한 상품의 표시명", example = "무선 마우스")
    private final String productName;

    @Schema(description = "주문 수량 (낱개 기준)", example = "3")
    private final int quantity;

    @Schema(description = "부가세 포함 총 결제 금액 (원)", example = "89700")
    private final BigDecimal totalPrice;

    // 생성자와 정적 팩토리 생략
}

원칙 2. 제약 조건은 검증 어노테이션에만 적는다

여기가 이 표준의 핵심이다. springdoc은 Bean Validation 어노테이션을 읽어서 OpenAPI 스펙을 자동으로 채운다. @NotBlank·@Size는 문서만을 위한 표시가 아니다 — 실제로 요청이 올 때마다 값을 검사한다. @NotBlank는 빈 문자열이면 안 된다는 뜻이고, @Size(max = 50)는 길이가 50을 넘으면 안 된다는 뜻이다. 그런데 이걸 모르고 @Schema에도 같은 제약을 적으면, 같은 사실이 두 군데에 존재하게 된다.

// ❌ 같은 제약을 두 군데에 적었다
@Getter
public class CreateOrderRequest {

    @NotBlank
    @Size(max = 50)
    @Schema(
            description = "주문할 상품의 표시명",
            example = "무선 마우스",
            requiredMode = Schema.RequiredMode.REQUIRED,  // @NotBlank와 중복
            maxLength = 50                                 // @Size와 중복
    )
    private final String productName;

    // 생성자 생략
}

이 코드의 문제는 나중에 드러난다. 상품명 길이 제한을 100자로 늘리려고 @Size(max = 100)만 고치면, 문서에는 여전히 50자로 표시된다. 검증은 통과하는데 문서는 거부한다고 말하는, 가장 나쁜 종류의 불일치다.

// ✅ 제약은 검증 어노테이션에만, @Schema는 설명과 예시만
@Getter
public class CreateOrderRequest {

    @NotBlank
    @Size(max = 50)
    @Schema(description = "주문할 상품의 표시명", example = "무선 마우스")
    private final String productName;

    @NotNull
    @Min(1)
    @Max(999)
    @Schema(description = "주문 수량 (낱개 기준)", example = "3")
    private final Integer quantity;

    // 생성자 생략
}

springdoc이 검증 어노테이션에서 자동으로 뽑아내는 항목은 다음과 같다.

검증 어노테이션 자동 반영되는 OpenAPI 속성
@NotNull, @NotBlank, @NotEmpty required: true
@Size(min, max) minLength, maxLength (문자열) / minItems, maxItems (컬렉션)
@Min, @Max minimum, maximum
@DecimalMin, @DecimalMax minimum, maximum
@Pattern(regexp) pattern

즉 위 표에 있는 항목은 @Schema에 절대 적지 않는다. @Schema가 담당하는 것은 검증 어노테이션이 표현할 수 없는 것 — description과 example 두 가지뿐이다.

역할이 이렇게 나뉜다.

관심사 담당 이유
필드의 의미, 예시 값 @Schema 코드로는 표현 불가능한 정보
필수 여부, 길이, 범위, 형식 Bean Validation 런타임 검증의 단일 출처이고, 문서는 여기서 파생된다

원칙 3. 제네릭 래퍼의 스키마 이름은 그대로 둔다

DTO 표준에 따라 모든 Controller 메서드는 ResponseEntity<HttpApiResponse<T>>를 반환한다. springdoc이 읽어야 하는 본문 타입은 그대로 HttpApiResponse<T>다.

@PostMapping("/orders")
public ResponseEntity<HttpApiResponse<OrderCreatedResponse>> createOrder(
        @RequestBody @Valid CreateOrderRequest request) {
    ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand());
    OrderCreatedResponse data = OrderCreatedResponse.from(result);
    URI location = URI.create("/orders/" + data.getOrderId());
    return HttpApiResponses.created(location, data);
}

한 줄씩 따라가기. @PostMapping("/orders")는 HTTP POST 방식으로 /orders에 온 요청을 이 메서드가 처리한다는 표시다. @RequestBody는 요청 본문(JSON)을 CreateOrderRequest 객체로 바꿔서 받으라는 뜻이고, @Valid는 그렇게 받은 객체에 붙은 Bean Validation 규칙을 지금 검사하라는 뜻이다. 반환 타입 ResponseEntity<HttpApiResponse<OrderCreatedResponse>>는 제네릭을 두 번 겹친 것인데, 바깥 ResponseEntity는 상태 코드·헤더까지 담는 응답 상자이고 안쪽 HttpApiResponse<OrderCreatedResponse>는 그 상자에 실제로 담기는 값이다. request.toCommand()는 Controller 계층의 CreateOrderRequest를 Service가 받는 형태(Command)로 바꿔 주는 메서드다 — 계층마다 DTO를 나누는 이유는 계층별 DTO 네이밍 표준에 있다. orderCommandUseCase는 UseCase 인터페이스로, Controller는 이 인터페이스만 보고 실제 주문 생성 절차는 그 뒤의 Service 클래스에 맡긴다. 이 메서드만 보면 orderCommandUseCase가 어디서 왔는지 안 보이는데, 이 코드 예시는 메서드 본문만 보여주려고 클래스 선언과 필드 부분을 생략했기 때문이다. 실제 Controller 클래스에는 orderCommandUseCase를 담는 필드가 선언돼 있고, 클래스에 @RequiredArgsConstructor를 붙여 두면 Lombok이 그 필드를 받는 생성자를 자동으로 만들어 주며, 스프링이 그 생성자를 통해 실제 구현체를 채워 넣는다 — 이걸 의존성 주입이라 부른다. ServiceResult<OrderDomain>은 Service가 “성공했다”는 사실과 그 결과로 생긴 OrderDomain을 함께 담아 돌려주는 상자이고, OrderCreatedResponse.from(result)는 그 상자 안의 OrderDomain을 꺼내 Controller가 응답으로 돌려줄 OrderCreatedResponse 모양으로 옮겨 담는, 생성자 대신 쓰는 정적 팩토리 메서드다. 마지막 줄 HttpApiResponses.created(location, data)는 이 데이터를 “생성 성공”을 뜻하는 공통 응답 틀(HttpApiResponse)에 담고, 상태 코드 201과 “방금 만든 자원을 어디서 다시 조회할 수 있는지” 알려주는 Location HTTP 헤더까지 채운 ResponseEntity를 돌려주는 헬퍼다(성공 응답 상태 표준 원칙 13).

이때 springdoc은 제네릭 타입을 리플렉션으로 읽어서 다음과 같은 스키마를 자동 생성한다.

"HttpApiResponseOrderCreatedResponse": {
  "type": "object",
  "properties": {
    "success": { "type": "boolean" },
    "status":  { "type": "integer", "format": "int32" },
    "message": { "type": "string" },
    "data":    { "$ref": "#/components/schemas/OrderCreatedResponse" }
  }
}

data 필드가 OrderCreatedResponse를 정확히 가리키고 있다는 점이 중요하다. 타입 정보는 전혀 손실되지 않는다. 다만 스키마 이름이 HttpApiResponseOrderCreatedResponse, HttpApiResponseProductResponse처럼 자동 조합되어, Swagger UI 하단 Schemas 목록이 다소 길어진다.

ModelConverter는 springdoc이 스키마를 만드는 중간에 끼어들어 타입을 변환하는 방법을 정하는 인터페이스다 — 이 인터페이스를 구현해서 이 이름을 OrderResponseApiResponse 같은 형태로 바꿀 수는 있다. 하지만 바뀌는 것은 이름뿐이고, 스키마 구조도 data의 타입 정보도 완전히 동일하다. 이 OpenAPI 문서를 읽어 프런트엔드가 쓸 API 호출 코드를 자동으로 만들어 주는 클라이언트 코드 생성 과정에도 영향이 없다 — 그 도구들은 스키마 이름이 아니라 data 필드가 가리키는 구조를 보고 코드를 만들기 때문이다. 순전히 Schemas 목록을 훑을 때의 가독성 문제다.

그래서 이 표준은 자동 생성된 이름을 그대로 쓴다. 가독성 하나를 위해 별도 컨버터 클래스를 만들고 springdoc 버전이 올라갈 때마다 유지보수하는 비용이, 얻는 것보다 크다고 판단했다.


원칙 4. 에러 응답은 전역으로 등록하고, 특이 케이스만 개별 명시한다

HttpApiResponse<T>는 실패 응답도 같은 구조로 내보내므로, 대부분의 엔드포인트가 동일한 에러 응답 형태를 공유한다. 이걸 메서드마다 적으면 순수한 복사-붙여넣기가 된다.

// ❌ 모든 컨트롤러 메서드에 같은 내용을 반복
@Operation(summary = "주문 생성")
@ApiResponses({
        @ApiResponse(responseCode = "400", description = "요청 값이 유효하지 않음"),
        @ApiResponse(responseCode = "401", description = "인증 정보가 없거나 만료됨"),
        @ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/orders")
public ResponseEntity<HttpApiResponse<OrderCreatedResponse>> createOrder(...) { ... }

공통 에러 응답은 OperationCustomizer로 한 번만 등록한다.

@Configuration
public class SwaggerCommonResponseConfig {

    @Bean
    public OperationCustomizer commonErrorResponses() {
        return (operation, handlerMethod) -> {
            operation.getResponses()
                    .addApiResponse("400", errorResponse("요청 값이 유효하지 않음", 400))
                    .addApiResponse("401", errorResponse("인증 정보가 없거나 만료됨", 401))
                    .addApiResponse("500", errorResponse("서버 내부 오류", 500));
            return operation;
        };
    }

    private ApiResponse errorResponse(String description, int status) {
        Schema<?> schema = new ObjectSchema()
                .addProperty("success", new BooleanSchema().example(false))
                .addProperty("status", new IntegerSchema().example(status))
                .addProperty("code", new IntegerSchema().example(400000001))
                .addProperty("message", new StringSchema().example(description))
                .addProperty("data", new ObjectSchema().nullable(true))
                .addProperty("errors", new ArraySchema().items(new ObjectSchema()
                        .addProperty("field", new StringSchema().example("productName"))
                        .addProperty("message", new StringSchema().example("공백일 수 없습니다"))));

        return new ApiResponse()
                .description(description)
                .content(new Content().addMediaType(
                        MediaType.APPLICATION_JSON_VALUE,
                        new io.swagger.v3.oas.models.media.MediaType().schema(schema)));
    }
}

한 줄씩 따라가기. @Configuration은 이 클래스를 프로그램이 시작될 때 읽어 빈을 등록하라는 표시이고(@Component의 한 종류다), 그 안의 commonErrorResponses()에 붙은 @Bean은 “이 메서드가 돌려주는 OperationCustomizer 객체를 빈으로 등록하라”는 뜻이다. springdoc은 등록된 OperationCustomizer를 찾아, 문서를 만들 때마다 그 안의 람다를 호출해 공통 에러 응답을 끼워 넣는다. errorResponse(...) 안의 new ObjectSchema().addProperty(...).addProperty(...)처럼 메서드를 점으로 계속 이어 붙이는 코드는, addProperty를 부를 때마다 새 객체를 만드는 게 아니라 같은 ObjectSchema 객체에 필드를 하나씩 쌓으면서 그 객체 자신을 돌려주기 때문에 가능하다. 그렇게 쌓인 필드(success·status·code·message·data·errors)가 실제 에러 응답 JSON의 모양이 된다. 메서드 마지막의 return new ApiResponse()...에 나오는 ApiResponse는 바로 앞 원칙에서 컨트롤러 메서드 위에 붙이던 애너테이션 @ApiResponse와는 다른 것이다 — 애너테이션 쪽 전체 이름은 io.swagger.v3.oas.annotations.responses.ApiResponse이고, 여기서 new로 만드는 쪽은 io.swagger.v3.oas.models.responses.ApiResponse라는, 애너테이션이 아니라 메서드를 이어 붙여 값을 채우는 평범한 자바 클래스다. 이름이 같아서 헷갈리기 쉽지만, 하나는 코드에 붙이는 표시이고 다른 하나는 new로 만들어 쓰는 보통의 객체다. 마지막의 .content(new Content().addMediaType(MediaType.APPLICATION_JSON_VALUE, ...))는 이 에러 응답의 본문이 어떤 형식인지를 문서에 적는 부분이다. Content는 OpenAPI 문서에서 “응답 본문이 이런 형식으로 온다”를 담는 객체이고, MediaType.APPLICATION_JSON_VALUE는 그 형식이 JSON이라는 뜻의 문자열 값(application/json)이다 — 응답 헤더의 Content-Type에 해당하는 값을 문서에도 똑같이 적어 주는 것이다.

이제 컨트롤러에는 그 API에만 존재하는 에러만 적는다.

// ✅ 공통 에러는 전역에서, 특이 케이스만 여기에
@Operation(summary = "주문 생성", description = "재고를 차감하고 주문을 생성한다.")
@ApiResponse(responseCode = "409", description = "재고가 부족하여 주문할 수 없음")
@PostMapping("/orders")
public ResponseEntity<HttpApiResponse<OrderCreatedResponse>> createOrder(
        @RequestBody @Valid CreateOrderRequest request) {
    ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand());
    OrderCreatedResponse data = OrderCreatedResponse.from(result);
    URI location = URI.create("/orders/" + data.getOrderId());
    return HttpApiResponses.created(location, data);
}

responseCode = "409"의 409는 HTTP 상태 코드다 — 요청 자체는 올바르지만 지금 서버 상태와 충돌해서 처리할 수 없다는 뜻이고, 여기서는 재고 부족이 그 경우다.

읽는 사람 입장에서도 이득이 있다. 컨트롤러에 붙어 있는 @ApiResponse는 곧 “이 API에만 있는 특별한 실패 케이스”라는 신호가 되기 때문이다. 전부 나열되어 있으면 그중 무엇이 이 API 고유의 것인지 구분할 수 없다.


원칙 5. API 그룹은 컨트롤러 단위로 묶는다

Swagger UI에서 API를 묶는 @Tag는 컨트롤러 클래스에 하나만 붙인다.

@Tag(name = "주문", description = "주문 생성 및 조회 API")
@RestController
@RequiredArgsConstructor
public class OrderController {
    // ...
}

@RestController는 이 클래스가 요청을 받는 Controller라는 뜻이고, @RequiredArgsConstructor는 Lombok이 final 필드만 받는 생성자를 자동으로 만들어 준다는 뜻이다(@RequiredArgsConstructor). 이 둘은 이미 있던 애너테이션이고, @Tag만 그 위에 새로 붙는다.

메서드마다 다른 태그를 붙여 한 컨트롤러의 API를 여러 그룹으로 흩뜨리거나, 여러 컨트롤러를 하나의 태그로 합치지 않는다. 그렇게 하면 Swagger UI의 그룹 구조와 실제 코드 구조가 어긋나서, 문서에서 본 API가 어느 클래스에 있는지 찾기 어려워진다.

다만 “그래서 컨트롤러를 어떤 기준으로 쪼갤 것인가”는 문서화가 아니라 설계 문제다. 이 표준은 태그와 컨트롤러를 1:1로 맞춘다는 규칙만 정하고, 컨트롤러 책임 분리 기준은 별도 주제로 다룬다.


판단 기준 정리

문서화 대상 어디에 적는가 비고
필드의 의미 @Schema(description) 모든 필드에 필수
필드 예시 값 @Schema(example) 모든 필드에 필수
필수 여부 @NotNull / @NotBlank / @NotEmpty @Schema에 중복 표기 금지
길이 제한 @Size @Schema에 중복 표기 금지
숫자 범위 @Min / @Max @Schema에 중복 표기 금지
문자열 형식 @Pattern @Schema에 중복 표기 금지
API 요약/설명 @Operation 컨트롤러 메서드
API 그룹 @Tag 컨트롤러 클래스당 하나
공통 에러 응답 OperationCustomizer 전역 1회 등록
개별 에러 응답 @ApiResponse 해당 API 고유 케이스만
제네릭 래퍼 스키마 이름 별도 처리 없음 springdoc 자동 생성 이름 사용

이 표준을 정하기까지

시작은 “어디까지 적을 것인가”였다. 설명을 모든 필드에 강제할지, 이름만으로 명확한 건 생략할지 고민했는데 답은 금방 나왔다. 예외를 허용하면 “이건 명확한가?”를 매번 판단해야 하고, 그 판단이 사람마다 달라진다. 강제하는 쪽이 규칙으로서 훨씬 단순하다. example도 같은 이유로 필수에 넣었다 — Swagger UI에서 바로 테스트할 수 있다는 실질적 이득까지 있었다.

그다음 발견한 것이 중복 표기 문제였다. @Size(max = 50)과 @Schema(maxLength = 50)을 나란히 적는 코드가 흔한데, springdoc이 애초에 Bean Validation을 읽어서 문서에 반영한다는 걸 확인하고 나니 @Schema 쪽은 순수한 중복이었다. 게다가 둘이 어긋났을 때 문서가 거짓말을 하게 된다는 게 결정적이었다. 그래서 역할을 명확히 갈랐다 — 검증 어노테이션은 제약의 단일 출처, @Schema는 코드로 표현 불가능한 것(설명, 예시)만.

HttpApiResponse<T>를 도입하면서 제네릭 스키마 이름 문제가 따라왔다. HttpApiResponseOrderResponse 같은 이름이 Response 종류만큼 생겨나는 게 걸려서 ModelConverter로 이름을 정리하는 방법을 검토했다. 그런데 따져보니 구조도 data의 타입 정보도 이미 정확했고, 바뀌는 건 이름표뿐이었다. 클라이언트 코드 생성에도 영향이 없었다. 유지보수할 클래스를 하나 늘릴 만한 이유가 아니라고 판단해서 그대로 두기로 했다.

에러 응답도 같은 방식으로 갈랐다. 메서드마다 @ApiResponse를 다 적으면 정확하지만 복사-붙여넣기가 끝없이 늘어나고, 전역으로만 처리하면 API별 고유 에러를 표현할 수 없다. 둘을 나눠서 공통은 OperationCustomizer로 한 번, 특이 케이스만 메서드에 적기로 했다. 부수 효과로 컨트롤러에 남은 @ApiResponse가 “이 API만의 특별한 실패”라는 신호가 되어 오히려 읽기 좋아졌다.

@Tag는 규칙만 남기고 이유는 덜어냈다. API를 어떤 단위로 묶을지 정하려다 보니 결국 “컨트롤러를 어떻게 쪼갤 것인가”라는 질문에 도달했다. 그건 문서화 규칙이 아니라 설계 규칙이고, 리소스 기준이냐 유스케이스 기준이냐처럼 따로 다룰 만한 무게가 있는 주제다. 그래서 이 글에는 컨트롤러와 태그를 1:1로 맞춘다는 기계적인 규칙만 남기고, 분리 기준 자체는 다음 주제로 넘겼다.


정리

  • 모든 필드에 description과 example을 붙인다. 예외를 두지 않는다
  • 제약 조건은 Bean Validation에만 적는다. springdoc이 자동으로 문서에 반영한다
  • @Schema에 requiredMode, maxLength 등을 중복 표기하지 않는다. 어긋나는 순간 문서가 거짓이 된다
  • 제네릭 래퍼의 자동 생성 스키마 이름을 그대로 쓴다. 이름만의 문제라 별도 컨버터를 만들지 않는다
  • 공통 에러 응답은 OperationCustomizer로 전역 등록하고, API 고유 에러만 @ApiResponse로 적는다
  • @Tag는 컨트롤러 클래스당 하나만 붙인다. 문서의 그룹 구조와 코드 구조를 일치시킨다

문서화도 결국 같은 원칙 위에 있다 — 하나의 정보는 한 곳에만 존재해야 한다. 이는 계층별 DTO 네이밍 표준에서 계층마다 타입을 나눠 책임을 한 곳에 모았던 것과, Null-free 객체 설계에서 검증을 생성자 한 곳으로 몰았던 것과 같은 생각이다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기