반응형

Spring Rest Docs VS Swagger

서론


팀원들과 Rest Docs와 Swagger 중 무엇을 고민할지 상의했다.
결과적으로는 나의 생각과는 다르게 Rest Docs를 선택했다.
Swagger도 튜토리얼 정도만 해봤기 때문에 둘의 장단점을 명확하게 공감하지는 못한다.
그래서 이번에는 팀원들의 의견을 따라 Rest Docs를 사용하며, 장단점을 느껴보자.

Spring Rest Docs VS Swagger


Spring Rest Docs Swagger
장점 프로덕션 코드에 영향이 없다. 문서상에 API를 테스트할 수 있는 기능이있다.
테스트 코드가 성공해야 문서 작성이 가능하다. 테스트 코드가 필요없으므로 적용이 쉽다.
단점 테스트 코드를 작성해야 하므로 적용이 불편하다. 프로덕션 코드에 테스트와 관련된 애노테이션이 추가된다.
즉, 라이브러리가 바뀌는 경우 등 불편하다.
문서를 위한 테스트 코드를 관리해야 한다. 프로덕션 코드에 추가되기 때문에 아주 지저분한 코드가 될 수 있다.

내가 Swagger를 원했던 이유

  • Rest Docs는 문서를 만들기 위한 테스트 코드를 작성해야 한다. 이 부분이 불편하다고 느꼈다.
    차라리 컨트롤러에 의존되고 조금 코드가 지저분해지는 것이 좋지 않을까? 라는 생각이었다.
  • Rest Docs는 테스트를 강제화 해서 문서를 만든다는 것이다. 여기서 테스트는 컨트롤러 테스트를 의미한다.
    하지만 우리는 인수 테스트를 컨트롤러 테스트로 하기 때문에 Rest Docs의 테스트가 의미가 없다.
  • Swagger는 테스트 코드가 없기 때문에 컨트롤러에 바로 작성할 수 있다.
    그래서 테스트를 관리할 필요가 없어서 좋다.
  • Swagger가 코드가 지저분해 진다면, @ApiModel등을 사용하여 추상화하면 된다.
    더 많이 추상화를 할 수 있는 기능은 아직 보지 않았지만, @ApiModel만 해도 일단 지저분한 코드는 분리할 수 있다.
    즉, 코드가 지저분해진다는 것은 공감하지 못한다.
  • 테스트가 실패하면, 제대로 작성이 되지 않는다는 것을 알 수 있기 때문에 Swagger 보다 Rest Docs가 안정적이다.

하지만 팀원 분은 프로덕션 코드에 의존되는 것 자체가 가독성이 좋지 않다고 했고,
@ApiModel을 사용해서 추상화를 한다고 해도 결국 추상화를 위한 클래스가 필요하기 때문에 별도로 클래스를 관리하는 것이
개발할 때 찾아가기 불편하다고 했다. 하지만 생각해보니 이 부분은 Rest Docs도 똑같은 것 같다.
결국 Rest Docs도 깔끔하게 하려면 추상화가 필요하다. 그래서 아직까지는 Swagger가 좋지 않을까 생각하고 있다.

MockMvc VS Rest Assured


MockMvc RestAssured
기능 JsonData 검증이 RestAssured보다는 불편하다. JsonData를 쉽게 검증할 수 있다.
가독성 가독성이 떨어진다. 잘 모르는 입장에서 나도
그렇게 느껴졌다. 아무래도 BDD 스타일이기 때문에 비개발자가 봐도 이해하기 쉽다. 실제로 나도 웹 테스트는 모르는데 쉬웠다.
속도 @SpringBootTest를 사용하지 않고 @WebMvcTest로 컨트롤러의 빈만을 사용할 수 있으므로 속다가 빠르다. @SpringBootTest와 같이 사용 해야 하므로 느리다.
작은 프로젝트인데도 체감 중이다.
의존성 스프링 프레임워크에 내장된 것이므로
별도의 추가가 필요 없다. 의존성을 별도로 추가해야 하기 때문에 프로그램이 무거워질 수 있다.

AsciiDoc VS Markdown


우리 프로젝트는 문서화 도구로 Markdown이 아닌 AsciiDoc을 사용했다.
ATDD 교육 과정에서 사용했었는데 이유는 include의 여부이다.
Markdown은 문법이 굉장이 편하다. 그리고 많이 사용하는 문법이다.
반면 AsciiDoc은 문법은 조금 불편하지만 include가 가능하기 때문에 html을 작성하는 것처럼
재활용이 가능하다. 그래서 AsciiDoc을 채택하게 되었다.

Junit에서 Rest Docs 셋팅


테스트에서 공통으로 사용될 셋팅을 만든다.

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.web.server.LocalServerPort;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.request.ParameterDescriptor;
import org.springframework.test.context.ActiveProfiles;

import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields;
import static org.springframework.restdocs.request.RequestDocumentation.requestParameters;
import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document;
import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.documentationConfiguration;

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(RestDocumentationExtension.class)
public class Documentation {
    @LocalServerPort
    int port;

    protected RequestSpecification spec;

    @BeforeEach
    public void setUp(RestDocumentationContextProvider restDocumentation) {
        RestAssured.port = port;

        this.spec = new RequestSpecBuilder()
                .addFilter(documentationConfiguration(restDocumentation))
                .build();
    }

    RequestSpecification given(String identifier, ParameterDescriptor[] parameterDescriptors, FieldDescriptor[] fieldDescriptors) {
        return RestAssured.given(this.spec).log().all()
                .filter(document(identifier, preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()),
                        requestParameters(parameterDescriptors),
                        relaxedResponseFields(fieldDescriptors)
                        )
                );
    }
}
  • protected RequestSpecification spec

    • MVC 테스트로 Rest Assured를 사용하기 때문에 사용한다.
      Rest Assuredgiven() 메서드의 리턴 타입이 RequestSpecification이다.
  • RestDocumentationContextProvider, RestDocumentationExtension.class

  • document

    • identifier : 문서화 식별자(디렉토리명)
    • preprocessRequest : 요청 전 무슨 처리를 할지
    • preprocessResponse : 응답 전 무슨 처리를 할지
    • prettyPrint : 포맷팅
    • requestParameters : 요청 파라미터 설명 데이터
    • relaxedResponseFields : 응답 필드 설명 데이터
  • @ExtendWith(RestDocumentationExtension.class)

    • rest docs를 확장하여 조금 더 편리하게 사용하기 위해 테스트에서 공통으로 사용할 기능을 지원한다.
      mockito로 예를 들어보자면 아래와 같다.

    • 공통으로 사용하도록 @Mock을 사용하여 공통화할 수 있다.*

      @ExtendWith(MockitoExtension.class)
      public class MockitoExtensionTest {
        @Mock
        private LineRepository lineRepository;
        @Mock
        private StationService stationService;
      
      
   @Test
   void findAllLines() {
         // given
     when(lineRepository.findAll()).thenReturn(Lists.newArrayList(new Line()));
     LineService lineService = new LineService(lineRepository, stationService);

       ...
    }
```

**공통화를 못하고 각 테스트마다 사용해야 한다.**

```java
@DisplayName("단위 테스트 - mockito를 활용한 가짜 협력 객체 사용")
public class MockitoTest {
   @Test
   void findAllLines() {
       // given
       LineRepository lineRepository = mock(LineRepository.class);
       StationService stationService = mock(StationService.class);

       when(lineRepository.findAll()).thenReturn(Lists.newArrayList(new Line()));
       LineService lineService = new LineService(lineRepository, stationService);

       ...
   }
```

**build.gradle 셋팅**

```java
plugins {
    id 'org.springframework.boot' version '2.6.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id "org.asciidoctor.jvm.convert" version "3.3.2"
    id 'java'
}

configurations {
    asciidoctorExt
}

group = 'project'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // handlebars
    implementation 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.3.0'

    // rest docs
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.5.RELEASE'
    testImplementation 'org.springframework.restdocs:spring-restdocs-restassured:2.0.5.RELEASE'
    testImplementation 'io.rest-assured:rest-assured:3.3.0'
}

// snippets 파일이 저장될 경로 변수 설정
ext {
    snippetsDir = file('build/generated-snippets')
}

// 테스트 실행 시 snippets를 저장할 경로로 snippetsDir 사용
test {
    useJUnitPlatform()
}

task testDocument(type: Test) {
    useJUnitPlatform()
    filter {
        includeTestsMatching "*.documentation.*"
    }
}

// API 문서 생성
asciidoctor {
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'
    dependsOn testDocument
}

bootJar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into 'static/docs'
    }
}

task copyDocument(type: Copy) {
    dependsOn asciidoctor

    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}
```

- `task` : `gradle`을 통해 실행되는 단위
- `ext` : 전역 변수 셋팅
- `asciidoctor` : `testDocument`를 의존하여 테스트를 실행하고, `snippetsDir`에서 `snippets`을 참조하여
문서를 생성한다.
- `bootJar` : `jar` 빌드 시 `asciidoctor`를 참조하여 문서를 생성하고, `snippetsDir`에 있는 `html5`파일을
`static/docs`로 복사한다. 복사하는 이유는 api 요청으로 문서 접근을 위함
- `copyDocument` : `from` 디렉토리에 있는 API 문서 파일을 `into`로 복사한다.
복사하는 이유는 api 요청으로 문서 접근을 위함. 테스트 시에는 이걸 쓰고, 배포 시에는 `bootjar`를 씀.
- `testDocument` : `restdocs` 전용 테스트 실행 task로 `**.documnetation.*`에 해당하는 테스트만 실행한다.*
- `includeTestMatching` : 실행할 테스트 패턴 정의
    - 우리는 `Documentation`이라는 RestDocs 전용 부모 클래스를 상속받아 사용하기 때문에
    `**.documnetation.*`로 지정*
    - [https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/TestFilter.html](https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/TestFilter.html)

### Rest Docs 실행 방법

---

- 테스트(`testDocument`)를 수행시켜 `snippet`을 생성한다.
    - `build.gradle`에서 설정한 `snippetsDir`에 생성 됨.
- gradle로 `asciidoctor task`를 수행시켜 문서 파일을 생성
    - 그 전에 `src/docs/asciidoc/index.adoc` 있어야 함.
    - `asciidoctor`가 `testDocument`를 의존하기 때문에 `asciidoctor`를 바로 실행해도 됨.
- `build > asciidoc > html5 > index.html`에 문서가 생성된 것을 오픈하여 잘 만들어졌는지 확인한다.
- `task copyDocument를 실행하여 배포할 디렉토리도 복사하기`
반응형
복사했습니다!