본문 바로가기
Java

Java & Spring | Swagger 커스텀 ApiResponse 어노테이션 사용기

by Hoya324 2024. 7. 17.

💎 작성된 글의 프로젝트

https://github.com/MARU-EGG/MARU_EGG_BE

 

GitHub - MARU-EGG/MARU_EGG_BE

Contribute to MARU-EGG/MARU_EGG_BE development by creating an account on GitHub.

github.com

 

💎 작성된 글의 Pull Request

https://github.com/MARU-EGG/MARU_EGG_BE/pull/42

 

[feat] Swagger 커스텀 ApiResponse 어노테이션 적용 by Hoya324 · Pull Request #42 · MARU-EGG/MARU_EGG_BE

✅ 작업 내용 작업 관련 정리 블로그 Swagger 커스텀 ApiResponse 어노테이션을 적용했습니다. 프로젝트를 진행하면서 API 문서를 swagger로 작업하고 있었습니다. API 마다 각각의 성공, 실패 Response가

github.com

 

 

들어가기 전에..

프로젝트를 진행하면서 API 문서를 swagger로 작업하고 있었습니다.

API 마다 각각의 성공, 실패 Response가 존재하게 됩니다. 이때, Swagger에서는 성공의 경우 DTO에 @Schema 를 통해 쉽게 example을 설정할 수 있지만, 예외 Response의 경우 Controller에 content를 모두 입력하고 구현된 스키마까지 작성해야하기에 이를 개선해보고 합니다.

목표는 @CustomApiResponse@CustomApiResponses 를 커스텀 어노테이션으로 정의함으로써, 코드의 가독성을 높이고 유지 보수를 용이하는 것입니다.

이 글에서는 @CustomApiResponses@CustomApiResponse 어노테이션의 작동 방식과 기능, 사용법에 대해 다룹니다.

최종적으로 적용된 코드

적용 전

@Tag(name = "Question API", description = "질문 관련 API 입니다.")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/questions")
public class QuestionController {

    private final QuestionService questionService;

    @Operation(summary = "질문 요청", description = "질문하는 API")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "질문 성공"),
        @ApiResponse(responseCode = "400", description = "Invalid input format: JSON parse error: Cannot deserialize value of type `mju.iphak.maru_egg.question.domain.QuestionType` from String \\\"SUSI 또는 PYEONIP 또는 JEONGSI 또는 JAEOEGUGMIN\\\": not one of the values accepted for Enum class: [SUSI, PYEONIP, JEONGSI, JAEOEGUGMIN]", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))
        }),
        @ApiResponse(responseCode = "404", description = "type: SUSI, category: PAST_QUESTIONS, content: 수시 입학 요강에 대해 알려주세요.인 질문을 찾을 수 없습니다.", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))
        }),
        @ApiResponse(responseCode = "500", description = "내부 서버 오류가 발생했습니다.", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))
        })
    })
    @PostMapping()
    public QuestionResponse question(@Valid @RequestBody QuestionRequest request) {
        return questionService.question(request.type(), request.category(), request.content());
    }

    @Operation(summary = "질문 목록 요청", description = "질문 목록을 보내주는 API")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "질문 성공"),
        @ApiResponse(responseCode = "400", description = "Invalid input format: JSON parse error: Cannot deserialize value of type `mju.iphak.maru_egg.question.domain.QuestionType` from String \\\"SUSI 또는 PYEONIP 또는 JEONGSI 또는 JAEOEGUGMIN\\\": not one of the values accepted for Enum class: [SUSI, PYEONIP, JEONGSI, JAEOEGUGMIN]", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))
        }),
        @ApiResponse(responseCode = "500", description = "내부 서버 오류가 발생했습니다.", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))
        })
    })
    @GetMapping()
    public List<QuestionResponse> getQuestions(@Valid @ModelAttribute FindQuestionsRequest request) {
        return questionService.getQuestions(request.type(), request.category());
    }
}

적용 후

@Tag(name = "Question API", description = "질문 관련 API 입니다.")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/questions")
public class QuestionController {

    private final QuestionService questionService;

    @Operation(summary = "질문 요청", description = "질문하는 API", responses = {
        @ApiResponse(responseCode = "200", description = "질문 성공")
    })
    @CustomApiResponses({
        @CustomApiResponse(error = "HttpMessageNotReadableException", status = 400, message = "Invalid input format: JSON parse error: Cannot deserialize value of type `mju.iphak.maru_egg.question.domain.QuestionType` from String \\\"SUSI 또는 PYEONIP 또는 JEONGSI 또는 JAEOEGUGMIN\\\": not one of the values accepted for Enum class: [SUSI, PYEONIP, JEONGSI, JAEOEGUGMIN]", description = "validation에 맞지 않은 요청을 할 경우"),
        @CustomApiResponse(error = "EntityNotFoundException", status = 404, message = "type: SUSI, category: PAST_QUESTIONS, content: 수시 입학 요강에 대해 알려주세요.인 질문을 찾을 수 없습니다.", description = "질문 또는 답변을 찾지 못한 경우"),
        @CustomApiResponse(error = "InternalServerError", status = 500, message = "내부 서버 오류가 발생했습니다.", description = "내부 서버 오류")
    })
    @PostMapping()
    public QuestionResponse question(@Valid @RequestBody QuestionRequest request) {
        return questionService.question(request.type(), request.category(), request.content());
    }

    @Operation(summary = "질문 목록 요청", description = "질문 목록을 보내주는 API", responses = {
        @ApiResponse(responseCode = "200", description = "질문 성공")
    })
    @CustomApiResponses({
        @CustomApiResponse(error = "HttpMessageNotReadableException", status = 400, message = "Invalid input format: JSON parse error: Cannot deserialize value of type `mju.iphak.maru_egg.question.domain.QuestionType` from String \\\"SUSI 또는 PYEONIP 또는 JEONGSI 또는 JAEOEGUGMIN\\\": not one of the values accepted for Enum class: [SUSI, PYEONIP, JEONGSI, JAEOEGUGMIN]", description = "validation에 맞지 않은 요청을 할 경우"),
        @CustomApiResponse(error = "InternalServerError", status = 500, message = "내부 서버 오류가 발생했습니다.", description = "내부 서버 오류")
    })
    @GetMapping()
    public List<QuestionResponse> getQuestions(@Valid @ModelAttribute FindQuestionsRequest request) {
        return questionService.getQuestions(request.type(), request.category());
    }
}

어노테이션 정의

@CustomApiResponse

@CustomApiResponse은 개별 API 응답을 정의하는 데 사용됩니다. 각 응답에는 오류 유형, 상태 코드, 메시지, 설명이 포함됩니다.

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(CustomApiResponses.class)
public @interface CustomApiResponse {
    String error() default "RuntimeException";

    int status() default 500;

    String message() default "서버에 오류가 발생했습니다. 담당자에게 연락주세요.";

    String description() default "내부 서버 오류";
}

@Target({ElementType.METHOD}) - 어노테이션 적용 타입

  • ElementType.METHOD: 어노테이션이 메소드에만 적용될 수 있음을 의미합니다.
  • controller의 메소드 단위로 적용할 예정입니다.
  • 추가로, TYPE (클래스, 인터페이스, enum), FIELD (필드), CONSTRUCTOR (생성자) 등이 있습니다.

@Retention(RetentionPolicy.RUNTIME) - 어노테이션의 보존 정책

  • RetentionPolicy.RUNTIME: 어노테이션이 런타임 동안 유지되어야 하며, 리플렉션을 통해 접근할 수 있음을 의미합니다.
    • 리플렉션(Reflection): 런타임 시에 프로그램의 구성을 검사하고 수정할 수 있는 기능을 의미
    • 참고 자료: https://hudi.blog/java-reflection/
  • 추가로, SOURCE (컴파일 타임에만 유지, 컴파일 후 버려짐), CLASS (컴파일러가 클래스 파일에 기록하지만 JVM에 의해 로드되지 않음) 등이 있습니다.

@Documented - Javadoc 같은 도구에 의해 문서화되어야 함을 의미

@Repeatable(CustomApiResponses.class) - 이 어노테이션이 동일한 요소에 여러 번 사용할 수 있음

  • CustomApiResponses.class: 반복되는 어노테이션을 감싸는 컨테이너 어노테이션을 지정

각 속성의 의미

// API 응답에서 발생할 수 있는 오류의 유형을 지정
String error() default "RuntimeException";

// HTTP 상태 코드를 지정
int status() default 500;

// 오류 메시지를 지정
String message() default "서버에 오류가 발생했습니다. 담당자에게 연락주세요.";

// 오류에 대한 설명을 지정
String description() default "내부 서버 오류";

@CustomApiResponses

@CustomApiResponses은 여러 @CustomApiResponse를 그룹화하는 역할을 수행합니다.

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CustomApiResponses {
    CustomApiResponse[] value();
}

적용 방법

전체 적용된 코드

@Operation(summary = "질문 요청", description = "질문하는 API", responses = {
    @ApiResponse(responseCode = "200", description = "질문 성공")
})
@CustomApiResponses({
    @CustomApiResponse(error = "HttpMessageNotReadableException", status = 400, message = "Invalid input format: JSON parse error: Cannot deserialize value of type `mju.iphak.maru_egg.question.domain.QuestionType` from String \\\"SUSI 또는 PYEONIP 또는 JEONGSI 또는 JAEOEGUGMIN\\\": not one of the values accepted for Enum class: [SUSI, PYEONIP, JEONGSI, JAEOEGUGMIN]", description = "validation에 맞지 않은 요청을 할 경우"),
    @CustomApiResponse(error = "EntityNotFoundException", status = 404, message = "type: SUSI, category: PAST_QUESTIONS, content: 수시 입학 요강에 대해 알려주세요.인 질문을 찾을 수 없습니다.", description = "질문 또는 답변을 찾지 못한 경우"),
    @CustomApiResponse(error = "InternalServerError", status = 500, message = "내부 서버 오류가 발생했습니다.", description = "내부 서버 오류")
})
@PostMapping()
public QuestionResponse question(@Valid @RequestBody QuestionRequest request) {
    return questionService.question(request.type(), request.category(), request.content());
}

성공인 경우

  @Operation(summary = "질문 요청", description = "질문하는 API", responses = {
      @ApiResponse(responseCode = "200", description = "질문 성공")
  })

Response DTO 클래스 예시

@Builder
@Schema(description = "질문 응답 DTO", example = """
    {
      "id": 600697396846981500,
      "content": "수시 일정 알려주세요."
      "dateInformation": "생성일자: 2024-07-15T23:36:59.834804, 마지막 DB 갱신일자: 2024-07-15T23:36:59.834804",
      "answer": {
        "id": 600697396935061600,
        "content": "2024년 수시 일정은 다음과 같습니다:\\n\\n- 전체 전형 2024.12.19.(목)~12.26.(목) 18:00: 최초합격자 발표 및 시충원 관련 내용 공지 예정\\n- 문서등록 및 등록금 납부 기간: 2025. 2. 10.(월) 10:00 ~ 2. 12.(수) 15:00\\n- 등록금 납부 기간: 2024.12.16.(월) 10:00 ~ 12. 18.(수) 15:00\\n\\n추가로, 복수지원 관련하여 수시모집의 모든 전형에 중복 지원이 가능하며, 최대 6개 이내의 전형에만 지원할 수 있습니다. 반드시 지정 기간 내에 문서등록과 최종 등록(등록금 납부)을 해야 합니다. 또한, 합격자는 합격한 대학에 등록하지 않을 경우 합격 포기로 간주되니 유의하시기 바랍니다.",
        "renewalYear": 2024,
        "dateInformation": "생성일자: 2024-07-15T23:36:59.847690, 마지막 DB 갱신일자: 2024-07-15T23:36:59.847690"
      }
    }
    """)
public record QuestionResponse(
    @Schema(description = "질문 id")
    Long id,

    @Schema(description = "질문")
    String content,

    @Schema(description = "답변 DB 생성 및 업데이트 날짜")
    String dateInformation,

    @Schema(description = "답변 DTO")
    AnswerResponse answer
) {

    public static QuestionResponse of(Question question, AnswerResponse answer) {
        return QuestionResponse.builder()
            .id(question.getId())
            .content(question.getContent())
            .dateInformation(question.getDateInformation())
            .answer(answer)
            .build();
    }
}

예외에 Response에 적용

@CustomApiResponses({
    @CustomApiResponse(error = "HttpMessageNotReadableException", status = 400, message = "Invalid input format: JSON parse error: Cannot deserialize value of type `mju.iphak.maru_egg.question.domain.QuestionType` from String \\\"SUSI 또는 PYEONIP 또는 JEONGSI 또는 JAEOEGUGMIN\\\": not one of the values accepted for Enum class: [SUSI, PYEONIP, JEONGSI, JAEOEGUGMIN]", description = "validation에 맞지 않은 요청을 할 경우"),
    @CustomApiResponse(error = "EntityNotFoundException", status = 404, message = "type: SUSI, category: PAST_QUESTIONS, content: 수시 입학 요강에 대해 알려주세요.인 질문을 찾을 수 없습니다.", description = "질문 또는 답변을 찾지 못한 경우"),
    @CustomApiResponse(error = "InternalServerError", status = 500, message = "내부 서버 오류가 발생했습니다.", description = "내부 서버 오류")
})

SwaggerConfig 설정

OperationCustomizer 빈 등록

@Bean
public OperationCustomizer operationCustomizer() {
    return (operation, handlerMethod) -> {
        ApiResponses apiResponses = operation.getResponses();
        if (apiResponses == null) {
            apiResponses = new ApiResponses();
            operation.setResponses(apiResponses);
        }
        handleCustomApiResponses(apiResponses, handlerMethod);
        return operation;
    };
}
  • OperationCustomizer는 Swagger(OpenAPI) 문서에서 각 API 엔드포인트의 동작(Operation)을 사용자 정의할 수 있게 합니다.
  • 여기서는 API 메서드의 응답(ApiResponses)을 가져와서, 만약 응답이 정의되어 있지 않다면 새로운 ApiResponses 객체를 생성합니다.
  • handleCustomApiResponses 메서드를 호출하여 사용자 정의 응답을 설정합니다.

handleCustomApiResponses 메서드

private void handleCustomApiResponses(ApiResponses apiResponses, HandlerMethod handlerMethod) {
    Method method = handlerMethod.getMethod();
    CustomApiResponses customApiResponses = method.getAnnotation(CustomApiResponses.class);
    if (customApiResponses != null) {
        for (CustomApiResponse customApiResponse : customApiResponses.value()) {
            ApiResponse apiResponse = new ApiResponse();
            apiResponse.setDescription(customApiResponse.description());
            addContent(apiResponse, customApiResponse.error(), customApiResponse.status(), customApiResponse.message());
            apiResponses.addApiResponse(String.valueOf(customApiResponse.status()), apiResponse);
        }
    } else {
        CustomApiResponse customApiResponse = method.getAnnotation(CustomApiResponse.class);
        if (customApiResponse != null) {
            ApiResponse apiResponse = new ApiResponse();
            apiResponse.setDescription(customApiResponse.description());
            addContent(apiResponse, customApiResponse.error(), customApiResponse.status(), customApiResponse.message());
            apiResponses.addApiResponse(String.valueOf(customApiResponse.status()), apiResponse);
        }
    }
}
  • handlerMethod로부터 메서드를 가져와서, 해당 메서드에 CustomApiResponses 어노테이션이 있는지 확인합니다.
  • CustomApiResponses 어노테이션이 있으면, 그 안에 포함된 여러 CustomApiResponse 어노테이션을 반복하면서 각각의 사용자 정의 응답을 생성합니다.
  • CustomApiResponse 어노테이션이 개별적으로 설정된 경우도 처리하여 사용자 정의 응답을 생성합니다.

addContent 메서드

private void addContent(ApiResponse apiResponse, String error, int status, String message) {
    Content content = new Content();
    MediaType mediaType = new MediaType();
    Schema<ErrorResponse> schema = new Schema<>();
    schema.$ref("#/components/schemas/ErrorResponse");
    mediaType.schema(schema)
        .example(new ErrorResponse(error, status, message));
    content.addMediaType("application/json", mediaType);
    apiResponse.setContent(content);
}
  • ApiResponse 객체에 응답의 내용을 추가합니다.
  • ErrorResponse 스키마를 참조하여 JSON 형태로 응답의 예제를 설정합니다.

위 과정을 통해 얻은 Swagger 문서 형태

결론

annotation에 대해 공부해보는 좋은 경험이라는 마음으로 시작한 작업이었는데 생각보다 가독성 측면과 결과에서는 유의미한 결과를 냈다는 생각이 들었습니다.

// 적용하기 전 (여기에 추가로 예외 메세지 적용해야함)
@ApiResponse(responseCode = "404", description = "type: SUSI, category: PAST_QUESTIONS, content: 수시 입학 요강에 대해 알려주세요.인 질문을 찾을 수 없습니다.", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))
        })

// 적용한 후
@CustomApiResponse(error = "EntityNotFoundException", status = 404, message = "type: SUSI, category: PAST_QUESTIONS, content: 수시 입학 요강에 대해 알려주세요.인 질문을 찾을 수 없습니다.", description = "질문 또는 답변을 찾지 못한 경우"),

NestJS로 개발하던 때에도 decorator를 Custom해봤던 경험을 살려서 작업했는데 아직 사이드이펙트는 떠오르는 것이 없지만, 애초에 API 문서를 보기 쉽도록 하고, 작성하는데에 귀찮음을 굉장히 줄일 수 있었다는 점에서 꽤나 성공적이라고 생각했습니다.

NestJS Custom Decorator

Java의 annotation을 좀더 이해했으니 필요한 상황에 좀더 적용해보고자 합니다. (WebClient를 annotation으로 작성했던 적이 있었는데 AOP까지 적용하고나니 정말 큰 사이드 이펙트가 예상되어 접었던..적이 있으니 다시..?)

Reference