💎 작성된 글의 프로젝트
https://github.com/MARU-EGG/MARU_EGG_BE
💎 작성된 글의 Pull Request
https://github.com/MARU-EGG/MARU_EGG_BE/pull/42
들어가기 전에..
프로젝트를 진행하면서 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 문서를 보기 쉽도록 하고, 작성하는데에 귀찮음을 굉장히 줄일 수 있었다는 점에서 꽤나 성공적이라고 생각했습니다.
Java의 annotation을 좀더 이해했으니 필요한 상황에 좀더 적용해보고자 합니다. (WebClient를 annotation으로 작성했던 적이 있었는데 AOP까지 적용하고나니 정말 큰 사이드 이펙트가 예상되어 접었던..적이 있으니 다시..?)