[우테코 6기 레벨3] Swagger UI, REST Docs - 코드가 깨끗한 API 문서

새양·2024년 8월 26일
0

우테코 6기 일기장

목록 보기
15/16
post-thumbnail

목표

  • Spring Boot 프로젝트에 Swagger UI 적용하기
  • REST Docs 를 사용해서 OAS(Open API Specification) 만들기
  • 소스 코드 더럽히지 않고 테스트 코드로 OAS 생성 후 Swagger UI 와 연동

API 문서

API 문서는 백엔드 API 서버를 개발하여 이를 사용하는 클라이언트와 소통하는데 사용하는 문서 입니다.

요구사항

우테코에서 진행한 팀 프로젝트에서 API 문서의 사용자는 안드로이드 분야 개발자 팀원인 것이죠!

안드로이드 팀원 모두 Swagger 를 이전에 미션을 통해 사용해 본 경험이 있었고, 다른 프로젝트를 했을 때 사용했던 API 문서보다 훨씬 편리했다고 합니다.

API 문서 또한 하나의 서비스이고 어떠한 서비스든 사용자가 편해야 사용하기 좋습니다.

API 문서 사용자인 안드로이드 팀원의 의견은 아래와 같았습니다.

  • Swagger 를 사용하니 개발 서버에 직접 요청을 테스트해볼 수 있어서 편리했어요.
  • 이전 프로젝트에서 API 문서를 주었는데 그대로 반영해서 요청이나 응답 값이 달라서 (백엔드가 API 문서를 업데이트 안함) 결국 Postman 과 같은 다른 프로그램으로 직접 요청 후 응답 값을 보고 추측해서 개발 했어요.
  • 더욱히 요청 값이 달라지면 백엔드 개발자에게 물어본 후에야 개발이 기다림의 시간이 추가로 들었어요.

요약해보면 직접 요청할 수 있는 것과 API가 변경되면 즉시 반영 이 핵심 요구사항 이었습니다.

선택

백엔드 팀원들의 경험으로 API 문서를 어떤 것으로 만들지에 선택지는 아래와 같았습니다.

  1. Swagger
  2. REST Docs
  3. Postman
  4. Swagger UI + REST Docs

Case 1. Swagger ❌

단순 Swagger 는 라이브러리 하나만 설치한 후 Controller 에 특정 어노테이션을 추가하는 것 만드로 편하게 API 문서를 만들 수 있는 강력한 도구입니다.

하지만 어노테이션 추가는 메인 로직이 담진 소스 코드를 더럽힌다는 단점이 있는데요.

이 또한 아래 코드 처럼 인터페이스를 두어 해결이 가능하긴 합니다. (옆 팀이 알려준 방법이에요!)

@Tag(name = "Member API")
@SecurityRequirement(name = "Authorization")
public interface MemberControllerSwagger {

    @Operation(
            summary = "회원 추가",
            responses = @ApiResponse(responseCode = "201", description = "회원 추가 성공")
    )
    @ErrorCode500
    ResponseEntity<Void> save(@Parameter(hidden = true) String authorization);
}

하지만 모든 컨트롤러 마다 파일을 추가로 생성해 줘야 하며 같이 따라다닌 다는 점에서 포기했었습니다.

Case 2. REST Docs ❌

REST Docs 하나만 사용하면 많은 adoc 확장자의 스니펫 파일을 만들어 주어 이를 기반으로 API 문서를 html 로 생성해줍니다.

테스트 코드로 작성하기 때문에 소스 코드의 더럽힘 없이 API 문서를 만들 수 있어서 좋아 보였지만 결국 실제 요청을 해보려면 안드로이드 팀원은 웹 페이지에서가 아닌 다른 요청 프로그램을 사용해야 했기에 Swagger 쪽으로 여전히 마음이 많이 기울어져 있었습니다.

Case 3. Postman ❌

팀 내 여러 백엔드 크루 중 한 크루가 이전 미션 때 Postman 을 사용했다고 알려주었으며 API 개발이 끝나고 Postman 으로 요청하면 나온 응답 값을 저장해두어 이를 기반으로 API 문서를 만들어 준다고 하였습니다.

Swagger 처럼 직접 요청을 할 수 있다는 점이 매력적이었습니다.

하지만 여전히 API가 변경되었을 때 문서 동기화 면에서 뾰족한 해결 방안이 없었습니다.

또한 큰 걸림돌은 아니지만 해당 팀원이 Export 하여 알려준 문서에 들어가서 테스트해보려 하니 Postman 에 로그인 해야하는 점도 있었습니다.

Case 4. REST Docs + Swagger UI ✔️

Swagger 는 자체적으로 OAS 도 제작해주며 이를 Swagger UI 가 바라보게 하여 동작하는 원리입니다.

OAS (Open API Specification)

토스페이먼츠에서 설명은 굉장히 잘 해주어서 꼭 읽어보시는 것을 추천드립니다!
https://docs.tosspayments.com/resources/glossary/oas

SwaggerOAS 를 생성할 때 컨트롤러에 어노테이션을 붙여야 했었는데 이 부분은 REST Docs 의 테스트 코드 작성으로 바꿔버린다면 원했던 아래 2가지를 모두 충족시킬 수 있었습니다.

  • 직접 요청할 수 있는 Swagger UI
  • API가 변경되면 즉시 반영 되는 REST Docs 테스트 코드
    • 테스트 코드는 Rest Assured 라는 API 테스트 도구를 사용하는데 Spring Context 를 띄운 뒤 요청 및 응답을 받는 라이브러리 입니다.
    • 이후 REST Docs 는 미리 정의된 요청과 응답에 일치한지 확인한 후 맞을 경우에만 테스트를 통과시키게 되므로 API가 변경되었을 때 테스트 실패로 API 문서를 고치게 될 것입니다.

따라서 REST DocsOASyaml 파일을 먼저 생성한 뒤 이를 정적 파일 위치인 resources/static 으로 옮겨 컴파일 하면 이후 웹 서버가 뜰 때 Swagger UI 에서 읽고 사용할 수 있게 될 것입니다.

Swagger UI 설치

버전 체크

설치해야할 라이브러리는 org.springdoc:springdoc-openapi-starter-webmvc-ui 입니다.

충돌

어디서 springfox 의 Swagger UI 설치하라고도 알려줬던 것 같아요.
하지만 설치 시 다른 라이브러리랑 충돌 날 수도 있다고 들었던 것 같습니다.
업데이트도 꾸준히 되는 org.springdoc 의 라이브러리를 사용하시는 것을 추천드립니다!

설치 전 많이 사용하는 버전은 어느 것인지 먼저 Maven Repository 에서 확인합니다.
https://mvnrepository.com/artifact/org.springdoc/springdoc-openapi-starter-webmvc-ui

2.2.0 버전을 가장 많이 사용하지만 최신 버전을 아예 기피하지 않으므로 해당 버전을 사용하기로 하였습니다.

의존성 추가

먼저 build.gradledependencies 에 아래 코드를 추가합니다.

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'

코끼리 새로고침을 해줍니다.

설정값 추가

application.yaml 에 아래 내용을 추가합니다. (application.properties 를 사용할 경우 절절히 . 으로 변경해서 사용하시면 됩니다.)

springdoc:
  enable-default-api-docs: false
  api-docs:
    path: /openapi3.yaml
  • springdoc.enable-default-api-docs 기본 OAS 사용 여부
  • springdoc.api-docs.path 직접 만든 OAS Endpoint 경로

테스트

Spring Boot 를 실행한 후 아래 링크로 접속 시 사진과 같이 보인다면 성공입니다.

http://localhost:8080/swagger-ui/index.html

이제 저 openapi3.yaml 이라는 파일을 만들 차례입니다!

REST Docs 설치

ePages 라는 곳에서 restdocs-api-spec 라이브러리를 만들었고 해당 문서로는 아래 Github 링크가 있습니다.
https://github.com/ePages-de/restdocs-api-spec

버전 선택

해당 깃허브 리드미의 Getting started 를 보니 Spring Boot 에 따른 버전 선택이 명시되어 있네요!

처음 설치 때 해당 부분을 보지 않고 다른 레퍼런스만 따라하다가 시간 낭비를 했었습니다.

Maven Repository 의 최신 버전 Usages 도 살펴보고 문서도 꼼꼼히 살펴 시간 저처럼 시간 낭비를 하지 않으셨으면 좋겠어요!

결론적으로 안정적이여 보이는 0.18.2 버전을 선택하였습니다.

의존성 추가

testImplementation 'org.springframework.restdocs:spring-restdocs-restassured'
testImplementation 'com.epages:restdocs-api-spec-restassured:0.18.2'
testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2'

restdocs-api-spec-mockmvc 라이브러리도 추가하였는데 처음엔 restdocs-api-spec-restassured 에 포함되어 할 필요 없을 줄 알았는데 OAS 를 생성하려면 기본적으로 필요한 베이스 라이브러리였습니다.

Rest Assured

또한, 저희 팀은 API 테스트 도구로 Mock 을 사용하지 않고 직접 API 요청을 보내는 Rest Assured 를 사용하였기에 관련 라이브러리 2개가 추가되었는데 Mock 을 사용하실 경우 제일 아랫 줄만 추가하시면 될 것이며, 이 글이 아닌 ePages 깃허브 공식 문서를 따라하셔서 OAS 를 생성하시면 됩니다!

그래들 설정 추가

openapi3 라는 gradle task 의 추가적인 설정 값이 필요합니다.

build.gradledependencies 블럭 아래에 아래 블럭을 추가한 후 자신 상황에 맞게 수정해주세요.

  • servers API 요청을 보낼 서버 리스트 (첫번째가 메인)
openapi3 {
	servers = [{ url = "https://dev.pengcook.net" }, { url = "http://localhost:8080" }]
	title = 'Pengcook API'
	description = 'Pengcook API description'
	version = '0.1.0'
	format = 'yaml'
}

OAS 생성

  • IntelliJ 사용자라면 우측에 Gradle 탭을 열어 documentation > openapi3 를 실행합니다.
  • 그렇지 않다면 터미널에 ./graldew openapi 를 입력합니다.

이후 프로젝트 폴더에서 build > api-spec > openapi3.yaml 파일을 열어봅니다.

아래와 같이 보인다면 성공입니다!

문서화 베이스 테스트 코드

아래 추상 클래스를 테스트 최상단에 위치시킵니다.

이는 RestAssured 라이브러리에 RequestSpecification 을 등록하여 REST Docs 와 연동하기 위함입니다.

⭐ import ⭐

앞으로 작성되는 코드들에 모두 일부러 구분지어 import 구문을 추가해 두었습니다.
동일한 이름의 메서드가 여러 라이브러리에 존재하므로 테스트 코드만 복사 한 뒤 라이브러리는 직접 import 하며 어떤 것이 정확한 것인지 선택하시면서 외워두시길 바랍니다!

import io.restassured.RestAssured;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.specification.RequestSpecification;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;

import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration;
@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class RestDocsSetting {

    protected static final String DEFAULT_RESTDOCS_PATH = "{class_name}/{method_name}/";

    protected RequestSpecification spec;
    @LocalServerPort
    int port;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @BeforeEach
    void setUpRestDocs(RestDocumentationContextProvider restDocumentation) {
        spec = new RequestSpecBuilder()
                .addFilter(documentationConfiguration(restDocumentation)
                        .operationPreprocessors()
                        .withRequestDefaults(prettyPrint(), modifyHeaders()
                                .remove("Host")
                                .remove("Content-Length")
                        )
                        .withResponseDefaults(prettyPrint(), modifyHeaders()
                                .remove("Transfer-Encoding")
                                .remove("Keep-Alive")
                                .remove("Date")
                                .remove("Connection")
                                .remove("Content-Length")
                        )
                )
                .setPort(port)
                .build();
    }
}

테스트 코드

이제 제작한 API의 특정 Controller 테스트 파일에서 테스트 메서드를 아래와 같이 작성해주세요.

또한 새로운 컨트롤러 테스트 작성 시 문서화를 하려면 extends RestDocsSetting 하는 것과 RestAssured.given(spec) 을 꼭 기억해주시면 됩니다!

⭐ import ⭐

queryParameters responseFields 메서드를 정확하게 import 하시길 바랍니다!

import io.restassured.RestAssured;
import io.restassured.http.ContentType;

import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document;
import static org.hamcrest.Matchers.is;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
class RecipeControllerTest extends RestDocsSetting {

    @Test
    @WithLoginUser(email = "loki@pengcook.net")
    @DisplayName("레시피 개요 목록을 조회한다.")
    void readRecipes() {
        RestAssured.given(spec).log().all()
                .filter(document(DEFAULT_RESTDOCS_PATH,
                        "특정 페이지의 레시피 목록을 조회합니다.",
                        "레시피 조회 API",
                        queryParameters(
                                parameterWithName("pageNumber").description("페이지 번호"),
                                parameterWithName("pageSize").description("페이지 크기"),
                                parameterWithName("category").description("조회 카테고리").optional(),
                                parameterWithName("keyword").description("제목 또는 설명 검색 키워드").optional(),
                                parameterWithName("userId").description("작성자 아이디").optional()
                        ),
                        responseFields(
                                fieldWithPath("[]").description("레시피 목록"),
                                fieldWithPath("[].recipeId").description("레시피 아이디"),
                                fieldWithPath("[].title").description("레시피 제목"),
                                fieldWithPath("[].author").description("작성자 정보"),
                                fieldWithPath("[].author.authorId").description("작성자 아이디"),
                                fieldWithPath("[].author.authorName").description("작성자 이름"),
                                fieldWithPath("[].author.authorImage").description("작성자 이미지"),
                                fieldWithPath("[].cookingTime").description("조리 시간"),
                                fieldWithPath("[].thumbnail").description("썸네일 이미지"),
                                fieldWithPath("[].difficulty").description("난이도"),
                                fieldWithPath("[].likeCount").description("좋아요 수"),
                                fieldWithPath("[].commentCount").description("댓글 수"),
                                fieldWithPath("[].description").description("레시피 설명"),
                                fieldWithPath("[].createdAt").description("레시피 생성일시"),
                                fieldWithPath("[].category").description("카테고리 목록"),
                                fieldWithPath("[].category[].categoryId").description("카테고리 아이디"),
                                fieldWithPath("[].category[].categoryName").description("카테고리 이름"),
                                fieldWithPath("[].ingredient").description("재료 목록"),
                                fieldWithPath("[].ingredient[].ingredientId").description("재료 아이디"),
                                fieldWithPath("[].ingredient[].ingredientName").description("재료 이름"),
                                fieldWithPath("[].ingredient[].requirement").description("재료 필수 여부"),
                                fieldWithPath("[].mine").description("조회자 작성여부")
                        )))
                .queryParam("pageNumber", 0)
                .queryParam("pageSize", 3)
                .when()
                .get("/recipes")
                .then().log().all()
                .body("size()", is(3));
    }
}

이렇게 테스트 코드 작성이 끝났으며 openapi3 그래들 테스크를 실행하면 알아서 모든 테스트 코드도 실행되므로 문서가 알맞게 생성되는지 확인하시길 바랍니다.

배포용 빌드

기존

기존에는 ./gradlew clean build 이런식으로 빌드파일 삭제 후 빌드 하는 것을 하셨을 거에요!

build task

> Task :compileJava
> Task :processResources
> Task :classes
> Task :resolveMainClassName
> Task :bootJar
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources
> Task :testClasses
> Task :test
> Task :check
> Task :build

그러면 bootJar 까지 알아서 실행되어 buid/lib/*.jar 파일이 생성됩니다.

하지만 위 과정 중 processResources 작업이 이루어 지기 전에 저희는 openapi3.yamlresources/static 폴더안에 넣어두어야 합니다! 그래야 스프링이 실행될 때 Swagger UI 가 Endpoint /openapi3.yaml 로 접근해 내용을 읽고 사용할 수 있게 해주기 때문입니다.

개념 알고가기

processResources 태스크는 이름에서도 알 수 있듯이 resources/static 안의 파일을 조합해주는 역할을 합니다.
하지만 다른 블로그들에서는 단순히 bootJar 태스크가 실행되기 전 dependsOn 으로 openapi3 를 하도록 하는데 이는 잘못된 방법입니다!
왜 안되지 하지 마시고 Gradle 태스크 출력 문구를 보고 정확히 이해하시는 것을 추천드립니다!

OAS 복사 태스크 추가

build.gradle 에 아래 새로운 태스크를 추가합니다.
openapi 블럭 아래에 두시면 됩니다.

dependOn 으로 OAS 를 먼저 생성한 후 build/api-spec/open3.yaml 파일을 resources/static 으로 옮기는 작업입니다.

tasks.register("copyOasToSwagger", Copy) {
	dependsOn("openapi3")

	from layout.buildDirectory.file("api-spec/openapi3.yaml").get()
	into "src/main/resources/static"
}

OAS 복사 실행

위 태스크를 추가하고 코끼리를 새로고침 하면 우측 Gradle 패널에 아래와 같이 추가되어 있을거에요.

이것을 더블 클릭하여 실행해주세요!
IntelliJ 를 사용중이지 않다면 ./gradlew copyOasToSwagger 명령어를 실행해주세요!

copyOasToSwagger task

> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources UP-TO-DATE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :openapi3
> Task :copyOasToSwagger

위의 copyOasToSwagger task 대부분 UP-TO-DATE 인 것을 볼 수 있습니다.

이는 이전에 build 태스크를 실행했기 때문인데요, openapi3 가 실행하는 태스크들과 build 가 실행하는 태스크들이 곂치는 것이 많습니다!

bootJar 이전에 copyOAS 실행

마지막으로 아래 태스크 블럭을 추가합니다.

bootJar {
    dependsOn("copyOasToSwagger")
}

이로써 최종적으로 bootJar 를 만들기 위해서는 아래 명령을 사용하면 됩니다.

./gradlew clean bootJar

명령어로 만드시면 순서대로 실행하며 기존 빌드 데이터를 지우고 컴파일 및 테스트를 진행한 후 OAS를 resources 로 옮긴 뒤 리소스를 bootJar 태스크는 리소스를 다시 처리한 뒤 최종 .jar 파일을 생성하게 됩니다.

빌드는 언제하나요?

bootJar 태스크 이전에 openapi3 가 태스크가 동작하며 빌드와 테스트 모두 진행하게 됩니다.
따라서 굳이 build 라는 태스크를 추가적으로 진행할 필요가 없습니다.

빌드, 테스트, 배포 파일 생성을 분리하고 싶어요.

CD 진행 중 이 세 가지를 보통 분리하는데 간단하게 그냥 ./gradlew build -x test 처럼 하고싶지 않은 태스크는 -x 옵션으로 제외하거나 그냥 둬도 상관 없습니다.
태스크 진행 중 UP-TO-DATE 를 보게 되는데 그래들도 알아서 캐싱되어 이미 진행한 부분은 불필요한 실행을 또 하지 않기 때문입니다!

접속 테스트

copyOasToSwagger 태스크 실행 이후 resources/static/openapi3.yaml 이 생겼을 경우 SpringApplication 을 실행합니다.

이후 http://localhost:8080/swagger-ui/index.html 접속 시 아래와 같이 보인다면 성공입니다!

profile
안녕, 세상!

0개의 댓글