본문 바로가기

개발일기장

xuni - Spring Rest Docs 도입하기

해당 포스팅은 Spring Rest Docs 3.0.0 공식 문서와 개인적인 경험을 통해 작성되었습니다.

 

목차

1. 도입 배경

2. Spring Rest Docs 채택 이유

3. Spring Rest Docs 설정

4. Rest Docs 테스트 만들어보기

5. AsciiDoc으로 API 문서 만들기

6. Json Field Path, Mocking 을 통해 명세하기


도입 배경

팀 프로젝트를 진행할 당시, 백엔드 개발 담당이었던 나는 REST API로 프론트엔드 개발자에 데이터를 전달했어야 했다. 그래서 명세서를 만들기로 했고 지나보면 추억이지만 수기로 REST API 명세를 작성했었다. 

 

수기로 작성했었던... 그님스 API 명세

 

문제는 인간은 기계보다 영리하지만 기계보다 꼼꼼하지는 못하다는 것이다. 다음은 수기로 API 명세를 만들 때 단점이다.

 

1. 프로덕션 코드와 API 명세의 동기화를 보장할 수 없음

2. 호출이 불가능한 API 경우에도 명세가 작성될 수 있다.

 

실제로 1번으로 인해 프론트앤드 분들에게 종종 불편을 드렸던 기억이 난다. 당시에는 API를 만들고 꼭 명세하자라고 기억했지만 나는 인간이기 때문에 꼼꼼하지 못하다...

 

수기 API 명세를 보완할 수 있는 방법이 없을까? 스프링 진영에서는 대표적으로 Swagger, Spring Rest Docs 통해 API 명세를 자동화할 수 있습니다.

 

 

Swagger
Spring Rest Docs

 


Spring Rest Docs 채택 이유

Swagger, Spring Rest Docs 둘 중 무엇을 도입하더라도 1. 프로덕션 코드와 API 명세의 동기화를 보장할 수 없음 문제를 해결할 수 있다. Spring Rest Docs는 테스트가 미통과 시 빌드 자체가 되지 않기 때문에 동기화를 보장한다. Swagger 경우, 동기화를 보장하진 않는다는 글들이 있는데 이유는 잘 모르겠다.

 

문제는 2. 호출이 불가능한 API 경우에도 명세가 작성될 수 있다. 이다. 사실 API 문서와는 크게 관련없는 내용일수도 있다고 생각한다. 테스트 코드를 잘 구성하면 Swagger를 사용하더라도 문제가 없을 것이다. 반면 Spring Rest Docs를 사용하더라도 테스트를 어떻게 구성하느냐에 따라 문제가 생길수도 있다. 하지만 중요한 것은 Spring Rest Docs는 테스트를 강제한다는 것이다. 개발자가 테스트를 잘 구성하면 2번 문제를 강제로 해결할 수 있게 된다.

 

그리고 개인적으로 Swagger를 사용하면 프로덕션 코드가 오염되는 것 같아 선호하지 않는다. 다만 Spring Rest Docs를 위해 순순하게 노력을 들여야되는 부분이 존재한다. 뭐 개인 프로젝트는 시간 압박이 있는 프로젝트는 아니라서 이정도 트레이드 오프는 가져갈 수 있을 것이라 생각했다.

 

그리고 정말 마지막으로 Rest Docs 라는 말이 조금 멋있어 보였다.

 


Spring Rest Docs 설정

Spring Rest Docs 3.0.0 을 기준으로 작성되었음을 다시 한 번 강조합니다.

 

적용한 프로젝트 설정

 

  • Spring Boot 3.0.5
  • Gradle 7.6
  • Window

 

Spring Rest Docs 3.0.0 최소 요구조건

  • Java 17
  • Spring Framework 6

 

때문에 SpringBoot 2.x.x로 프로젝트를 진행하고 있다면 문제가 생길수도 있다.

2.x.x 에서는 기본적으로 Spring Framework 5.x.x를 자동 주입합니다. (2.7.8 이후 버전은 잘 모르겠어요.)

 

 

설정을 위해 Build.gradle 에 다음과 같은 설정을 추가합니다.

 

plugins { (1)
	id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
	asciidoctorExt (2)
}

dependencies {
	asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:{project-version}' (3)
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:{project-version}' (4)
}

ext { (5)
	snippetsDir = file('build/generated-snippets')
}

test { (6)
	outputs.dir snippetsDir
}

asciidoctor { (7)
	inputs.dir snippetsDir (8)
	configurations 'asciidoctorExt' (9)
	dependsOn test (10)
}

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

 

(5) 스니펫(adoc 확장자)이 만들어지는 디렉토리, 스니펫은 API 문서를 만들기 위한 조각이라고 생각하면 된다. 테스트 성공 시, 스니펫들이 만들어진다.

(10) asciidoctor 가 test task 에 의존하게 만든다.

그래서 결국 기본 설정을 따르면 build task를 수행하면 test도 수행하기 때문에 자동으로 asciidoctor도 수행된다.

(11) 번을 추가해야 빌드 시 jar 파일에 Docs로 만든 API 문서를 불러올 수 있습니다.

 

마지막으로 Rest Docs를 만들기 위해 아래 애노테이션을 붙여 Spring Rest Docs 관련 빈들을 주입 받아옵니다.

@AutoConfigureRestDocs

Rest Docs 테스트 만들어보기

프로젝트에서 채택하고 있는 테스트 방식

  • 레이어 별 단위 테스트
  • Presentation 계층은 기본 @WebMvcTest 주입
  • MockMvc 를 통한 Rest Docs 생성

 

아래는 그룹을 생성하는 API를 명세한 테스트 코드입니다.

 

(1) 앞서 설정에서 build/generated-snippets 디렉토리에 스니펫이 생성되도록 만들었습니다. 해당 API 스니펫이 만들어지는 경로를 설정합니다. 즉, build/generated-snippets/group/create에 API에 스니펫이 만들어지게 됩니다.

 

(2) 그룹 생성 시, 클라이언트는 Http Body에 데이터를 담아 보내야 합니다. 보내야 하는 데이터를 명세하고 있습니다.

 

(3) 클라이언트 요청에 대한 응답을 명세하고 있습니다.

 

 

테스트를 성공하면 아래와 같이 adoc 파일들이 만들어집니다.

 

 

 


AsciiDoc으로 API 문서 만들기

AsciiDoc을 통해 문서를 만들고 있습니다. Markdown으로도 문서를 만들수도 있다고 합니다.

 

우선 API 문서를 만들기 위해 디렉토리를 만들어야 합니다. 설명이 이해되지 않는다면 Using the Snippets를 참고해주세요. 아래처럼 src/docs/asciidoc/*.adoc 파일을 만들어주세요.

 

 

group.adoc

:doctype: book
:icons: font
:toc: left
:toclevels: 3


== 그룹 생성
=== HTTP request
include::{snippets}/group/create/http-request.adoc[]

=== HTTP response
include::{snippets}/group/create/response-body.adoc[]

 

이제 gradle을 통해 빌드를 수행합니다.

./gradlew build

 

빌드가 정상적으로 수행되면 libs 디렉토리 내부에 jar 압축 파일이 만들어지게 됩니다.

 

 

 

해당 압축 파일로 프로젝트를 실행시켜봅시다. 터미널에 아래 명령어를 입력합니다.

 java -jar ./build/libs/{jar 파일 이름}.jar

 

만들어진 API 문서는 {protocol}://{domain}:{port}/docs/*.html 을 통해 접근할 수 있습니다. 

 

 

아래로 요청을 해보면 API 문서가 만들어진 것을 확인할 수 있습니다.

http://localhost:8080/docs/group.html

 

여기까지로 Spring Rest Docs 를 통해 API 문서를 만드는 방법을 알아보았습니다. 마지막은 Rest Docs를 만들면서 헤매고 해결했던 부분을 한 가지 설명해보도록 하겠습니다.

 


Json Field Path, Mocking 을 통해 명세하기

API 개발 시 클라이언트의 요청에 응답을 하기위해 Service 레이어를 의존하는 경우가 많습니다. 예를 들면 조회 API 경우, 엔티티를 그대로 내려주는 것이 아니라 Service 레이어에서 dto로 변환해 Controller 레이어로 전달합니다.

 

이 경우에 테스트 시 Service 레이어 의존이 필요합니다. 하지만 @WebMvcTest 는 @Service 컴포넌트를 스캔하지 않고 애초에 컨트롤러 기능 자체를 수행하는 것이기 때문에 Rest Docs 생성을 위해 @SpringBootTest 를 의존하는 것이 맞을지 고민해봐야 합니다.

 

이런 문제는 Mocking을 통해 해결할 수 있습니다. @MockBean 을 통해 깡통 빈으로 의존 관계를 설정해주세요.

@MockBean
GroupReadService groupReadService;

 

이후 Mokito, MockBean 을 활용해서 가상의 응답을 만들어줍니다.

 

 

여기서 response 에 해당하는 부분을 만들었습니다.

 

가상 응답을 만든 뒤 이를 Rest Docs에 ResponseFields 에 매핑해야 합니다. 매핑하는 방법은 JSON Field Paths

를 통해 확인하시면 됩니다. 아래는 위 JSON 형식의 응답을 매핑한 코드입니다.

 

responseFields(
        fieldWithPath("status").type(JsonFieldType.NUMBER).description("상태 코드"),
        fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"),

        fieldWithPath("response").type(JsonFieldType.ARRAY).description("조회 데이터"),
        fieldWithPath("response[].groupId").type(JsonFieldType.NUMBER).description("그룹 식별자"),

        fieldWithPath("response[].capacity").type(JsonFieldType.OBJECT).description("조회 데이터"),
        fieldWithPath("response[].capacity.totalCapacity").type(JsonFieldType.NUMBER).description("정원"),
        fieldWithPath("response[].capacity.leftCapacity").type(JsonFieldType.NUMBER).description("남은 자리"),

        fieldWithPath("response[].time").type(JsonFieldType.OBJECT).description("그룹 스터디 시간"),
        fieldWithPath("response[].time.startTime").type(JsonFieldType.STRING).description("시작 시간"),
        fieldWithPath("response[].time.endTime").type(JsonFieldType.STRING).description("종료 시간"),

        fieldWithPath("response[].period").type(JsonFieldType.OBJECT).description("그룹 스터디 기간"),
        fieldWithPath("response[].period.startDate").type(JsonFieldType.STRING).description("시작일"),
        fieldWithPath("response[].period.endDate").type(JsonFieldType.STRING).description("종료일"),

        fieldWithPath("response[].groupStatus").type(JsonFieldType.STRING).description("그룹 상태"),

        fieldWithPath("response[].host").type(JsonFieldType.OBJECT).description("호스트"),
        fieldWithPath("response[].host.hostId").type(JsonFieldType.NUMBER).description("호스트 식별자"),
        fieldWithPath("response[].host.hostName").type(JsonFieldType.STRING).description("호스트 이름"),

        fieldWithPath("response[].groupMembers").type(JsonFieldType.ARRAY).description("그룹 멤버"),
        fieldWithPath("response[].groupMembers[].groupMemberId").type(JsonFieldType.NUMBER).description("그룹 멤버 식별자"),
        fieldWithPath("response[].groupMembers[].groupMemberName").type(JsonFieldType.STRING).description("그룹 멤버 이름"),
        fieldWithPath("response[].groupMembers[].isLeft").type(JsonFieldType.BOOLEAN).description("탈퇴 여부")
    )

 

포스팅은 여기서 마치겠습니다. 감사합니다.