📡 백엔드/🌱 Spring Boot

[Spring] Swagger 공통 응답 예시 커스터마이징

gengminy 2024. 3. 17. 18:46
SuccessResponse 를 @RestControllerAdvice 를 이용해 전역 처리 중이며
Swagger Example 커스터마이징을 하고 싶은 경우
이 글을 읽어주시면 도움이 될 것 입니다.

 

들어가며

Swagger 를 사용중이며, @RestControllerAdvice 를 사용해 전역 응답을 설정중인 경우
Swagger 응답 예시에서 wrapping 시켜주는 객체가 나타나지 않는다.

최근에는 springfox 공식 업데이트가 멈췄고 springdocs 를 많이 사용중이다.
springfox 같은 경우 기본 설정으로 제네릭 클래스를 응답으로 선택할 수 있는데
springdocs 에서는 이를 지원해주지 않아 다른 방법을 사용해야 한다.

 

SuccessResponseAdvice 예시

@Getter
@NoArgsConstructor
public class SuccessResponse<T> {

    private final boolean success = true;
    private final LocalDateTime timeStamp = LocalDateTime.now();
    private int status;
    private T data;

    public SuccessResponse(T data) {
        this.status = 200;
        this.data = data;
    }

    public SuccessResponse(T data, int status) {
        this.status = status;
        this.data = data;
    }
}
@RestControllerAdvice(basePackages = "kr.co.swagger")
public class SuccessResponseAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(
            Object body,
            MethodParameter returnType,
            MediaType selectedContentType,
            Class<? extends HttpMessageConverter<?>> selectedConverterType,
            ServerHttpRequest request,
            ServerHttpResponse response) {
        final HttpServletResponse servletResponse =
                ((ServletServerHttpResponse) response).getServletResponse();

        int status = servletResponse.getStatus();
        final HttpStatus resolve = HttpStatus.resolve(status);

        if (resolve == null) {
            return body;
        }

        if (resolve.is2xxSuccessful()) {
            return new SuccessResponse<>(body, status);
        }

        return body;
    }
}

 

예시 응답

실제 응답

이럴 경우 swagger 상에서 설정을 바꿔줘서 기본 예시를 wrapping 할 수 있다.

 

Swagger 동작 원리

{host}/v3/api-docs 로 접속할 경우 아래와 같이 swagger-ui 로 변환되기 이전의 JSON 데이터가 노출된다.
이를 바탕으로 스웨거는 화면에 API 문서를 띄우게 된다.

즉 이 JSON 데이터를 설정해준다면 스웨거가 그리는 화면을 수정할 수 있다는 것이다.

 

Swagger Customizer

스웨거에서는 customizer 라는 함수형 인터페이스를 제공하는데
이를 빈으로 등록하게 될 경우 커스터마이징을 할 수 있다

이 중에서 컨트롤러 메소드를 처리하는 OperationCustomizer 를 사용할 것이다.

@FunctionalInterface
public interface OperationCustomizer {
    Operation customize(Operation operation, HandlerMethod handlerMethod);
}

 

공통 응답 랩핑 처리

@Configuration
public class SwaggerConfig {
    ...

    @Bean
    public OperationCustomizer operationCustomizer() {
        return (operation, handlerMethod) -> {
            this.addResponseBodyWrapperSchemaExample(operation);
            return operation;
        };
    }

    private void addResponseBodyWrapperSchemaExample(Operation operation) {
        final Content content = operation.getResponses().get("200").getContent();
        if (content != null) {
            content.forEach((mediaTypeKey, mediaType) -> {
                Schema<?> originalSchema = mediaType.getSchema();
                Schema<?> wrappedSchema = wrapSchema(originalSchema);
                mediaType.setSchema(wrappedSchema);
            });
        }
    }

    private Schema<?> wrapSchema(Schema<?> originalSchema) {
        final Schema<?> wrapperSchema = new Schema<>();

        wrapperSchema.addProperty("success", new Schema<>().type("boolean").example(true));
        wrapperSchema.addProperty("timeStamp", new Schema<>().type("string").format("date-time").example(
                LocalDateTime.now().toString()));
        wrapperSchema.addProperty("status", new Schema<>().type("integer").example(200));
        wrapperSchema.addProperty("data", originalSchema);

        return wrapperSchema;
    }
}

operation 의 getResponses() 를 통해 예시 응답 리스트를 꺼내고 그 중에서 200 성공 응답에 대해 가져온다.
JSON 에서 각 컨트롤러 메소드의 예시는 응답 코드 쌍으로 그룹핑된다.

그 후 내부에서는 각 mediaType 별로 그룹핑되어 있는데, 이 중에서 Schema 를 꺼내면 원본 예시가 나오게 된다.
이를 새로운 응답으로 랩핑해주면 원하는 결과가 나올 것이다.

 

기존 JSON

...        
        "/user": {
            "get": {
                "tags": [
                    "사용자 API"
                ],
                "summary": "사용자 정보 조회",
                "description": "사용자 정보를 조회합니다.",
                "operationId": "getUser",
                "responses": {
                    "200": {
                        "description": "OK",
                        "content": {
                            "application/json;charset=UTF-8": {
                                "schema": {
                                    "$ref": "#/components/schemas/UserResponse"
                                }
                            }
                        }
                    }
                }
            }
        }
...

새로운 JSON

...
			"/user": {
            "get": {
                "tags": [
                    "사용자 API"
                ],
                "summary": "사용자 정보 조회",
                "description": "사용자 정보를 조회합니다.",
                "operationId": "getUser",
                "responses": {
                    "200": {
                        "description": "OK",
                        "content": {
                            "application/json;charset=UTF-8": {
                                "schema": {
                                    "properties": {
                                        "success": {
                                            "type": "boolean",
                                            "example": true
                                        },
                                        "timeStamp": {
                                            "type": "string",
                                            "format": "date-time",
                                            "example": "2024-03-17T18:30:50.334396"
                                        },
                                        "status": {
                                            "type": "integer",
                                            "example": 200
                                        },
                                        "data": {
                                            "$ref": "#/components/schemas/UserResponse"
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
...

결과

각 스키마에 property 가 지정되며 응답이 성공적으로 wrapping 되었다.
다만 각 필드를 삽입하는 과정에서 텍스트를 하드코딩하게 되는 문제가 있는데 리플렉션으로 해결해보았다.

 

@Configuration
public class SwaggerConfig {
    ...
    
    @Bean
    public OperationCustomizer operationCustomizer() {
        return (operation, handlerMethod) -> {
            this.addResponseBodyWrapperSchemaExample(operation, SuccessResponse.class, "data");
            return operation;
        };
    }
    
    private void addResponseBodyWrapperSchemaExample(Operation operation, Class<?> type, String wrapFieldName) {
        final Content content = operation.getResponses().get("200").getContent();
        if (content != null) {
            content.keySet()
                    .forEach(mediaTypeKey -> {
                        final MediaType mediaType = content.get(mediaTypeKey);
                        mediaType.schema(wrapSchema(mediaType.getSchema(), type, wrapFieldName));
                    });
        }
    }
    
    @SneakyThrows
    private <T> Schema<T> wrapSchema(Schema<?> originalSchema, Class<T> type, String wrapFieldName) {
        final Schema<T> wrapperSchema = new Schema<>();
        final T instance = type.getDeclaredConstructor().newInstance();
        
        for (Field field : type.getDeclaredFields()) {
            field.setAccessible(true);
            wrapperSchema.addProperty(field.getName(), new Schema<>().example(field.get(instance)));
            field.setAccessible(false);
        }
        wrapperSchema.addProperty(wrapFieldName, originalSchema);
        return wrapperSchema;
    }
}

이렇게 되면 SuccessResponse 필드 코드가 바뀌었을 때에도 조치가 가능하다.
이 부분은 최초 Swagger 로딩시에만 리플렉션이 동작하고 이후에는 캐싱되기 때문에 어느정도 감수할만 하다고 생각해서 적용해보았다.

스웨거 커스터마이징에 익숙해진다면 더 좋은 방법이 있을 수도 있을 것 같다.

 

마치며

스웨거 응답을 커스터마이징하여 API 문서의 공통 응답을 변경하는 방법을 알아보았다.
이외에도 예외 처리에 대한 문서 자동화, 복잡한 처리 등이 가능하니 잘 찾아본다면 더 깔끔하고 정확한 스웨거 문서를 작성할 수 있겠다.