새소식

framework/spring

[Spring] Spring REST Docs, Swagger 조합. restdocs-api-spec

  • -

Spring REST Docs + Swagger

Swagger, Spring REST Docs 간단한 설명

자바로 API를 개발하게 되면 일반적으로 Spring REST Docs와 Swagger 중 하나를 사용하여 API 문서화를 진행하게 된다.
Spring Rest Docs와 Swagger의 특징은 아래와 같다.

[Swagger]

  • API 테스트가 가능하다.
  • API 문서 생성이 자동으로 이루어진다.
  • 프로덕션 코드에 Swagger 문서화를 위한 어노테이션이 추가된다.

[Spring REST Docs]

  • 테스트 코드 작성을 강제하여 API 문서가 신뢰성이 있다.
  • 테스트 성공 이후 생성된 스니펫으로 직접 문서를 작성해야 한다.
  • API 테스트 불가.

프로덕션 코드에 API 문서 관련 코드가 추가되면 컨트롤러 단이 상당히 지저분해질 것 같아서 Spring REST Docs를 자주 사용했다.
하지만 테스트 코드 변경시마다 직접 문서를 수정하는 과정이 반복되다 보니 불편함을 느껴서 방법을 찾아보게 되었고 restdocs-api-spec 라이브러리를 알게 되었다.

restdocs-api-spec

restdocs-api-spec 라이브러리를 사용하면 Spring REST Docs와 같이 테스트 코드가 통과하면 OpenAPI 스펙을 얻게되고, Swagger-UI를 통해 OpenAPI 스펙을 문서로 띄우고 테스트할 수 있는 환경을 제공할 수 있다.

이 포스팅은 Spring REST Docs와 Swagger의 장점을 동시에 누릴 수 있도록 restdocs-api-spec 라이브러리를 사용하여 문서를 작성하는 방법을 기록한다.

restdocs-api-spec 깃허브 바로가기

적용 방법

  • 작성한 모든 코드는 필자의 깃허브에서 확인할 수 있다.

build.gradle 설정

restdocs-api-spec 라이브러리를 사용하기 위해 build.gradle에 의존성을 추가한다.

// restdocs-api-spec: version 변수 설정
buildscript {
    ext {
        restdocsApiSpecVersion = '0.16.2'
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.7'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    // restdocs-api-spec: plugin 추가
    id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

// restdocs-api-spec: OpenAPI 스펙 생성 설정 명시
openapi3 {
    server = "http://localhost:8081"
    title = "restdocs-swagger Test API Documentation"
    description = "Spring REST Docs with SwaggerUI"
    version = "0.0.1-SNAPSHOT"
    format = "json"
    outputDirectory = "src/main/resources/static"
    outputFileNamePrefix = "swagger"
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    // restdocs-api-spec: restdocs-api-spec-mockmvc 의존성 추가
    testImplementation "com.epages:restdocs-api-spec-mockmvc:${restdocsApiSpecVersion}"
}

tasks.named('test') {
    useJUnitPlatform()
}

openapi3 테스크에서 생성될 OpenAPI 스펙 관련 설정을 추가하는 것을 확인할 수 있다.
관련 설정은 여기에서 확인할 수 있다.

프로젝트 생성시 Spring REST Docs 의존성을 추가하여 생성했다면 asciidoctor 관련 설정이 적용되어 있을 텐데, restdocs-api-spec 라이브러리를 사용하게 되면 해당 설정이 필요없다. asciidoctor 관련 설정을 지운다.

’org.springframework.restdocs:spring-restdocs-mockmvc’ 의존성도 필요없다. restdocs-api-spec-mockmvc 라이브러리가 restdocs의 기능을 지원한다.

API 작성

다음으로 API를 작성한다.
예제를 위해 간단하게 다음과 같이 작성했다.

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
    private final UserService userService;

    @PostMapping
    public ApiResponse<UserAddResponse> userAdd(@Validated @RequestBody UserAddRequest request, BindingResult bindingResult) {
        // 요청 데이터 검증 실패시 ControllerAdvice가 Catch -> Exception Controll 함.
        if (bindingResult.hasErrors()) {
            throw new ValidationException(bindingResult);
        }

        Long userId = userService.saveUser(
                new User(
                        request.getName(),
                        request.getAge(),
                        request.getGender()
                )
        );

        return ApiResponse.createSuccess(new UserAddResponse(userId));
    }
}

// 공통 응답 Spec
@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ApiResponse<T> {
    private LocalDateTime responseTime;
    private String errorMessage;
    private T data;

    public static <T> ApiResponse<T> createSuccess(T data) {
        return ApiResponse.<T>builder()
                .responseTime(LocalDateTime.now())
                .data(data)
                .build();
    }

    public static ApiResponse<?> createError(String errorMessage) {
        return ApiResponse.builder()
                .responseTime(LocalDateTime.now())
                .errorMessage(errorMessage)
                .build();
    }

    // 요청 데이터 검증 실패시 data 필드에 MultiValueMap으로 필드 오류 메시지 넣음
    public static <T> ApiResponse<T> createValidationError(T data) {
        return ApiResponse.<T>builder()
                .responseTime(LocalDateTime.now())
                .errorMessage("요청 데이터 검증 오류 발생")
                .data(data)
                .build();
    }
}

// 요청 성공시 ApiResponse.data에 들어갈 응답 데이터
@Data
@AllArgsConstructor
public class UserAddResponse {
    private Long userId;
}

// 요청 Body 데이터
@Data
@AllArgsConstructor
public class UserAddRequest {
    @NotBlank
    private String name;

    @Range(min = 19, max = 30)
    private int age;

    private Gender gender;
}

이제 테스트 코드를 작성하자.

테스트 코드 작성

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs //
@ExtendWith(RestDocumentationExtension.class) //
public class UserControllerTest {
    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    // 요청 성공시 공통 응답 Spec 부분 추출
    FieldDescriptor[] successResponseFields = {PayloadDocumentation.fieldWithPath("responseTime").type(JsonFieldType.STRING).description("응답 시간"),
            PayloadDocumentation.subsectionWithPath("errorMessage").type(JsonFieldType.STRING).description("오류 메시지").optional(),
            PayloadDocumentation.subsectionWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터").optional()};

    // 요청 데이터 검증 실패시 공통 응답 Spec 부분 추출
    FieldDescriptor[] validFailResponseFields = {PayloadDocumentation.fieldWithPath("responseTime").type(JsonFieldType.STRING).description("응답 시간"),
            PayloadDocumentation.subsectionWithPath("errorMessage").type(JsonFieldType.STRING).description("오류 메시지"),
            PayloadDocumentation.subsectionWithPath("data").type(JsonFieldType.OBJECT).description("오류 필드")};

    @Nested
    @DisplayName("유저 생성")
    class userAdd {

        @Test
        @DisplayName("유저 생성 성공")
        public void success() throws Exception {
            // given
            UserAddRequest userAddRequest = new UserAddRequest("user1", 25, Gender.MEN);

            // when
            ResultActions perform = mockMvc.perform(
                    RestDocumentationRequestBuilders.post("/api/v1/users")
                            .contentType(MediaType.APPLICATION_JSON)
                            .characterEncoding(StandardCharsets.UTF_8)
                            .content(objectMapper.writeValueAsString(userAddRequest))
            );

            // then
            perform.andExpect(MockMvcResultMatchers.status().isOk())
                    .andExpect(MockMvcResultMatchers.jsonPath("$.data.userId").isNotEmpty());

            // docs
            perform.andDo(
                    MockMvcRestDocumentationWrapper.document(
                            "{class-name}/{method-name}",
                            Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
                            Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
                            ResourceDocumentation.resource(
                                    ResourceSnippetParameters.builder()
                                            .description("유저 생성")
                                            .requestFields(
                                                    PayloadDocumentation.fieldWithPath("name").description("이름"),
                                                    PayloadDocumentation.fieldWithPath("age").description("나이"),
                                                    PayloadDocumentation.fieldWithPath("gender").description("성별")
                                            )
                                            .responseFields(
                                                    new FieldDescriptors(successResponseFields).and(
                                                            PayloadDocumentation.fieldWithPath("data.userId").description("생성된 유저 식별값")
                                                    )
                                            )
                                            .build()
                            )
                    )
            );
        }

        @Test
        @DisplayName("요청 데이터 검증 실패")
        public void validFail() throws Exception {
            // given
            UserAddRequest userAddRequest = new UserAddRequest(null, 10, null);

            // when
            ResultActions perform = mockMvc.perform(
                    RestDocumentationRequestBuilders.post("/api/v1/users")
                            .contentType(MediaType.APPLICATION_JSON)
                            .characterEncoding(StandardCharsets.UTF_8)
                            .content(objectMapper.writeValueAsString(userAddRequest))
            );

            // then
            perform.andExpect(MockMvcResultMatchers.status().isBadRequest());

            // docs
            perform.andDo(
                    MockMvcRestDocumentationWrapper.document(
                            "{class-name}/{method-name}",
                            Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
                            Preprocessors.preprocessResponse(Preprocessors.prettyPrint()),
                            ResourceDocumentation.resource(
                                    ResourceSnippetParameters.builder()
                                            .responseFields(
                                                    new FieldDescriptors(validFailResponseFields).and(
                                                            PayloadDocumentation.subsectionWithPath("data.name").description("name 필드 검증 오류 메시지 리스트").optional(),
                                                            PayloadDocumentation.subsectionWithPath("data.age").description("age 필드 검증 오류 메시지 리스트").optional()
                                                    )
                                            )
                                            .build()
                            )
                    )
            );
        }
    }
}

기본적으로 Spring REST Docs 사용법과 같다.
주의할 점은 docs 작성 시에 MockMvcRestDocumentationWrapper.document()를 사용해야 한다는 점이다.

또한 @AutoConfigureRestDocs 어노테이션이 필요하다.
기존에 Spring REST Docs를 사용할 때에는 해당 어노테이션이 없어도 MockMvc에 커스터마이징한 설정을 넣어서 사용했다.
내가 방법을 못찾은 것일 수도 있지만.. 이번 시도에서는 커스터마이징이 어려웠다.

한 가지 주의할 점이 또 있다.
이 역시 Spring REST Docs를 사용할 때에는 PayloadDocumentation.beneathPath()를 통해 응답의 특정 필드부터만 문서 작성을 명시해도 테스트가 통과되었다.
이 때에는 @AutoConfigureRestDocs 어노테이션을 사용하지 않았고 직접 설정을 추가했었는데, restdocs-api-spec 라이브러리를 사용할 때에는 @AutoConfigureRestDocs 어노테이션 때문인지 몰라도, beneathPath()를 사용하면 테스트가 실패했다(스니펫은 정상적으로 생성된다).
이 때문에 공통 API 스펙같은 경우 따로 추출하여 FieldDescriptors(...)로 묶어주고, 추가로 FieldDescriptor가 필요한 경우, FieldDescriptors.and(...) API를 통해 붙여주었다.

Open API Spec 생성

테스트 코드 작성이 완료되고 테스트를 수행하여 통과되면 /build/generated-snippets/ 아래에 .adoc 파일과 resource.json 파일이 생성되었을 것이다.

이제 gradle의 openapi3 태스크를 수행하면 .adoc 스니펫 파일들과 resource.json 파일을 이용해서 Open API 스펙을 생성한다.
./gradlew openapi3 명령을 입력하거나 인텔리제이 기준 Gradle 탭을 열어 Tasks/documentation/openapi3 태스크를 수행하자.

태스크 수행이 성공적으로 완료되면, build.gradle에서 openapi3 태스크 설정시 지정한 outputDirectory 경로에 {outputFileNamePrefix}.{format}으로 Open API 스펙이 생성된다.
따로 지정하지 않았다면 디폴트 값인 build/api-spec/openapi3.json으로 생성된다.

Swagger-UI로 Open API Spec 띄우기

프로젝트에서 Swagger-UI 의존성을 추가하여 Swagger 페이지를 생성할 수도 있지만, 별도 Swagger-UI 서버를 띄워 여기에 Open API 스펙들을 모아서 관리하도록 하였다.
요즘 MSA 프로젝트를 많이 구성하는데 여러 서버의 문서를 한 곳에서 모아서 보고 싶었기 때문이다.

Swagger-UI 서버를 띄우기 위해 Docker Compose를 이용했다.

# swaggerui-docker-compose.yml
version: "3"
services:
  swagger:
    container_name: local-swagger
    image: swaggerapi/swagger-ui
    ports:
      - "8080:8080"
    env_file:
      - .env

컨테이너에 필요한 환경변수를 별도 파일로 작성했다.

// .env
URLS=[{url:'http://localhost:8081/swagger.json',name:'Test'},]

URLS에 {url:'url1',name:'name1'} 형식으로 [] 내부에 값을 집어넣으면 Swagger-UI에서 name 값을 선택하면 해당 name에 매핑된 url을 불러온다. 따라서 name에 특정 서버의 문서 이름을 기입하고 url에 해당 서버의 Open API Spec을 불러올 경로를 입력하면, Swagger-UI를 통해 여러 서버의 Open API Spec들을 편리하게 확인할 수 있다.

이제 우리가 작성한 애플리케이션 서버를 띄우고, Swagger-UI 서버 컨테이너를 실행해서 확인해보자.

Swagger-UI에서 서버의 Open API Spec을 불러와 띄운 모습

구동에 성공했다.

Swagger-UI에 접속해서 애플리케이션 서버의 Open API Spec을 불러오는데 Fetch error가 발생한다면?
Failed to fetch http://localhost:8081/swagger.json**Fetch error**
Possible cross-origin (CORS) issue? The URL origin (http://localhost:8081) does not match the page (http://localhost:8080). Check the server returns the correct ‘Access-Control-Allow-*’ headers.

Open API Spec을 불러올 서버에 CORS 설정이 필요하다. CORS 설정을 추가해주자.

고찰

restdocs-api-spec 라이브러리가 아직 완벽하게 Spring REST Docs를 지원하지는 못하는 것 같다.
MultiValueMap 같은 경우 <String, String> 타입인데, Mapvalue 타입을 String으로 명시하고 테스트를 수행하니 테스트가 실패한다.
타입 명시를 지우고 테스트를 성공시킨 후, resource.json을 확인해보니 Array 타입으로 명시되어있다. MultiValueMap<String, String>의 특정 키의 value[0]String 타입인데 말이다..

또한 Swagger-UI에서 이를 확인하면 아래와 같이 출력된다.

MultiValueMap의 문제..?

API를 테스트 할 수 있다는 점은 협업하는 입장에서 매우 좋은 것 같다.
하지만 API 필드 타입과 설명을 명시하는 부분에서는 Spring REST Docs만을 사용해서 직접 문서로 만드는 것이 더 확실하게 명시할 수 있는 것 같다(내 개인적인 생각이다).

어쨋든 Spring으로 API 문서를 작성할 때 Swagger, Spring REST Docs 2개의 선택지에서 restdocs-api-spec라는 선택지가 하나 더 생기게 되었다.
협업하는 팀원들간에 상의를 해보고 더 적절한 것을 선택하면 좋을 것 같다.

[Spring] Spring REST Docs, Swagger 조합. restdocs-api-spec

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.