안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 API 개발의 마지막 관문이라고 할 수 있는 SpringRestDocs를 발급하는 과정을 정리하려고 합니다.

 

API 문서를 개발하는 과정은 까다로운 점들이 있습니다. 먼저 백엔드에서 개발한 api 명세 규칙을 프론트 개발자 혹은 api를 활용하는 클라이언트가 명확하게 이해하여 활용할 수 있도록 작성되어야 합니다. api의 uri, 전달 방식, 헤더 필수 정보, 필요한 파라미터 와 응답 객체 등 많은 내용을 정확하게 전달해야 합니다.

 

스프링에서 api docs를 개발하도록 돕는 기술은 여러 가지가 있는데, 제가 소개드릴 수 있는 기능은 Swagger와 SpringRestDocs입니다. 두 가지 방법은 각각 장단점이 존재하기 때문에 간단하게 정리한 후, 제가 선택한 SpringRestDocs 위주로 작성 방법과 공통 기능 분리하기, 테스트 진행 후 docs 발급 등을 작성하도록 하겠습니다.

 

 

 

1. Swagger vs SpringRestDocs

 

Swagger는 api 문서를 자동으로 생성하여 Swagger UI를 사용하여 생성된 문서를 확인할 수 있습니다. 다른 방법에 비해 어노테이션으로 api를 개발할 수 있기 때문에, 간편한 설정으로 빠른 개발이 가능합니다.

 

<Swagger> 

 

@Slf4j
@Api(value = "auth api")
@RestController
@RequestMapping("/member-service")
@RequiredArgsConstructor
public class AuthController {

    private final FacadeService facadeService;

    /**
     * 회원 가입 요청
     */
    @ApiOperation(value = "Register", response = ResponseEntity.class)
    @PostMapping("/register")
    public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
        facadeService.register(request);
        return ResponseEntity.ok(SendSuccess.of());
    }

 

 

<SpringRestDocs>

 

SpringRestDocs는 테스트 기반으로 api 명세를 발급하는 기술입니다. JUnit 기반 테스트를 진행하여, 클라이언트의 mock 요청에 대해 비즈니스 로직을 수행하여 문제가 없다면 테스트를 성공하고 테스트 결과물로 api 명세를 작성하는 방식입니다.

 

@Test
@DisplayName("RestDocs: fetchPlayerRank / Get")
public void fetchPlayerRank() throws Exception {
    //given
    setUpForRanking();
    Pageable page = PageRequest.of(0, 10);

    //when
    ResultActions result = mockMvc.perform(
            get("/result-service/result/rank")
                    .accept(MediaType.APPLICATION_JSON)
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(new Gson().toJson(page))
                    .header("Authorization", "Bearer AccessToken")
                    .header("RefreshToken", "refreshToken")
                    .header("userId", "user-id"));

 

 

 

2. SpringRestDocs를 선택한 이유

 

저는 백엔드 개발에 TDD 설계가 정말 중요하다고 생각합니다. 저는 기술적으로 굉장히 많이 부족한 상태이고 많이 배워가야 하는 상황에서 제 코드를 맹신한다는 것은 과오임이 분명합니다. 따라서, 제가 작성한 코드가 어느 부분에서든 에러가 발생할 수 있는데, 테스트 없이 api를 발급하는 과정은 리스크가 있다고 생각하였습니다. 또한 , 소비자에게 테스트로 검증되지 않은 api 명세를 제공하는 것은 신뢰도 측면에서 악영향을 미칠 수 있다고 판단하였습니다.

 

SpringRestDocs는 Slice 혹은 Page 처럼 페이징 쿼리가 적용되는 코드에 대해서는 실제 페이징 응답을 전부 제공해야 하는 점에서 개발자의 피로도를 늘릴 수 있습니다. 하지만 역시 스프링은 위대한 기술임을 증명하듯, 이러한 과정을 하나의 유틸성 클래스를 작성함으로써 해결할 수 있습니다.

 

따라서, SpringRestDocs를 개발하는 과정이 보다 복잡해보이지만 상속 기능을 통해 최대한 중복되는 코드를 줄이고,

다음에도 활용 가능한 코드로 구현함으로써 신뢰도와 개발 편리성을 모두 지키는 방식으로 코드를 작성해 보는 시도를 진행하겠습니다. 

 

 

 

3. SpringRestDocs 의존성 주입 받기

 

먼저 제 기본 환경은 다음과 같습니다.

 

OS: Ubuntu22.04 (linux)
Java: openJdk17
SpringBoot: 3.0.2

 

혹시 제 코드를 활용하시더라도 SpringRestDocs가 버전 문제로 인해 진행이 안되실 수 도 있습니다. 이 경우는 구글링이나 Chat GPT의 도움을 받으시면 버전에 맞는 라이브러리를 받을 수 있습니다.

 

(해당 프로젝트에 QueryDsl을 적용하거나 기타 다른 라이브러리를 활용하게 되면 Build.gradle에서 많은 에러가 발생할 수 있습니다. 저는 최종 jar로 빌드하는 과정에서 많은 에러가 발생했습니다. 따라서 제가 적용했을 때 에러가 없었던 환경으로 공유드리기 위해 필요하다고 생각되는 설정으로 제시하였습니다.!)

 

// <-- docs 추가 asciidoctor 이 부분 --> //
plugins {
   id 'java'
   id 'org.springframework.boot' version '3.0.2'
   id 'io.spring.dependency-management' version '1.1.0'
   id "org.asciidoctor.jvm.convert" version "3.3.2"
}

group = 'liar'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

// <-- docs 추가 asciidoctorExt 이 부분 --> //
configurations {
   compileOnly {
      extendsFrom annotationProcessor
   }
   asciidoctorExt
}

// <-- docs 추가 snippetsDir --> //
ext {
    snippetsDir = file('build/generated-snippets') // restdocs
    set('springCloudVersion', "2022.0.1")
}


dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
   implementation 'org.springframework.boot:spring-boot-starter-web'
   
   // json
   implementation 'com.google.code.gson:gson:2.10.1'

   // mockito
   testImplementation 'org.mockito:mockito-core:4.11.0'
   testImplementation 'org.mockito:mockito-junit-jupiter:4.11.0'
   
   // test
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
   testImplementation 'org.springframework.security:spring-security-test'

   // <-- docs 시작 --> //
   asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
   testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
   // <-- docs 끝 --> //

   // lombok
   compileOnly 'org.projectlombok:lombok'
   annotationProcessor 'org.projectlombok:lombok'

   //test lombok
   testCompileOnly 'org.projectlombok:lombok'
   testAnnotationProcessor 'org.projectlombok:lombok'
}

// <-- docs 시작 --> //

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

sourceSets {
	main {
		java {
			srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
		}
	}
}

clean {
	delete file('src/main/generated')
}


test {
	outputs.dir snippetsDir
	useJUnitPlatform()

	systemProperty 'spring.config.name', 'application-test'
	systemProperty 'spring.cloud.bootstrap.name', 'bootstrap'
}

asciidoctor {
	inputs.dir snippetsDir
	configurations 'asciidoctorExt'
	dependsOn test
}

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

bootJar {
	dependsOn asciidoctor

	copy {
		from asciidoctor.outputDir
		into "src/main/resources/static/docs"
	}
}

jar {
	enabled = false
}

tasks.withType(JavaCompile) {
	options.release = 17
}

// <-- docs 시작 --> //

 

 

 

4. 공통 코드 작성하기

 

자바는 상속을 활용할 수 있으므로 공통 로직은 부모 클래스에서 작성한 후, 하위 자식 클래스에서 해당 클래스를 상속하여 공통 로직을 줄이는 방식을 사용하였습니다.

 

@SpringBootTest
@AutoConfigureWebMvc
@AutoConfigureRestDocs(uriScheme = "https", uriHost = "docs.liar.com", uriPort = 443)
@ExtendWith(RestDocumentationExtension.class)
public class CommonRestDocsController {

    protected MockMvc mockMvc;

	@Autowired RedisTemplate redisTemplate;

    @Autowired MemberRepository memberRepository;

    @Autowired TokenRepository tokenRepository;
    
    @Autowired FacadeService facadeService;
    
    @Autowired ObjectMapper objectMapper;

    @BeforeEach
    public void init(WebApplicationContext webApplicationContext,
                     RestDocumentationContextProvider restDocumentationContextProvider) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(documentationConfiguration(restDocumentationContextProvider))
                .build();
    }
}

 

어노테이션과 사용되는 파라미터를 하나씩 정리하면 다음과 같습니다.

 

@SpringBootTest: SpringBoot를 Test환경에서 실행하도록 돕는 어노테이션입니다. application을 컨텍스트에 로드하며, 필요한 빈을 주입하여 통합 테스트 환경을 제공해 줍니다.

 

@AutoConfigureWebMvc: MockMvc를 사용하는데 필요한 설정정보를 자동으로 제공하는 역할을 수행합니다. HadlerMapping 혹은 HandlerAdapter 등의 구성요소를 자동으로 주입해 주기 때문에 복잡한 Mvc 관련 코드를 생략할 수 있습니다.

(SpringMvc로 적용되지만 실제 RestController(RestFul)로 작성한 경우도 모두 포함됩니다. SpringMvc라고 표현되는 이유는 SpringWebFlux와 구분하기 위함입니다.)

 

@AutoConfigureRestDocs: SpringRestDocs를 자동으로 구성하여 api 문서화를 돕는 어노테이션입니다. springRestDocs에 필요한 설정 정보를 제공하며, api의 문서를 생성하는 역할을 수행합니다.

 

@ExtendWith(RestDocumentationExtension.class): Junit5 확장 모델을 사용하여 RestFul api 문서화를 돕는 어노테이션입니다. 

 

MockMvc: MockMvc는 SpringMvc를 테스트할 때 사용하는 기술로, 스프링은 DispatcherServlet을 사용하여 Http요청을 처리하는데, MockMvc는 이러한 과정을 Mock(가짜) 객체로  일련의 과정을 처리해 줍니다. 따라서, 테스트 환경에서 보다 간편하게 코드를 작성하는데 도움을 줄 수 있습니다.

 

@BeforeEach의 init() : Spring Mvc application의 Web Context로 앞서 @ExtendWith(RestDocumentationExtension.class) 어노테이션으로 자동 설정받은 restDocumentationContextProvider를 WebApplicationContext에 적용하여 셋업 함으로써, Mock 환경에서 restDocument를 작성할 수 있는 환경을 만들고 build 하여 인스턴스를 생성한 후 mockMvc에 적용하는 과정입니다.

 

 

 

5. SpringRestDocs 적용을 위한 실제 ControllerTest

 

제가 테스트하고자 하는 RestController의 메서드는 다음과 같습니다.

 

/**
 * 회원 가입 요청
 */
@PostMapping("/register")
public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
    facadeService.register(request);
    return ResponseEntity.ok(SendSuccess.of());
}

 

요청 객체는 FormRegisterRequest이고, 응답 객체는 ResponseEntity입니다.

이제, 테스트 코드를 작성하여 SpringRestDocs를 발급하도록 하겠습니다.

 

SpringRestDocs에서 제공되는 라이브러리는 Intellij에서 패키지 자동 추천이 되지 않는 경우가 많이 있었습니다. 저는 처음 적용할 때, 이 부분에서 많이 혼란스러웠습니다. 따라서, 해당 코드는 길 수 있지만 의존성 패키지까지 전부 제시하였습니다.

 

import com.google.gson.Gson;
import liar.memberservice.member.controller.dto.request.FormRegisterRequest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;

import java.util.UUID;

import static javax.management.openmbean.SimpleType.STRING;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class AuthControllerTest extends CommonRestDocsController {

    @Test
    @DisplayName("RestDocs: register / Post")
    public void registerMvc() throws Exception {
        //given
        FormRegisterRequest request = FormRegisterRequest.builder()
                .email("kose@naver.com")
                .username("gosekose")
                .password(UUID.randomUUID().toString())
                .build();

        //when
        ResultActions perform = mockMvc.perform(
                post("/member-service/register")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(request))
        );

        //then
        perform
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value("200"))
                .andExpect(jsonPath("$.message").value("성공"))
                .andDo(document("member-register",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        requestFields(
                                fieldWithPath("username").type(STRING).description("회원 이름"),
                                fieldWithPath("email").type(STRING).description("이메일"),
                                fieldWithPath("password").type(STRING).description("패스워드")
                        ),
                        responseFields(
                            fieldWithPath("code").type(STRING).description("응답 상태 코드"),
                            fieldWithPath("message").type(STRING).description("상태 메세지")
                        )));

    }
}

 

코드를 분석하면 다음과 같습니다. mockMvc를 활용하면 SpringMvc의 Controller를 mockMvc.perform()을 통해 테스트할 수 있습니다. 저는 Post요청을 수행하므로, 하단의 패키지를 static import 하였습니다.

 

import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;

 

post 요청에는 request로 받을 수 있는 accept, contentType, content를 명시할 수 있습니다. 해당 컨트롤러는 json으로 요청받아야 하므로 content에 Request를 Json으로 변환한 값을 주입하였습니다.

 

추가로 post요청에 헤더 정보를 작성할 수 있습니다. 만약 특정 요청에 대해서는 반드시 Authorization, RefreshToken, UserId 헤더 정보가 필요하다고 하면 다음과 같이 header()로 추가할 수 있습니다.

 

ResultActions perform = mockMvc.perform(
        post("/member-service/register")
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content(new Gson().toJson(request))
                .header("Authorization", "Bearer AccessToken")
                .header("RefreshToken", "refreshToken")
                .header("UserId", "userId"));
);

 

최종적으로 mockMvc.perform()으로 수행된 값은 ResultActions 객체 인스턴스를 반환합니다.

//then 구문에서는 resultActions에 대한 결과를 검증하고 필요한 docs를 발급하는 과정을 작성할 수 있습니다.

 

perform
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.code").value("200"))
        .andExpect(jsonPath("$.message").value("성공"))

 

perform 객체의 andExpect()가 의미하는 바는, 응답 코드와 ResponseEntitiy 응답 객체의 값입니다. 저는 응답 결과로, 정형화된 응답 값을 제공하고자 하였습니다. Body가 필요한 경우는 T 타입이 추가된 SendSuccessBody 클래스 인스턴스를 제공하지만, 현재 응답은 크게 T body가 필요하지 않으므로, SendSuccess.of()를 응답 객체로 제시하였습니다.

 

@NoArgsConstructor
@AllArgsConstructor
public class SendSuccess {
    public String code;
    public String message;

    public static SendSuccess of() {
        return new SendSuccess(SuccessCode.OK, SuccessMessage.OK);
    }
}

 

하단의. andDo()는 document의 이름을 명시할 수 있고 출력 결과를 이쁘게 보여주는 prettyPrint()를 추가할 수 있습니다.

이후, responseFields()에서 응답 결과로 제공하는 필드명과 설명을 제공할 수 있습니다.

fieldWithPath().type().description() 결과는 해당 필드 이름, 제공 타입, 필드의 설명을 각각 작성하는 부분입니다.

 

.andDo(document("member-register",
        preprocessRequest(prettyPrint()),
        preprocessResponse(prettyPrint()),
        responseFields(
            fieldWithPath("code").type(STRING).description("응답 상태 코드"),
            fieldWithPath("message").type(STRING).description("상태 메세지")
        )));

 

 

이후 테스트를 진행하면, 테스트에 성공한 경우 성공 메시지가 나오며, 패키지의 build/generated-snippets 폴더에 adoc 파일이 생성됩니다. 만약 생성되지 않는다면, Intellij의 캐시 무효화 버튼으로 모든 캐시를 제거 후 다시 시작하면 generated-snippets에 정상적으로 adoc 파일이 추가된 것을 확인할 수 있습니다.

 

 

 

 

 

 

6. 중복 코드 제거하기 

 

SpringRestDocs는 중복되는 코드가 많이 발생할 수 있습니다. mockMvc.perform()을 수행하는 과정에 적용되는 Application_Json, andDo()의 preprocessingRequest, preprocessingResponse 등이 그 예에 속합니다.

따라서, 위에서 작성한 코드를 리팩토링하여 반복되는 코드를 정리하였습니다.

 

public <T> MockHttpServletRequestBuilder customPost(String uri, T t) {
    return post(uri)
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .content(new Gson().toJson(t));
}

public <T> ResultActions mockMvcPerformPost(String uri, T t) throws Exception {
    return mockMvc.perform(customPost(uri, t));
}

mockMvc.perform()을 수행하는 코드에서 accept, contentType, conent는 공통적으로 사용하므로 제네릭 메소드를 활용하여 request타입으로 받아서 MockHttpServletRequestBuilder를 리턴합니다.

 

이후 mockMvcPerformPost()도 제네릭 메소드로 선언하여 ResultActions를 리턴하도록 하였습니다.

두 메서드를 분리하여 각각 적용하도록 한 이유는 상황에 따라, MockHttpServletRequestBuilder 타입에 추가로 헤더 정보등을 넣어야 할 수도 있습니다. 이 경우 두 타입을 분리하여 상황에 맞게 적용하기 위함입니다.

 

만약 헤더 정보를 추가해야한다면 자바의 다형성을 활용하여 uri, T 타입 request를 받거나, uri, T 타입 request, AuthTokenDto로 헤더 정보를 받을 수 있습니다.

 

public <T> MockHttpServletRequestBuilder customPost(String uri, T t) {
    return post(uri)
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .content(new Gson().toJson(t));
}

public <T> ResultActions mockMvcPerformPost(String uri, T t) throws Exception {
    return mockMvc.perform(customPost(uri, t));
}

public <T> MockHttpServletRequestBuilder customPost(String uri, T t, AuthTokenDto auth) {
    return post(uri)
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .content(new Gson().toJson(t))
            .header("Authorization", auth.getAccessToken())
            .header("RefreshToken", auth.getRefreshToken())
            .header("UserId", auth.getUserId());

}

public <T> ResultActions mockMvcPerformPost(String uri, T t, AuthTokenDto auth) throws Exception {
    return mockMvc.perform(customPost(uri, t, auth));
}

 

< 최종 수정된 코드 >

//when
ResultActions perform = mockMvcPerformPost("/member-service/register", request);

 

다음은 ResultActions에 공통적으로 호출되는  document()을 커스텀화하였습니다.

static method는 리턴타입이 RestDocumentationResultHandler입니다. 따라서, document() 메서드를 호출한 후 preprocessRequest, preprocessResponse를 추가하였습니다. 

 


snippet의 경우 다양한 코드가 추가될 수 있습니다. 이때 공통적으로 묶을 수 있는 부분은 ResponseFieldsSnippet 리턴타입으로 custom하게 작성할 수 있습니다.

 

public RestDocumentationResultHandler customDocument(String identifier,
                                                     Snippet... snippets) {
    return document(
            identifier,
            preprocessRequest(prettyPrint()),
            preprocessResponse(prettyPrint()),
            snippets
    );
}

public ResponseFieldsSnippet responseCustomFields(FieldDescriptor... fieldDescriptors) {
    FieldDescriptor[] defaultFieldDescriptors = new FieldDescriptor[] {
            fieldWithPath("code").type(STRING).description("응답 상태 코드"),
            fieldWithPath("message").type(STRING).description("상태 메세지")
    };

    return responseFields(defaultFieldDescriptors).and(fieldDescriptors);
}

 

< 최종 수정된 코드 > 

perform
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.code").value("200"))
        .andExpect(jsonPath("$.message").value("성공"))
        .andDo(customDocument("member-register",
                requestFields(
                        fieldWithPath("username").type(STRING).description("회원 이름"),
                        fieldWithPath("email").type(STRING).description("이메일"),
                        fieldWithPath("password").type(STRING).description("패스워드")
                ),
                responseCustomFields()
        ));

 

 

 

7. 페이징 쿼리 응답 추가하기

 

SpringRestDocs에서 가장 복잡한 부분은 페이징 쿼리를 응답을 작성하는 부분입니다. 만약 QueryDsl로 Slice<>, Page<> 형태의 객체를 응답 Body로 제공할 때, SpringRestDocs 테스트를 성공하려면 사용되는 페이징 쿼리를 전부 작성해야 합니다.

 

이 문제는 여간 쉬운 일이 아니고, 테스트마다 모두 작성하는 것은 비효율적입니다.

따라서, 이러한 코드도 앞서 Custom하게 정리했던 방식대로 페이징 쿼리 전용 메서드를 만들 수 있습니다.

 

 

이를 구현하기 위해, CommonRestDocsController에 ResponseFieldsSnippet 리턴타입의 responseFieldsSnippetPageable 메서드를 추가였고 공통적으로 사용되는 페이징 응답을 작성하였습니다.

 


public ResponseFieldsSnippet responseCustomFieldsPageable(FieldDescriptor... fieldDescriptors) {
    FieldDescriptor[] fields = new FieldDescriptor[] {
            fieldWithPath("body.pageable.offset").type(NUMBER).description("The offset of the current page"),
            fieldWithPath("body.pageable.pageNumber").type(NUMBER).description("The number of the current page"),
            fieldWithPath("body.pageable.pageSize").type(NUMBER).description("The size of the current page"),
            fieldWithPath("body.pageable.paged").type(BOOLEAN).description("Whether the current page is paged"),
            fieldWithPath("body.pageable.unpaged").type(BOOLEAN).description("Whether the current page is unpaged"),
            fieldWithPath("body.sort.empty").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.sort.sorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.sort.unsorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.pageable.sort.empty").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.pageable.sort.sorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.pageable.sort.unsorted").type(BOOLEAN).description("Whether the current page is sorted"),
            fieldWithPath("body.totalPages").type(NUMBER).description("The total number of pages"),
            fieldWithPath("body.totalElements").type(NUMBER).description("The total number of elements"),
            fieldWithPath("body.last").type(BOOLEAN).description("Whether the current page is the last one"),
            fieldWithPath("body.size").type(NUMBER).description("The size of the current page"),
            fieldWithPath("body.number").type(NUMBER).description("The number of the current page"),
            fieldWithPath("body.numberOfElements").type(NUMBER).description("The number of elements in the current page"),
            fieldWithPath("body.first").type(BOOLEAN).description("Whether the current page is the first one"),
            fieldWithPath("body.empty").type(BOOLEAN).description("Whether the current page is empty")
    };
    return responseFields(fieldDescriptors).and(fields);
}

 

FieldDescriptor 타입의 인자들을 받아서, reponseFields()를 생성한 후 FieldDescriptor 배열을 추가하여 ResponseFieldsSnippet의 인스턴스를 적용하는 방식입니다.

 

이를 활용하여, responseCustomFieldsPageable()은 commonRestDocsController에서 작성한 메서드를 받아서 추가로 필요한 custom 응답값을 추가합니다. 

 

result.andExpect(status().isOk())
        .andDo(customDocument("result-myResult",
                requestFields(
                        fieldWithPath("userId").type(STRING).description("유저 아이디"),
                        fieldWithPath("viewLatest").type(BOOLEAN).description("최신 순 조회"),
                        fieldWithPath("viewOnlyWin").type(BOOLEAN).description("승리한 경기만 조회"),
                        fieldWithPath("viewOnlyLose").type(BOOLEAN).description("패배한 경기만 조회"),
                        fieldWithPath("searchGameName").type(STRING).description("게임 이름을 포함한 경기만 조회")
                ),
                responseCustomFieldsPageable(
                        fieldWithPath("code").type(STRING).description("상태 코드"),
                        fieldWithPath("message").type(STRING).description("상태 메세지"),
                        fieldWithPath("body.content[].gameId").type(STRING).description("게임 아이디"),
                        fieldWithPath("body.content[].gameName").type(STRING).description("게임 이름"),
                        fieldWithPath("body.content[].topicName").type(STRING).description("주제"),
                        fieldWithPath("body.content[].winner").type(STRING).description("승리한 역할"),
                        fieldWithPath("body.content[].totalUsers").type(LONG).description("총 유저 수"),
                        fieldWithPath("body.content[].myRole").type(STRING).description("내 역할"),
                        fieldWithPath("body.content[].answer").type(BOOLEAN).description("내 투표 정답")
                )));

 

따라서, 많은 페이징 쿼리를 작성하더라도 커스텀 메서드를 활용하여 코드의 양을 줄일 수 있었습니다.

 

 

 

7.  정리하며...

 

Swagger나 SpringRestDocs를 사용하는 것은 많은 트레이드오프가 있습니다. 무엇이 더 좋다고 평가하기 어려울 정도로 두 기능 모두 각각 장점들이 너무 뚜렷합니다.  만약 Controller에 대한 테스트를 따로 진행한다면, swagger를 적용하여 빠르고 정확하게 개발을 진행할 수 있습니다.

 

저는 개인적으로 Controller에서 핵심 로직 이외에 부가 기능을 수행하는 어노테이션을 추가하는 것에 부담을 느꼈고, 실제로 아직 실력이 너무나도 부족하기 때문에 테스트를 거치지 않은 제 코드를 믿지 않는 편입니다. 직접 눈으로 확인하고, 에러를 발생시켜 보고 "왜 이건 안되지?" 생각하는 시간이 저에게 발전을 가져다주는 것 같습니다.

 

그러다 보니 의식적으로라도 SpringRestDocs를 활용하여 테스트를 진행한 후 api 명세를 발급하게 되는 것 같습니다.  또한, 복잡하고 중복되는 코드의 경우 메서드로 정리해놓은 후, 해당 메서드를 적용하면 되므로 개발 시간도 단축시킬 수 있었습니다.

 

잘못된 부분은 피드백 주시면 바로 배우겠습니다.!

부족하지만 오늘도 읽어주셔서 감사드립니다!!!

 

+ Recent posts