[Packy] Swagger @ApiResponses를 커스텀 어노테이션으로 대체하기

반응형

1. @ApiResponses의 한계

Swagger에서 정상 응답값과 함께 에러 응답값도 명시해주어야 클라이언트 단에서도 에러 응답값에 대한 예외 처리를 할 수 있다.

Swagger에서는 기본적으로 아래와 같이 @ApiResponses와 @ApiResponse 어노테이션을 사용할 수 있다.

@ApiResponses(value = {
    @ApiResponse(responseCode = "200", description = "로그인 성공"),
    @ApiResponse(responseCode = "400", description = "로그인 실패")
})

예시 결과

 

하지만 @ApiResponses를 사용하며 여러모로 불편함을 느꼈다.

 

1. responseCode, description을 수작업으로 적어주어야 한다.

 

[Packy] API 공통 응답 포맷 만들기 - 정상 응답인 경우& 예외 처리 응답인 경우

1. 들어가며 클라이언트 단에서는 백엔드에서 개발한 API Response를 토대로 데이터 모델이라는 것을 만들어 화면에 데이터를 출력한다. 만약 통일된 규격의 API Response가 없으면 클라이언트 개발자

leeeeeyeon-dev.tistory.com

 

위 포스트에서 소개했던 것처럼 ErrorCode라는 enum 클래스를 만들어 에러 응답 DTO와 에러 코드를 관리하고 있다.

에러 코드의 httpStatus와 message를 그대로 활용하면 되지 않냐? 안된다 ...

 

@ApiResponse의 속성들은 상수 값만 가능하기 때문에 httpStatus와 message를 동적으로 가져오지 못하고, 결국은 ErrorCode 클래스를 보며 내가 일일이 옮겨적어주어야 한다.

 

2. Example Value를 커스텀하지 못한다.

 

1번에서 설명했던 이유와 마찬가지로, ErrorResponseDto를 동적으로 가져오지 못하므로 Example Value를 원하는대로 출력하는 것이 어렵다

 

3. 명세가 길어지고, 코드의 가독성이 떨어진다.

 

에러 응답이 많아질수록 @ApiResponses의 길이가 점점 길어지고 결국은 프로덕션 코드보다 Swagger 명세의 코드가 길어지는 일이 발생한다. 그러다보니 정작 중요한 프로덕션 코드에 집중되지 않았다.

 

2. 에러 응답값을 출력하는 커스텀 어노테이션을 만들어보자

그러므로 이번 포스트에서는 ErrorCode를 활용하여 코드와 예시 값을 적절하게 출력해주는 커스텀 어노테이션을 만들어볼 것이다.

 

아래 예시처럼 에러 응답값이 1개일 때와 여러 개일 때로 나누어 @ApiErrorCodeExample과 @ApiErrorCodeExamples 어노테이션을 만들 것이다.

 

어노테이션을 밑바닥부터 전부 구현하는 것은 아니고, 4번에서 후술하는 내용처럼 스웨거의 ApiResponses와 ApiResponse 객체를 적절하게 수정하여서 사용하는 것이다.

 

// 예시 1 - 에러 응답값이 1개일 때
@Operation(summary = "JWT 만료 시 재발급")
@ApiErrorCodeExample(ErrorCode.INVALID_REFRESH_TOKEN)
@PostMapping("/reissue")
public DataResponseDto<JwtResponse> reissue(@RequestBody JwtRequest jwtRequest) {
    return DataResponseDto.from(jwtService.reissueJwt(jwtRequest));
}

// 예시 2 - 에러 응답값이 여러 개일 때
@Operation(summary = "회원 가입", description = "provider는 kakao 또는 apple")
@ApiErrorCodeExamples({ErrorCode.KAKAO_SERVER_ERROR, ErrorCode.APPLE_SERVER_ERROR})
@PostMapping("/sign-up")
public DataResponseDto<JwtResponse> signUp(
    @RequestHeader("Authorization") @Schema(description = "Bearer prefix 제외해주세요") String providerAccessToken,
    @RequestBody SignupRequest signupRequest) {
    return DataResponseDto.from(authService.signUp(providerAccessToken, signupRequest));
}

 

3. 어노테이션 클래스 생성

먼저 어노테이션 클래스를 작성해주자.

  • @Target - 어노테이션이 부착될 수 있는 타입 지정
  • @Retention - 어노테이션의 메타 정보가 버려질 타이밍 설정
    • RUNTIME: 런타임동안 유지되고, 런타임동안 프로그램에서도 접근 가능, Reflection API로 접근도 가능
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiErrorCodeExample {

    ErrorCode value();
}

 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiErrorCodeExamples {

    ErrorCode[] value();
}

 

4. SwaggerConfig 작성

SwaggerConfig에 아래 코드를 추가해준다. 코드가 살짝 긴데, 우선 전체 코드를 보여준 후 네 부분으로 나누어 설명하겠다. 

@Bean
public OperationCustomizer customize() {
    return (Operation operation, HandlerMethod handlerMethod) -> {
        ApiErrorCodeExamples apiErrorCodeExamples = handlerMethod.getMethodAnnotation(
            ApiErrorCodeExamples.class);

        // @ApiErrorCodeExamples 어노테이션이 붙어있다면
        if (apiErrorCodeExamples != null) {
            generateErrorCodeResponseExample(operation, apiErrorCodeExamples.value());
        } else {
            ApiErrorCodeExample apiErrorCodeExample = handlerMethod.getMethodAnnotation(
                ApiErrorCodeExample.class);

            // @ApiErrorCodeExamples 어노테이션이 붙어있지 않고
            // @ApiErrorCodeExample 어노테이션이 붙어있다면
            if (apiErrorCodeExample != null) {
                generateErrorCodeResponseExample(operation, apiErrorCodeExample.value());
            }
        }

        return operation;
    };
}

// 여러 개의 에러 응답값 추가
private void generateErrorCodeResponseExample(Operation operation, ErrorCode[] errorCodes) {
    ApiResponses responses = operation.getResponses();

    // ExampleHolder(에러 응답값) 객체를 만들고 에러 코드별로 그룹화
    Map<Integer, List<ExampleHolder>> statusWithExampleHolders = Arrays.stream(errorCodes)
        .map(
            errorCode -> ExampleHolder.builder()
                .holder(getSwaggerExample(errorCode))
                .code(errorCode.getHttpStatus().value())
                .name(errorCode.name())
                .build()
        )
        .collect(Collectors.groupingBy(ExampleHolder::getCode));

    // ExampleHolders를 ApiResponses에 추가
    addExamplesToResponses(responses, statusWithExampleHolders);
}

// 단일 에러 응답값 예시 추가
private void generateErrorCodeResponseExample(Operation operation, ErrorCode errorCode) {
    ApiResponses responses = operation.getResponses();

    // ExampleHolder 객체 생성 및 ApiResponses에 추가
    ExampleHolder exampleHolder = ExampleHolder.builder()
        .holder(getSwaggerExample(errorCode))
        .name(errorCode.name())
        .code(errorCode.getHttpStatus().value())
        .build();
    addExamplesToResponses(responses, exampleHolder);
}

// ErrorResponseDto 형태의 예시 객체 생성
private Example getSwaggerExample(ErrorCode errorCode) {
    ErrorResponseDto errorResponseDto = ErrorResponseDto.from(errorCode);
    Example example = new Example();
    example.setValue(errorResponseDto);

    return example;
}

// exampleHolder를 ApiResponses에 추가
private void addExamplesToResponses(ApiResponses responses,
    Map<Integer, List<ExampleHolder>> statusWithExampleHolders) {
    statusWithExampleHolders.forEach(
        (status, v) -> {
            Content content = new Content();
            MediaType mediaType = new MediaType();
            ApiResponse apiResponse = new ApiResponse();

            v.forEach(
                exampleHolder -> mediaType.addExamples(
                    exampleHolder.getName(),
                    exampleHolder.getHolder()
                )
            );
            content.addMediaType("application/json", mediaType);
            apiResponse.setContent(content);
            responses.addApiResponse(String.valueOf(status), apiResponse);
        }
    );
}

private void addExamplesToResponses(ApiResponses responses, ExampleHolder exampleHolder) {
    Content content = new Content();
    MediaType mediaType = new MediaType();
    ApiResponse apiResponse = new ApiResponse();

    mediaType.addExamples(exampleHolder.getName(), exampleHolder.getHolder());
    content.addMediaType("application/json", mediaType);
    apiResponse.content(content);
    responses.addApiResponse(String.valueOf(exampleHolder.getCode()), apiResponse);
}

 

customize 메서드

메서드 위에 @ApiErrorCodeExamples나 @ApiErrorCodeExample이 달렸는지 확인하고 달린 어노테이션에 따라 알맞는 메서드를 호출한다.

@Bean
public OperationCustomizer customize() {
    return (Operation operation, HandlerMethod handlerMethod) -> {
        ApiErrorCodeExamples apiErrorCodeExamples = handlerMethod.getMethodAnnotation(
            ApiErrorCodeExamples.class);

        // @ApiErrorCodeExamples 어노테이션이 붙어있다면
        if (apiErrorCodeExamples != null) {
            generateErrorCodeResponseExample(operation, apiErrorCodeExamples.value());
        } else {
            ApiErrorCodeExample apiErrorCodeExample = handlerMethod.getMethodAnnotation(
                ApiErrorCodeExample.class);

            // @ApiErrorCodeExamples 어노테이션이 붙어있지 않고
            // @ApiErrorCodeExample 어노테이션이 붙어있다면
            if (apiErrorCodeExample != null) {
                generateErrorCodeResponseExample(operation, apiErrorCodeExample.value());
            }
        }

        return operation;
    };
}

 

generateErrorCodeResponseExample 메서드

Swagger 문서에 출력될 에러 응답 예시를 만든다.

두 번째 인자에 따라 두 번째 인자가 다른 addExamplesToResponses 메서드를 호출한다.

 

단일 에러 응답값의 경우 ExampleHolder를 하나만 만들지만 여러 개의 에러 응답값을 다룰 경우, Map을 사용하여 상태 코드별로 ExampleHolder를 묶는다.

// 여러 개의 에러 응답값 추가
private void generateErrorCodeResponseExample(Operation operation, ErrorCode[] errorCodes) {
    ApiResponses responses = operation.getResponses();

    // ExampleHolder(에러 응답값) 객체를 만들고 에러 코드별로 그룹화
    Map<Integer, List<ExampleHolder>> statusWithExampleHolders = Arrays.stream(errorCodes)
        .map(
            errorCode -> ExampleHolder.builder()
                .holder(getSwaggerExample(errorCode))
                .code(errorCode.getHttpStatus().value())
                .name(errorCode.name())
                .build()
        )
        .collect(Collectors.groupingBy(ExampleHolder::getCode));

    // ExampleHolders를 ApiResponses에 추가
    addExamplesToResponses(responses, statusWithExampleHolders);
}

// 단일 에러 응답값 예시 추가
private void generateErrorCodeResponseExample(Operation operation, ErrorCode errorCode) {
    ApiResponses responses = operation.getResponses();

    // ExampleHolder 객체 생성 및 ApiResponses에 추가
    ExampleHolder exampleHolder = ExampleHolder.builder()
        .holder(getSwaggerExample(errorCode))
        .name(errorCode.name())
        .code(errorCode.getHttpStatus().value())
        .build();

    addExamplesToResponses(responses, exampleHolder);
}

 

getSwaggerExample 메서드

Swagger에 출력될 예시 코드를 ErrorResponseDto 형태로 만드는 코드이다.

ErrorCode를 인자로 받아 code, message를 에러 코드에 따라 다르게 출력할 수 있다.

// ErrorResponseDto 형태의 예시 객체 생성
private Example getSwaggerExample(ErrorCode errorCode) {
    ErrorResponseDto errorResponseDto = ErrorResponseDto.from(errorCode);
    Example example = new Example();
    example.setValue(errorResponseDto);

    return example;
}

 

addExamplesToResponses 메서드

인자로 받은 exampleHolder(또는 exampleHolders)를 ApiResponses에 추가한다.

exampleHolder가 하나일 때는 바로 대입해주어도 된다.

 

하지만 addApiResponse 메서드는 HashMap을 사용하고 있어 동일한 key 값(상태 코드)에 대해 중복을 허용하지 않는다.

그러므로 바로 ApiResponse로 추가해주는 것이 아니라 MediaType을 추가하는 방식으로 구현해야 한다.

 

처음에는 위 사실을 모르고 @ApiErrorCodeExample 하나만 구현했다가 삽질을 많이 했다 ㅠ,ㅠ

private void addExamplesToResponses(ApiResponses responses,
    Map<Integer, List<ExampleHolder>> statusWithExampleHolders) {
    statusWithExampleHolders.forEach(
        (status, v) -> {
            Content content = new Content();
            MediaType mediaType = new MediaType();
            ApiResponse apiResponse = new ApiResponse();

            v.forEach(
                exampleHolder -> mediaType.addExamples(
                    exampleHolder.getName(),
                    exampleHolder.getHolder()
                )
            );
            content.addMediaType("application/json", mediaType);
            apiResponse.setContent(content);
            responses.addApiResponse(String.valueOf(status), apiResponse);
        }
    );
}

// exampleHolder를 ApiResponses에 추가
private void addExamplesToResponses(ApiResponses responses, ExampleHolder exampleHolder) {
    Content content = new Content();
    MediaType mediaType = new MediaType();
    ApiResponse apiResponse = new ApiResponse();

    mediaType.addExamples(exampleHolder.getName(), exampleHolder.getHolder());
    content.addMediaType("application/json", mediaType);
    apiResponse.content(content);
    responses.addApiResponse(String.valueOf(exampleHolder.getCode()), apiResponse);
}

 

5. 결과

코드 구현을 완료한 후 Swagger에서 아래와 같이 에러 응답 예시를 확인할 수 있다.

상태 코드가 같은 경우 Examples에서 원하는 에러 상황을 선택하여 각 에러 코드에 맞는 응답값을 볼 수 있다.

 

6. 마치며

처음에는 Swagger가 커스텀할 수 있는 요소가 별로 없다고 생각하여 선호하지 않았다. 어노테이션을 만들며 Swagger의 기본 구조와 원리에 대해 이해할 수 있었고, 이를4 적절히 활용할 수 있다면 Swagger를 원하는대로 커스텀할 수 있다는 것을 배울 수 있었다.

 

그리고 평소 비효율적이라고 생각하던 부분을 코드를 통해 효율적으로 개선시켰다는 점이 뿌듯했다 :)

 

전체 코드는 아래 레포지토리에서 확인할 수 있다.

 

GitHub - Central-MakeUs/Packy-Server: 패키(Packy), 마음으로 채우는 특별한 선물박스 앱 🎁

패키(Packy), 마음으로 채우는 특별한 선물박스 앱 🎁. Contribute to Central-MakeUs/Packy-Server development by creating an account on GitHub.

github.com

 

 

feat: Swagger 에러 응답값 커스텀 어노테이션 구현 및 제작된 API에 에러 응답값 예시 추가 by leeeeeyeo

🛰️ Issue Number #93 🪐 작업 내용 ErrorCode를 기반으로 Swagger에서 에러 응답값 예시를 만드는 커스텀 어노테이션을 구현하였습니다. (@ApiErrorCodeExample, @ApiErrorCodeExamples) 커스텀 어노테이션을 사용

github.com

Reference

반응형