API 문서가 코드와 어긋나는 순간, 문서는 없느니만 못한 것이 된다. 클라이언트 개발자는 문서를 믿고 구현했는데 실제 서버는 다르게 동작하기 때문이다. 그리고 이 어긋남은 대개 같은 정보를 두 군데에 적어놓고 한 쪽만 고쳤을 때 생긴다.
이 글은 계층별 DTO 네이밍 표준에서 정리한 Controller 계층 DTO(XxxRequest, XxxResponse, HttpApiResponse<T>)를 springdoc-openapi로 문서화할 때의 규칙을 정리한다. 핵심 전제는 하나다 — 하나의 정보는 한 곳에만 적는다.
원칙 1. 모든 필드에 설명과 예시를 붙인다
필드 이름만으로 의미가 명확해 보여도 예외를 두지 않는다. “명확하다”는 기준이 사람마다 다르고, 예외를 허용하는 순간 어디까지 적어야 하는지 매번 판단해야 하기 때문이다.
// ❌ 설명도 예시도 없다
@Getter
public class CreateOrderRequest {
private final String productName;
private final Integer quantity;
// 생성자 생략
}
productName이 상품 코드인지 표시용 이름인지, quantity의 단위가 개수인지 박스 수인지 문서만 봐서는 알 수 없다.
// ✅ 모든 필드에 description과 example
@Getter
public class CreateOrderRequest {
@Schema(description = "주문할 상품의 표시명", example = "무선 마우스")
private final String productName;
@Schema(description = "주문 수량 (낱개 기준)", example = "3")
private final Integer quantity;
// 생성자 생략
}
example을 강제하는 이유는 Swagger UI의 “Try it out”이 바로 동작하기 때문이다. 예시 값이 없으면 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 스펙을 자동으로 채운다. 그런데 이걸 모르고 @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 메서드는 HttpApiResponse<T>로 응답을 감싼다.
@PostMapping("/orders")
public HttpApiResponse<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest request) {
ServiceResult<OrderDomain> result = orderService.createOrder(request.toCommand());
return HttpApiResponse.success(OrderResponse.from(result));
}
이때 springdoc은 제네릭 타입을 리플렉션으로 읽어서 다음과 같은 스키마를 자동 생성한다.
"HttpApiResponseOrderResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"status": { "type": "integer", "format": "int32" },
"message": { "type": "string" },
"data": { "$ref": "#/components/schemas/OrderResponse" }
}
}
data 필드가 OrderResponse를 정확히 가리키고 있다는 점이 중요하다. 타입 정보는 전혀 손실되지 않는다. 다만 스키마 이름이 HttpApiResponseOrderResponse, HttpApiResponseProductResponse처럼 자동 조합되어, Swagger UI 하단 Schemas 목록이 다소 길어진다.
ModelConverter를 구현해서 이 이름을 OrderResponseApiResponse 같은 형태로 바꿀 수는 있다. 하지만 바뀌는 것은 이름뿐이고, 스키마 구조도 data의 타입 정보도 완전히 동일하다. 클라이언트 코드 생성 결과에도 영향이 없다. 순전히 Schemas 목록을 훑을 때의 가독성 문제다.
그래서 이 표준은 자동 생성된 이름을 그대로 쓴다. 가독성 하나를 위해 별도 컨버터 클래스를 만들고 springdoc 버전이 올라갈 때마다 유지보수하는 비용이, 얻는 것보다 크다고 판단했다.
원칙 4. 에러 응답은 전역으로 등록하고, 특이 케이스만 개별 명시한다
HttpApiResponse<T>는 실패 응답도 같은 구조로 내보내므로, 대부분의 엔드포인트가 동일한 에러 응답 형태를 공유한다. 이걸 메서드마다 적으면 순수한 복사-붙여넣기가 된다.
// ❌ 모든 컨트롤러 메서드에 같은 내용을 반복
@Operation(summary = "주문 생성")
@ApiResponses({
@ApiResponse(responseCode = "400", description = "요청 값이 유효하지 않음"),
@ApiResponse(responseCode = "401", description = "인증 정보가 없거나 만료됨"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/orders")
public HttpApiResponse<OrderResponse> 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(400001))
.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)));
}
}
이제 컨트롤러에는 그 API에만 존재하는 에러만 적는다.
// ✅ 공통 에러는 전역에서, 특이 케이스만 여기에
@Operation(summary = "주문 생성", description = "재고를 차감하고 주문을 생성한다.")
@ApiResponse(responseCode = "409", description = "재고가 부족하여 주문할 수 없음")
@PostMapping("/orders")
public HttpApiResponse<OrderResponse> createOrder(@RequestBody @Valid CreateOrderRequest request) {
ServiceResult<OrderDomain> result = orderService.createOrder(request.toCommand());
return HttpApiResponse.success(OrderResponse.from(result));
}
읽는 사람 입장에서도 이득이 있다. 컨트롤러에 붙어 있는 @ApiResponse는 곧 “이 API에만 있는 특별한 실패 케이스”라는 신호가 되기 때문이다. 전부 나열되어 있으면 그중 무엇이 이 API 고유의 것인지 구분할 수 없다.
원칙 5. API 그룹은 컨트롤러 단위로 묶는다
Swagger UI에서 API를 묶는 @Tag는 컨트롤러 클래스에 하나만 붙인다.
@Tag(name = "주문", description = "주문 생성 및 조회 API")
@RestController
@RequiredArgsConstructor
public class OrderController {
// ...
}
메서드마다 다른 태그를 붙여 한 컨트롤러의 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 객체 설계에서 검증을 생성자 한 곳으로 몰았던 것과 같은 생각이다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/swagger-documentation-standard/SKILL.md
---
name: swagger-documentation-standard
description: springdoc-openapi 기반 Swagger 문서화 규칙. Controller, Request/Response DTO, @Schema, @Operation, @ApiResponse 관련 코드를 생성하거나 리뷰할 때 반드시 적용한다.
---
# Swagger 문서화 표준
이 프로젝트는 springdoc-openapi를 사용한다. 문서화의 대원칙은 **하나의 정보는 한 곳에만 적는다**이다.
## 필드 문서화
- Request/Response DTO의 **모든 필드**에 `@Schema(description = "...", example = "...")`를 붙인다. 이름만으로 명확해 보여도 생략하지 않는다.
- `description`은 필드의 의미와 단위를 적는다. `example`은 실제로 통과하는 값으로 적는다.
## 제약 조건은 중복 표기하지 않는다
- 필수 여부는 `@NotNull` / `@NotBlank` / `@NotEmpty`로만 표현한다. `@Schema(requiredMode = ...)`를 함께 쓰지 않는다.
- 길이 제한은 `@Size`로만 표현한다. `@Schema(maxLength/minLength)`를 함께 쓰지 않는다.
- 숫자 범위는 `@Min` / `@Max` / `@DecimalMin` / `@DecimalMax`로만 표현한다. `@Schema(minimum/maximum)`을 함께 쓰지 않는다.
- 문자열 형식은 `@Pattern`으로만 표현한다. `@Schema(pattern = ...)`을 함께 쓰지 않는다.
- 이유: springdoc이 Bean Validation 어노테이션을 읽어 OpenAPI 스펙에 자동 반영한다. 두 군데에 적으면 서로 어긋나 문서가 거짓 정보를 제공하게 된다.
- 따라서 `@Schema`에는 `description`과 `example`만 지정한다.
## 응답 문서화
- Controller 메서드에는 `@Operation(summary = "...")`을 붙인다.
- 400/401/500 같은 공통 에러 응답은 `OperationCustomizer` Bean에서 전역으로 등록한다. 컨트롤러 메서드에 반복해서 적지 않는다.
- 해당 API에만 존재하는 에러(예: 409 재고 부족)만 메서드에 `@ApiResponse`로 적는다.
- `HttpApiResponse<T>` 제네릭 래퍼의 스키마 이름은 springdoc이 자동 생성하는 이름(`HttpApiResponseOrderResponse` 등)을 그대로 쓴다. 이름을 바꾸기 위한 `ModelConverter`를 만들지 않는다.
## API 그룹
- `@Tag`는 컨트롤러 클래스에 하나만 붙인다. 메서드마다 다른 태그를 붙이거나, 여러 컨트롤러를 하나의 태그로 합치지 않는다.
## 검증
- Controller의 `@RequestBody` 파라미터에는 `@Valid`를 붙여 검증 어노테이션이 실제로 동작하게 한다.
GitHub Copilot — .github/instructions/swagger-documentation-standard.instructions.md
---
description: springdoc-openapi 기반 Swagger 문서화 규칙
applyTo: "**/*.java"
---
- Request/Response DTO의 모든 필드에 `@Schema(description = "...", example = "...")`를 붙인다.
- `@Schema`에는 `description`과 `example`만 지정한다.
- 필수 여부는 `@NotNull`, `@NotBlank`, `@NotEmpty`로만 표현하고 `@Schema(requiredMode)`를 함께 쓰지 않는다.
- 길이 제한은 `@Size`로만 표현하고 `@Schema(maxLength)`, `@Schema(minLength)`를 함께 쓰지 않는다.
- 숫자 범위는 `@Min`, `@Max`로만 표현하고 `@Schema(minimum)`, `@Schema(maximum)`을 함께 쓰지 않는다.
- 문자열 형식은 `@Pattern`으로만 표현하고 `@Schema(pattern)`을 함께 쓰지 않는다.
- Controller 메서드에는 `@Operation(summary = "...")`을 붙인다.
- `@Tag`는 컨트롤러 클래스에 하나만 붙인다.
- 메서드마다 다른 `@Tag`를 붙이거나 여러 컨트롤러를 하나의 `@Tag`로 합치지 않는다.
- 400, 401, 500 같은 공통 에러 응답은 컨트롤러에 적지 않는다. `OperationCustomizer` Bean에서 전역으로 등록한다.
- 해당 API에만 존재하는 에러 응답만 메서드에 `@ApiResponse`로 적는다.
- `HttpApiResponse<T>`의 스키마 이름을 바꾸는 `ModelConverter`를 만들지 않는다.
- Controller의 `@RequestBody` 파라미터에는 `@Valid`를 붙인다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기