[Java][Spring] Swagger(Springdoc-openapi)를 적용해보자!

SUI·2024년 4월 5일

Java

목록 보기
2/4
post-thumbnail

📑 API 문서화

API 문서화는 아주 중요하지. 얼마나 중요하냐면... API 문서화만큼 중요하다...

API 문서란 API의 기능, 사용 방법, 호출 가능한 엔드포인트, 전송해야 하는 데이터 형식, 반환되는 데이터의 형식 등을 설명하는 공식적인 문서이다.

백엔드 애플리케이션의 API를 클라이언트 쪽에서 사용하려면 API 정보가 필요하다. 클라이언트가 데이터를 받아와서 사용하기 위해서는 무슨 경로로 무슨 요청을 보내야하는지, 요청을 보내면 무슨 데이터가 무슨 형태로 오는지 명확하게 알아야하기 때문에 API 문서는 꼭! 만들어야한다.

📝 API 문서화 Tool 선택

  1. 노션에 직접 쓰기
  2. Postman
  3. Swagger

첫번째는 번거롭고 시간이 많이 소요되기 때문에 탈락!
그렇다면 Postman과 Swagger 중에 뭐가 좋을까?

기능PostmanSwagger
사용 편의성사용자 친화적이며 직관적인 UI 제공Swagger UI 활용
가독성API 문서의 가독성이 부족할 수 있음Swagger UI로 생성된 문서는 가독성이 좋음
자동화 기능API 문서화 자동화 기능 제한적코드와 연동하여 자동 문서화 가능
커스터마이징 가능성문서 디자인 및 스타일 커스터마이징 제한적다양한 커스터마이징 기능 제공
확장성주로 API 테스트에 사용됨다양한 언어와 프레임워크 호환 및 API 문서화에 사용됨

Postman은 팀원 모두 API 테스트 용으로 사용하고 있었기에 고려해봤지만, 역시 API 문서화에는 너무 불편한 점이 많았다. 게다가 팀 스페이스도 3명까지만 무료이기 때문에, 간편하고 무료로 사용할 수 있는 Swagger를 사용하기로 했다.

📝 Springfox VS Springdoc

구분SpringfoxSpringdoc-openapi
Spring Boot 지원최신 버전 호환성 이슈 가능성 있음최신 버전과 잘 호환됨
유지 관리 상태업데이트 느림활발히 유지 관리됨
성능대규모 프로젝트에서 이슈 가능대규모 프로젝트에서도 좋은 성능
사용의 용이성설정 다소 복잡설정 간단함
OpenAPI 지원Swagger 2.0 지원OpenAPI 3.0 지원
커스터마이징가능하지만 복잡쉬운 커스터마이징
비동기 프로그래밍 지원지원 부족잘 지원함
커뮤니티와 지원최근 활동 줄어듦활발한 지원

Spring Boot 애플리케이션에서 Swagger를 쓴다면, Springfox와 Springdoc라는 두가지 선택지가 있다.
Springfox는 2021년 이후로 업데이트가 없는데다가, spring 2버전까지만 지원하고 그 밖의 단점이 많기 때문에 Springdoc-openapi를 사용하기로 했다.



🚨 문제 상황 발생

🫢 이게 무슨 일이야! 왜 data가 안나와요?

의존성 설정, config 파일까지 설정해줬다.
이제 메서드에 Operation 적용이랑 데이터 example과 descrption, Schema 설정만 넣으면 되겠다고 생각했는데 갑자기 이런 결과물을 마주하게 됐다.

응답값의 형식을 통일시키기 위해 공통적으로 ApiResult라는 클래스를 사용하고, 그 안의 data 필드에 response 값을 넣어주도록 되어있는데, 가장 중요한 data의 값이 안나오게 되어버렸다.

public record ApiResult(
        @Schema(description = "응답 시간", example = "2024-03-27T14:20:52.026425")
        LocalDateTime timeStamp,
        @Schema(description = "응답 상태", example = "OK")
        HttpStatus status,
        @Schema(description = "응답 메시지", example = "성공 응답 메시지 예시")
        String message,
        @Schema(description = "응답 데이터")
        Object data
) {

    public static ApiResult success(String message, Object data) {
        return new ApiResult<>(LocalDateTime.now(), HttpStatus.OK, message, data);
    }

    public static ApiResult error(ErrorCode errorCode) {
        String errorMessage = errorCode.getCode() + " : " + errorCode.getMessage();
        return new ApiResult<>(LocalDateTime.now(), errorCode.getStatus(), errorMessage, null);
    }

}

열심히 자료를 찾아본 결과, Springdoc-openapi는 Object를 문서화하지 못한다는 사실을 알게되었다.🤦

✍️ 문제를 해결해봅시다

1️⃣ @Content의 @Schema를 설정하는 방법

    @HasAccess
    @PutMapping("/api/v1/feedbacks/admin/{feedbackNo}")
    @Operation(summary = "피드백 답변 작성/수정 (관리자)", description = "관리자가 피드백 답변을 작성 또는 수정합니다.")
    @ApiResponse(responseCode = "200", description = "피드백 답변 작성/수정 완료",
            content = @Content(mediaType = "application/json",
            schema = @Schema(implementation = UpdateFeedbackReplyResponse.class)))
    public ResponseEntity<ApiResult> updateFeedbackReply(
            @RequestHeader(value = "Authorization") String token,
            @PathVariable("feedbackNo") Long feedbackNo,
            @RequestBody UpdateFeedbackReplyRequest request
    ) throws BadRequestException {

        Long memberNo = jwtTokenProvider.getMemberNoFromToken(token);

        UpdateFeedbackReplyResponse response = feedbackService.updateFeedbackReply(request, feedbackNo, memberNo);

        return ResponseEntity.ok(ApiResult.success("피드백 답변 작성/수정 완료 : 관리자", response));

    }

ApiResult는 모든 도메인에서 공통적으로 사용하고 있기 때문에, 먼저 ApiResult의 수정없이 해결할 수 있는 방법을 찾아보기로했다.
@ApiResponse@Content(@Scheme)설정을 통해 반환값이 response.class라는 걸 명시해준다. 이렇게 하면 해당 API를 response값이 implementation으로 설정해준 response.class가 된다.

하지만 이렇게 설정하면 위와 같이 ApiResult.class에 있는 timeStamp, status, message를 확인할 수 없다. 응답값을 제대로 확인할 수 없는 API 문서는 API 문서라고 할 수 없기 때문에, 다른 방법을 찾기로 했다.

2️⃣ 제네릭 타입 <T>를 사용하는 방법

새로운 방법을 찾아보면서 좀 더 깊게 알아보게 됐다.

📌 그러니까, 애초에 Object를 제대로 표시하지 못하는 이유가 뭘까?

Springdoc-openapi가 문서를 생성하는 시점은 애플리케이션이 실행되는 런타임 시점이다. 스프링 부트 애플리케이션에서는 컨텍스트가 로드되고, bean이 생성되며, 의존성 주입이 완료된 후에, 스프링의 이벤트 리스너나 초기화 단계에서 Springdoc-openapi가 활성화되어 API 문서를 생성한다.
Object는 특정 타입에 대한 정보를 내포하고 있지 않기 때문에 어떤 타입의 객체가 들어가는지 파악이 불가능하다.

한마디로 Springdoc-openapi의 실행 구조상 Object 타입에 들어가는 객체 타입을 API별로 파악해서 문서를 만드는 것은 추가적인 작업없이는 불가능하다는 얘기다.

public class GetFeedbackResponseDTO extends ApiResult {
    public GetFeedbackResponseDTO(GetFeedbackResponse data, String message) {
        super.setData(data);
        super.setMessage(message);
    }
}

이런 식으로 ApiResult를 상속받는다면 Object를 인식시킬 수 있다. 하지만 이 방법을 사용한다면 ApiResult를 record가 아닌 class로 수정하고 위와 같은 형식의 DTO를 모든 Response에 맞춰서 새로 만들어주거나, ApiResult와 기존의 Response들을 class로 수정하고 Response가 ApiResult를 상속받도록 해야한다.🤦 (record는 상속을 지원하지 않는다.)

그럴 때 알게 된 방법이 제네릭 타입을 사용하는 방법이다. 제네릭을 사용하면 Object처럼 모든 객체를 data 필드에 넣을 수 있다. 하지만 여기서 의문점이 생겼다.

📌 제네릭 타입은 왜 되는걸까?

Java와 같은 정적 타입 언어에서 제네릭 타입은 컴파일 시점에 타입 정보가 결정되며, 런타임 시에는 타입 소거(Type Erasure)정책으로 인해 구체적인 타입 정보가 대부분 소거된다. 그런데 어떻게 Springdoc-openapi는 제네릭 타입을 문서화할 수 있는 것일까?

이유는 바로 Java의 리플렉션 덕분이다.
제네릭 타입에 대한 구체적인 정보는 타입 소거에 의해 소거되지만, 컴파일된 바이트코드 내에는 클래스, 메서드 시그니처, 어노테이션 등에 대한 메타데이터 정보가 여전히 유지된다.
Java의 리플렉션 API를 사용하면, 런타임에 이 정보에 접근하여 조회할 수 있다. Springdoc-openapi는 이 리플렉션 API를 활용하여 API 엔드포인트 메서드의 시그니처에서 제네릭 타입 정보를 추출한다.
예를 들어, 메서드의 반환 타입이 ResponseEntity<ApiResult<GetMemberResponse>>와 같이 제네릭 타입을 사용하는 경우, Springdoc-openapi는 이 정보를 통해 ApiResult가 GetMemberResponse 타입의 데이터를 포함하고 있음을 파악할 수 있는 것이다.

먼저 ApiResult를 제네릭 타입을 사용하도록 수정했다.

public record ApiResult<T>(
        @Schema(description = "응답 시간", example = "2024-03-27T14:20:52.026425")
        LocalDateTime timeStamp,
        @Schema(description = "응답 상태", example = "OK")
        HttpStatus status,
        @Schema(description = "응답 메시지", example = "성공 응답 메시지 예시")
        String message,
        @Schema(description = "응답 데이터")
        T data
) {
    public static <T> ApiResult<T> success(String message, T data) {
        return new ApiResult<>(LocalDateTime.now(), HttpStatus.OK, message, data);
    }
    
    public static <T> ApiResult<T> error(ErrorCode errorCode) {
        String errorMessage = errorCode.getCode() + " : " + errorCode.getMessage();
        return new ApiResult<>(LocalDateTime.now(), errorCode.getStatus(), errorMessage, null);
    }
}

그 다음 컨트롤러 메서드의 응답 형태를 수정했다.

    @HasAccess
    @PutMapping("/api/v1/feedbacks/admin/{feedbackNo}")
    @Operation(summary = "피드백 답변 작성/수정 (관리자)", description = "관리자가 피드백 답변을 작성 또는 수정합니다.")
    public ApiResult<UpdateFeedbackReplyResponse> updateFeedbackReply(
            @RequestHeader(value = "Authorization") String token,
            @PathVariable("feedbackNo") Long feedbackNo,
            @RequestBody UpdateFeedbackReplyRequest request
    ) throws BadRequestException {

        Long memberNo = jwtTokenProvider.getMemberNoFromToken(token);

        UpdateFeedbackReplyResponse response = feedbackService.updateFeedbackReply(request, feedbackNo, memberNo);

        return ApiResult.success("피드백 답변 작성/수정 완료 : 관리자", response);

    }

원하는 형식으로 응답값이 나오는 것을 볼 수 있다!
하지만 이대로 적용할 수는 없다... 왜냐하면 이렇게 반환값을 설정할 경우 ReponseEntity가 아니기 때문에 Http 응답을 원하는대로 설정할 수 없기 때문이다. Http 응답을 직접 설정해줘야할 때가 있기 때문에, ResponseEntity를 사용해야한다.

3️⃣ ResponseEntity<ApiResult<T>>

이전처럼 ResponseEntity를 사용하되, 그 안에 ApiResult가 아닌 ApiResult<T>를 넣어주었다.

    @HasAccess
    @PutMapping("/api/v1/feedbacks/admin/{feedbackNo}")
    @Operation(summary = "피드백 답변 작성/수정 (관리자)", description = "관리자가 피드백 답변을 작성 또는 수정합니다.")
    public ResponseEntity<ApiResult<UpdateFeedbackReplyResponse>> updateFeedbackReply(
            @RequestHeader(value = "Authorization") String token,
            @PathVariable("feedbackNo") Long feedbackNo,
            @RequestBody UpdateFeedbackReplyRequest request
    ) throws BadRequestException {

        Long memberNo = jwtTokenProvider.getMemberNoFromToken(token);

        UpdateFeedbackReplyResponse response = feedbackService.updateFeedbackReply(request, feedbackNo, memberNo);

        return ResponseEntity.ok(ApiResult.success("피드백 답변 작성/수정 완료 : 관리자", response));

    }

UpdateFeedbackReplyResponse에 Schema 설정까지해주면 위와 같이 나오는 것을 확인할 수 있다. 🪄

1개의 댓글

comment-user-thumbnail
2024년 4월 11일

API 문서화는 아주 중요하군요.... API 문서화만큼... 중요하군요...
많이 배워갑니다..

답글 달기