본 글은, 필자가 Swagger를 사용하면서 계속되어 사용법이 업데이트 될 예정입니다.
- 2023.03.24 : Tag / Operation / ApiResponses / ApiResponse / Parameter
- 2023.03.25 : @ControllerAdvice에서의 사용, @Schema, @Schema의 hidden 옵션과 문제 해결
🏝 0. 서론
백엔드 개발자가 REST API를 잘 개발하는 것은 평생의 숙원입니다. 이렇게 잘 만든 API에 대해 잘 정리한 문서가 있다면, 사용하는 클라이언트 개발자와, 인수인계를 받거나, 사내 API를 호출할 EndPoint에 대해 궁금해하는 다른 백엔드 개발자에게 굉장히 유효한 자원으로 남길 수 있습니다!
학생 때는, API 문서를 따로 외부 문서로 작성해서 저장을 했으나 개발을 진행할 때마다 최신화를 해주는 과정에서 굉장한 불편함을 느꼈습니다. 그 중에 휴먼에러가 발생해서 소통에 오류가 생겼던 경험도 있습니다.
우리는 코드를 작성하는 사람입니다. 코드레벨에서 API 문서를 자동화하여 작성할 수 있고, 코드 레벨에서 문서를 최신화 할 수 있다면 얼마나 좋을까요? 또한 클라이언트가 input value를 넣어 문서상에서 테스트를 진행할 수 있다면 얼마나 좋을까요?
- REST API 를 설계, 빌드, 문서화 및 사용하는데 도움이 되는 OpenAPI 사양을 중심으로 구축된 오픈 소스 도구 세트
- 코드 몇 줄 추가를 통해 적용하기 쉬우며, 문서 화면에서 UI를 통해 바로 API 테스트가 가능한 장점
위와 같은 장점을 가지고 있는 Swagger를 통해 이러한 문제를 해결해봅시다!
🤔 1. Swagger 프로젝트에 적용
1-1. Springdoc vs SpringFox
Swagger는 두 가지 라이브러리를 선택할 수 있습니다. 하나는 Springdoc이고, 하나는 SpringFox입니다.
[springdoc] https://mvnrepository.com/artifact/org.springdoc/springdoc-openapi-ui
[springfox] https://mvnrepository.com/artifact/io.springfox/springfox-swagger2
Springfox와 Springdoc의 차이는 다음과 같습니다.
Springfox | Springdoc | |
업데이트 최신화 | Jul 14, 2020 (3.0.0 version) | Mar 09, 2023 (1.6.15 version) |
webflux 지원 | X (신버전은 지원.) | O |
비고 | 한동안 업데이트가 끊겼다가, 간간히 업데이트 | 후발주자, Springfox 개선 |
꾸준한 업데이트와 좀 더 많은 레퍼런스나 자료를 제공하고 있는 Springdoc이 좀 더 낫다는 의견이 많았습니다. 기존에 Springfox를 사용하던 사람들에게 이주를 시키기 위한 방법도 Springdoc에 친절히 나와있었습니다.
따라서 저도 Springdoc을 이용해서 스프링 프레임워크에서 쉽게 Swagger를 사용해보도록 하겠습니다.
1-2. build.gradle 설정
- spring fox
implementation 'io.springfox:springfox-boot-starter:3.0.0'
implementation 'io.springfox:springfox-swagger-ui:3.0.0'
- spring doc
implementation 'org.springdoc:springdoc-openapi-ui:1.6.15'
저는 Springdoc을 사용해서 swagger를 사용하기로 했습니다.
간단하게 의존성만 추가한 후,
- http://localhost:8080/swagger-ui.html
위의 링크를 통해 접속하면(포트, ip 따로 지정 안 한 경우) 아래와 같이 Swagger UI를 확인할 수 있습니다.
1-3. application.yml 설정
위의 링크에 들어가면, application.yml에 설정하는 프로퍼티에 대해 나와있으니, 자신이 필요한 값에 대해서 옵션을 조정하면 됩니다.
springdoc:
override-with-generic-response: true
default-consumes-media-type: application/json # 소비 미디어 타입
default-produces-media-type: application/json # 생산 미디어 타입
swagger-ui:
operations-sorter: method # operations 정렬 방식은 HTTP Method 순
tags-sorter: alpha # tag 정렬 방식은 알파벳 순
path: "swagger.html" # http://localhost:8080/swagger.html로 접속 가능
저는 조금 더 사용하면서 필요한 프로퍼티를 추가하기 위해, 기본적으로 위와 같은 설정을 진행했습니다.
🤔 2. Swagger 사용하기
0. Swagger2 → Swagger3의 변화, Config 파일 작성하기
- 어노테이션에 많은 변화가 생겨, Swagger3로 변화한 이후에는 아래에 해당한 어노테이션을 사용해야 합니다.
@ApiParam → @Parameter
@ApiOperation → @Operation
@Api → @Tag
@ApiImplicitParams → @Parameters
@ApiImplicitParam → @Parameter
@ApiIgnore → @Parameter(hidden = true) / @operation(hidden = true) / @hidden
@apimodel → @Schema
@ApiModelProperty → @Schema
Swagger UI를 구성하기 위한 Config 파일을 하나 생성해줍니다.
@Configuration
public class GTOpenApiConfig {
@Bean
public OpenAPI openAPI() {
Info info = new Info()
.version("v1.0.0")
.title("GT(God Tong) API")
.description("God Tong (갓통) 팬과 스타의 자유로운 만남");
return new OpenAPI()
.info(info);
}
}
위와 같은 간단한 메서드를 만들어 빈으로 등록해줍시다.
version과, title, description은 각자의 프로젝트에 맞게 작성하면 됩니다.
1. @Tag와 @Operation
@Tag(name = "xxx", description = "xxx")
public class TestController{
// ...
}
@Tag는 API의 그룹 설정을 위한 태그입니다. REST API의 EndPoint는 컨트롤러에 있으므로, 해당 컨트롤러 클래스 위에 @Tag 어노테이션을 통해 API의 그룹을 설정할 수 있습니다.
위의 예시 코드 처럼, name과 description 프로퍼티를 통해 값을 커스텀 할 수 있습니다.
아래는, name = "GT Member", description = "GT의 회원 관련~"으로 설정한 후 Swagger UI를 확인한 모습입니다.
API의 그룹인 'GT Member'를 확인할 수 있고, 옆의 작은 폰트로 설명인 description이 달려있는 모습을 볼 수 있습니다.
이제 @Tag 어노테이션이 붙은 클래스 내부의 각 @Operation 어노테이션이 붙어있는 메소드를 한 그룹으로 묶이게 해줍니다.
@Tag(name = "xxx", description = "xxx")
public class TestController{
@Operation(summary = "xxx", description = "xxx")
@GetMapping("/test")
public void test(){
}
}
@Operation 어노테이션은, 각 API에 대한 상세 설명을 기재할 수 있는 어노테이션입니다. 해당 어노테이션에서 중요한 프로퍼티는 summary와 description입니다.
둘 다 String Type으로 입력이 가능하고, 예시로 summary = "회원 가입 (joinIn)", description = "GT 서비스에 회원가입~"와 같이 입력했다면, Swagger UI에는 다음과 같이 출력됩니다.
또한 @Operation 어노테이션의 parameters 프로퍼티를 사용해서, Swagger UI에 파라미터 입력 창의 띄울 수 있습니다.
@Operation(summary = "회원 가입 (joinIn)", description = "GT 서비스에 회원가입합니다. GT 에서 기본적으로 제공하는 회원가입 서비스입니다.",
parameters = {@Parameter(name = "GTJoinInRequest", description = "회원가입 요청 객체")})
위와 같은 어노테이션을 메소드위에 붙이면, 아래와 같은 화면이 나옵니다.
위와는 다르게 새로운 파라미터가 생겼습니다! 옆의 Try it out을 클릭하면, 해당 빈 칸에 값을 넣어 테스트 해볼 수 있는 시스템도 갖춰져 있습니다.
2. @ApiResponses와 @ApiResponse
요청은 위의 @Operation 내부의 @Parameter를 통해 나타낼 수 있었습니다. 이제부터, 해당 API가 내보내는 Response를 API 문서에 적어보도록 합시다.
API의 응답은 여러가지 케이스가 있습니다. 요청을 비즈니스 로직에 맞추어 잘 처리를 했을 때, 올바르지 않은 요청이 들어왔을 때, 서버 내부적인 에러에 의해 비즈니스 로직을 완수하지 못했을 때 등, 다양한 상황에 맞춰 어떤 방식으로 응답이 오는지 기록해야 할 필요성이 있습니다.
이에 맞춰 Springdoc에서는 @ApiResponses를 지원합니다.
@ApiResponses(value = { ... })
@Target({METHOD, TYPE, ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface ApiResponses {
ApiResponse[] value() default {};
Extension[] extensions() default {};
}
value의 타입은 ApiResponse[]이므로, value = {...} 부분에 @ApiResponse()를 여러 개 삽입하는 방식으로 구현할 수 있습니다.
즉, @ApiResponses는 여러 @ApiResponse를 한 그룹으로 묶어주는 역할을 합니다.
이제 각각의 응답에 대해 적어봅시다!
@Target({METHOD, TYPE, ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Repeatable(ApiResponses.class)
public @interface ApiResponse {
String description() default "";
String responseCode() default "default";
Header[] headers() default {};
Link[] links() default {};
Content[] content() default {};
Extension[] extensions() default {};
String ref() default "";
boolean useReturnTypeSchema() default false;
}
@ApiResponse는 description과 responseCode를 가지고 있습니다.
responseCode는 HttpStatus의 코드를 적으면 됩니다. (ex. 200, 404, 500 등)
description은 해당 응답에 대한 설명을 기재하면 됩니다.
해당 Response에 Header, Link, Content에 대해서도 문서에 기록할 수 있으므로, 필요 시 해당 어노테이션을 작성하면서 추가하면 됩니다.
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successful Join Us!"),
@ApiResponse(responseCode = "400", description = "Bad Request")
})
컨트롤러의 메소드 위에 위와 같은 어노테이션을 붙였다고 가정해 봅시다.
그렇다면, Swagger UI에서는 다음과 같은 화면을 볼 수 있습니다.
이러한 내용은 @ControllerAdvice에서도 마찬가지입니다.
@ExceptionHandler가 붙은 메소드에, 하나의 요청 응답 코드에 대한 예외를 처리한다면 @ResponseStatus 어노테이션을 사용해 정의하면 자동으로 해당 에러에 대한 응답이 Swagger UI에 반영됩니다.
하지만, 만약 ErrorResponse에 대해 Custom으로 정의했고, 상황에 따라 에러코드가 변화된다면 어떨까요?
@ResponseStatus는 constant한 값을 원하기 때문에 하나의 어노테이션으로 정의할 수 없습니다.
이때에도 마찬가지로 @ApiResponses와 @ApiResponse를 사용해서 처리할 수 있습니다.
@ExceptionHandler(value = {GTApiException.class})
@ApiResponses(value = {
@ApiResponse(responseCode = "400", description = "Bad Request!"),
@ApiResponse(responseCode = "403", description = "Forbidden! Already Exist"),
})
public ResponseEntity<GTErrorResponse> handleExceptionFromAPIMethod(GTApiException ex){
GTErrorCode errorCode = ex.getErrorCode();
return handleExceptionInternal(errorCode);
}
위와 같이 커스텀한 'GTApiException'에 대해 핸들링하고 있는 메소드에 대해, 발생한 Exception의 Enum 값에 따라 다양한 HTTP Status 코드를 갖는 상황입니다. 이때, @ApiResponse를 사용해서 해당 에러 코드들에 대해 responseCode와 description을 사용하면 이 내용을 Swagger UI에 잘 반영할 수 있습니다.
이와 같이 잘 반영된 모습을 확인할 수 있습니다.
3. @Schema
아직 부족한 점이 더 있습니다. Request Body와 Response의 Example Value와 Schema에, 각 필드에 매칭되는 타입만 기재되어 있습니다. 타입 뿐만 아니라 정확한 예시 데이터나 해당 필드에 대한 상세 설명이 부가적으로 들어가 있다면 더 좋은 API 문서가 될 것입니다.
이렇게 Request와 Response DTO에 대해 명세를 적을 수 있는 어노테이션이 바로 @Schema입니다.
@Schema(description = "테스트용 DTO")
public class ExampleDTO{
@Schema(description = "테스트 값", example = "abcd")
public String value;
}
위와 같은 방식으로 @Schema 어노테이션을 활용할 수 있습니다.
- 클래스에 어노테이션 : 해당 Request / Response 객체에 대해 정의, Swagger UI의 'schema' 탭에서 확인 가능
- 필드에 어노테이션 : 해당 필드에 대한 설명, 예시 데이터에 대해 설정 가능.
Response 객체 역시 @Schema 어노테이션을 똑같이 활용하면 되지만, Swagger UI에 적용하기 위해서 한 단계 더 거쳐야 합니다.
@ApiResponse의 'content' 프로퍼티에, @Content 어노테이션을 사용한 컨텐츠를 삽입해야 합니다.
// ... 생략
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successful Join Us!",
content = @Content(schema = @Schema(implementation = GTJoinInResponse.class))),
})
public void test(){}
위와 같이 @Schema 어노테이션으로 구성한 Response 클래스를 implementation한 컨텐츠를 생성하면, 최종적으로 Swagger UI에도 Response에 대한 명세도 반영이 됩니다.
4. @Schema의 hidden 옵션과 문제 해결
@Schema에는 hidden 옵션이 있어서 hidden = true 옵션을 주게 되면, Swagger UI에 출력되지 않습니다.
그러나 저는 다음과 같은 상황을 마주쳤습니다.
@Getter
@Builder
@Schema(description = "GT의 에러 응답 객체")
@RequiredArgsConstructor
public class GTErrorResponse {
@Schema(description = "GT 커스텀 에러코드", example = "INVALID_PARAMETER")
private final String code;
@Schema(description = "GT 에러 상세메시지로, ErrorCode 내장 메시지 + Exception 메시지가 합쳐진 형태",
example = "Invalid parameter included :: 나이(age) 필드에는 1 이상의 숫자가 입력되어야 합니다.")
private final String message;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@Schema(description = "Validation Error 발생시 상세 필드 에러 기재", nullable = true)
private final List<ValidationError> errors;
@Getter
@Builder
@RequiredArgsConstructor
public static class ValidationError {
// ...
}
}
이러한 ErrorResponse를 구현했고, List<ValidationError>는 유효성 검사를 진행했을 때 어떤 필드에서 에러가 찍혔는지 상세하게 나타내기 위해서 필드에 포함시켰습니다.
하지만, JsonInclude.Include.NON_EMPTY를 사용해서 List가 비어있지 않은 경우에 Json에 포함시키도록 설정했습니다.
여기까지 설정을 한다면, Swagger UI에서는 이러한 조건을 판단하기 어려워 모두 출력하게 됩니다.
위의 예시에서 400에러와 403 에러를 보도록 합시다. 둘 다 return 값은 GTErrorResponse로 동일합니다.
하지만, Validation 에러가 발생한 결과로 400 에러를 출력할 예정이므로, 400 에러에 대해서는 그대로 출력하고,
403 에러에 대해서는 "errors" 필드를 제거하고 싶은 상황입니다. 이러한 경우엔 어떻게 해야 할까요?
@Schema의 hidden 값을 조정하게 되면, 모든 에러에 대해 반영되므로 hidden을 사용하긴 어려워 보입니다.
제가 선택한 방법은, @JsonView 입니다.
@JsonView는 동일한 POJO 오브젝트에 대해서 선택적으로 서로 다른 프라퍼티가 조합된 JSON 문자열을 만들 수 있습니다.
따라서, 위의 List<ValidationError>에 대해서는 400 Error의 Response에서만 보이도록 조정할 수 있습니다.
1. JsonView Class 생성
public class GTJsonView {
public static class Common{}
public static class Hidden extends Common{}
}
위와 같은 JsonView 클래스를 생성했습니다. 단지 클래스를 구분하는 역할이므로, public static 키워드를 사용했습니다.
Common은 공통적으로 Json에 포함되는 내용이며, Hidden은 Common과 함께 추가로 Json에 포함되어야 하는 내용이므로 extends를 이용해 상속해줬습니다.
2. @JsonView 설정
@Getter
@Builder
@Schema(description = "GT의 에러 응답 객체")
@RequiredArgsConstructor
public class GTErrorResponse {
@Schema(description = "GT 커스텀 에러코드", example = "INVALID_PARAMETER")
@JsonView(GTJsonView.Common.class)
private final String code;
@Schema(description = "GT 에러 상세메시지로, ErrorCode 내장 메시지 + Exception 메시지가 합쳐진 형태",
example = "Invalid parameter included :: 나이(age) 필드에는 1 이상의 숫자가 입력되어야 합니다.")
@JsonView(GTJsonView.Common.class)
private final String message;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonView(GTJsonView.Hidden.class)
@Schema(description = "Validation Error 발생시 상세 필드 에러 기재", nullable = true)
private final List<ValidationError> errors;
@Getter
@Builder
@RequiredArgsConstructor
public static class ValidationError {
/ ...
}
}
이제 각 필드에 @JsonView를 설정해줍니다. value를 Class<?>[] 타입을 갖기 때문에 맞는 방금 만들어준 JsonView class를 입력해주면 됩니다.
이제 컨트롤러의 메소드에도 적용해봅시다.
@ExceptionHandler(value = {GTApiException.class})
@ApiResponses(value = {
@ApiResponse(responseCode = "403", description = "Forbidden! Already Exist ID or Member"),
})
@JsonView(GTJsonView.Common.class)
public ResponseEntity<GTErrorResponse> handleExceptionFromGTMemberController(GTApiException ex){
GTErrorCode errorCode = ex.getErrorCode();
String detailErrorMessage = ex.getMessage();
return handleExceptionInternal(errorCode, detailErrorMessage);
}
이 부분에는 Common 처리를 했습니다. Hidden에 속한 필드는 보이지 않겠죠.
@ControllAdvice에 400 에러가 있기 때문에, 여기의 코드도 발췌해 보겠습니다.
@Override
@ApiResponses(value = {
@ApiResponse(responseCode = "400", description = "Bad Request!",
content = @Content(schema = @Schema(implementation = GTErrorResponse.class))),
})
@JsonView(GTJsonView.Hidden.class)
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
log.warn("handleIllegalArgument", ex);
GTErrorCode errorCode = GTCommonErrorCode.INVALID_PARAMETER;
return handleExceptionInternal(errorCode, ex);
}
여기는 Hidden까지 허용하므로, Swagger UI에서 확인할 수 있습니다.
이제 결과를 확인해보도록 하겠습니다.
🏃♂️ 출처
[링크] https://velog.io/@ychxexn/Swagger-%EB%8B%A4-%EA%B0%99%EC%9D%80-%EB%86%88%EC%9D%B4-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4.-Springfox-vs-Springdoc
[링크] https://www.javatpoint.com/swagger