들어가기 전에
기존에 진행했던 프로젝트들의 API 명세서를 작성할 때는 어노테이션만으로 API 요청 및 응답 정보를 생성할 수 있고, Postman 등의 툴 없이 API를 실행할 수 있다는 장점이 있어서 OpenApi에서 제공하는 Swagger-UI를 사용하고 있었습니다.
하지만 스웨거의 경우 어노테이션을 통해 API 문서를 생성하다보니 API가 변경되었을 때 어노테이션의 내용도 수정하지 않으면 실제 API 사양과 스웨거 문서 상의 API 사양이 일치하지 않는 문제가 있어 API 문서를 완전히 자동으로 생성할 수는 방법이 필요했습니다.
그렇게 자동으로 API 문서를 생성하는 방법에 대해 찾아보던 중 스프링에서 제공하는 Spring REST Docs라는 도구가 있다는 사실을 알게되어 해당 방법을 도입해보기로 했습니다.
Spring REST Docs?
Spring REST Docs는 이름에서부터 알 수 있듯이 스프링에서 RESTful 서비스 문서 작성을 도와주는 도구입니다.
스웨거와 달리 테스트 코드를 기반으로 API 문서를 생성하기 때문에 프로덕션 코드에 영향을 주지 않고 API에 대한 다양한 정보를 제공할 수 있습니다.
테스트 코드를 기반으로 한다는 것은 API 문서를 생성하려면 반드시 테스트 코드를 작성해서 테스트에 성공해야 한다는 뜻이고, API 문서가 생성되었다는 것은 모든 테스트를 통과했다는 뜻이 됩니다. 즉 서비스의 신뢰도가 상승하는 것과 같습니다.
다만 스웨거와 달리 페이지 내에서 바로 API를 실행해볼 수 없기 때문에 API를 실행하려면 포스트맨이나 curl 등을 사용해야 하는 단점도 있습니다.
스웨거와 Spring REST Docs를 간단히 비교해보면 아래처럼 정리되겠네요.
Spring REST Docs | Swagger-UI | |
장점 | 테스트 코드를 기반으로 하기 때문에 신뢰도가 높다. | 페이지 내에서 바로 API를 실행해볼 수 있다. |
프로덕션 코드에 영향을 주지 않는다. | 간단하게 적용해서 사용할 수 있다. | |
자동으로 생성되기 때문에 변경된 API 사양에 바로 동기화가 된다. | ||
단점 | 적용하는 과정이 복잡하다. | API에 대해 다양한 정보를 제공하려면 여러 어노테이션을 사용해야 하기 때문에 프로덕션 코드가 지저분해진다. |
페이지 내에서 바로 API를 실행할 수 없다. | 어노테이션으로 직접 정보를 입력하기 때문에 API 사양이 변경되면 어노테이션의 정보도 직접 수정해야 한다. |
Spring REST Docs를 적용할 준비를 하자!
프로젝트 환경
Spring REST Docs를 적용한 프로젝트의 환경은 아래 환경을 사용했습니다.
- SpringBoot 3.1.11
- Gradle 8.7
- Kotlin 1.9.22(JDK 17)
- JUnit5
Gradle에 관련 설정을 추가
스프링부트 프로젝트에 Spring REST Docs를 적용하려면 build.gradle
또는 build.gradle.kts
파일에 아래 내용들을 추가해줘야 합니다.
build.gradle
plugins {
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
dependencies {
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
ext {
set('snippetsDir', file("build/generated-snippets"))
}
tasks.named('test') {
outputs.dir snippetsDir
}
tasks.named('asciidoctor') {
inputs.dir snippetsDir
dependsOn test
}
build.gradle.kts
plugins {
id("org.asciidoctor.jvm.convert") version "3.3.2"
}
dependencies {
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
}
extra["snippetsDir"] = file("build/generated-snippets")
tasks.bootBuildImage {
builder.set("paketobuildpacks/builder-jammy-base:latest")
}
tasks.test {
outputs.dir(project.extra["snippetsDir"]!!)
}
tasks.asciidoctor {
inputs.dir(project.extra["snippetsDir"]!!)
dependsOn(tasks.test)
}
스프링 이니셜라이저로 스프링부트 프로젝트를 신규 생성할 때 Testing 탭의 Spring REST Docs를 의존성에 추가한다면 위 내용들이 자동으로 작성됩니다.
Gradle 추가 설정
기본 설정은 위와 같이만 해줘도 API 문서 자동 생성이 가능하지만 아래 예시처럼 추가로 더 상세한 설정을 할 수도 있습니다.
build.gradle
ext {
set('snippetsDir', file("build/generated-snippets"))
set('docsOutputDir', file("build/docs/asciidoc")) // API 문서가 생성되는 경로
set('projectDocsDir', file("src/main/resources/static/docs")) // 생성된 API 문서를 복사할 경로
}
tasks.named('bootJar') {
dependsOn 'asciidoctor' // asciidoctor 수행 후 bootJar 수행
}
tasks.named('test') {
doFirst { delete(snippetsDir) } // test 수행 전 실행
outputs.dir snippetsDir
}
tasks.named('asciidoctor') {
doFirst { // asciidoctor 수행 전 실행
delete(docsOutputDir) // 기존 생성된 API 문서 삭제
delete(projectDocsDir) // 기존에 복사된 API 문서 삭제
}
inputs.dir snippetsDir
dependsOn test
doLast { // asciidoctor 수행 완료 후 실행
copy { // 생성된 API 문서를 지정된 경로에 복사
from docsOutputDir
into projectDocsDir
}
}
}
build.gradle.kts
extra["snippetsDir"] = file("build/generated-snippets")
extra["docsOutputDir"] = file("build/docs/asciidoc") // API 문서가 생성되는 경로
extra["projectDocsDir"] = file("src/main/resources/static/docs") // 생성된 API 문서를 복사할 경로
tasks.bootJar {
dependsOn(tasks.asciidoctor) // asciidoctor 수행 후 bootJar 수행
}
tasks.test {
doFirst { delete(project.extra["snippetsDir"]!!) } // test 수행 전 실행
outputs.dir(project.extra["snippetsDir"]!!)
}
tasks.asciidoctor {
doFirst { // asciidoctor 수행 전 실행
delete(project.extra["docsOutputDir"]!!) // 기존 API 문서 삭제
delete(project.extra["projectDocsDir"]!!) // 기존에 복사된 API 문서 삭제
}
inputs.dir(project.extra["snippetsDir"]!!)
dependsOn(tasks.test)
doLast { // asciidoctor 수행 완료 후 실행
copy { // 생성된 API 문서를 지정된 경로에 복사
from(project.extra["docsOutputDir"]!!)
into(project.extra["projectDocsDir"]!!)
}
}
}
위 내용은 bootJar
로 생성된 .jar
파일에 API 문서를 담기 위해 몇가지 조정을 한 예시입니다.
dependsOn
으로 asciidoctor
가 수행된 이후에 bootJar
가 수행되도록 해줍니다.
test
단계에서는 테스트 수행 전에 스니펫이 생성되는 디렉토리를 삭제하도록 했습니다. 기본적으로는 테스트 코드에서 지정한 디렉토리의 스니펫은 자동으로 삭제되고 재생성되지만, 테스트 코드에서 지정한 디렉토리를 변경할 경우에는 제대로 삭제되지 않습니다.
가령, member-info
디렉토리에 스니펫을 생성하도록 테스트 코드를 작성했다가 이 경로를 member/info
로 변경하고 테스트 코드를 실행하면 기존에 생성되어 있던 member-info
내부의 스니펫이 그대로 남게 됩니다. 이를 방지하기 위한 설정입니다.
asciidoctor
단계에서는 API 문서가 생성되기 전에 기존에 생성된 API 문서들을 삭제하고, API 문서가 생성된 이후엔 build
디렉토리 내에 생성된 API 문서를 프로젝트의 resources/static
으로 복사하여 서버에 접속하여 바로 확인할 수 있도록 하였습니다.
API 문서 만들기
Spring REST Docs로 API 문서를 생성하려면 테스트 코드 외에도 추가적인 설정이 필요합니다. 우선은 테스트 코드부터 작성해봅시다.
테스트 코드를 작성해보자!
Spring REST Docs를 사용하기 위한 테스트 코드는 기본적으로는 컨트롤러 테스트코드와 비슷합니다. 여기서는 MockMvc를 사용하여 테스트 코드를 작성했습니다.
Kotlin
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
class AdminControllerTest @Autowired constructor(
private val tokenProvider: TokenProvider,
private val memberRepository: MemberRepository,
private val mockMvc: MockMvc
) {
@BeforeEach
fun clear() {
memberRepository.deleteAllInBatch()
}
@Test
fun `모든 사용자 찾기`() {
val token = "Bearer ${tokenProvider.createAccessToken("admin:ADMIN")}"
// given
val uuids = memberRepository.saveAll(
listOf(
Member("colabear754", "1234", "콜라곰", type = MemberType.USER),
Member("ciderbear754", "1234", "사이다곰", type = MemberType.USER),
Member("fantabear754", "1234", "환타곰", type = MemberType.USER)
)
).map { it.id.toString() }
// when
val actionsDsl = mockMvc.get("/admin/members") { header(HttpHeaders.AUTHORIZATION, token) }
// then
actionsDsl.andExpect {
status { isOk() }
jsonPath("$.status", Matchers.`is`(ApiStatus.SUCCESS.name))
jsonPath("$.message", Matchers.nullValue())
jsonPath("$.data", Matchers.hasSize<Any>(3))
jsonPath("$.data[*].id", Matchers.containsInAnyOrder(uuids[0], uuids[1], uuids[2]))
jsonPath("$.data[*].name", Matchers.containsInAnyOrder("콜라곰", "사이다곰", "환타곰"))
jsonPath("$.data[*].type", Matchers.containsInAnyOrder("USER", "USER", "USER"))
}
// Spring REST Docs
actionsDsl.andDo { handle(
document(
"admin/members", // identifier
preprocessRequest( // requestPreprocessor
modifyUris()
.scheme("https")
.host("api.demo.com")
.removePort(),
modifyHeaders()
.set(HttpHeaders.AUTHORIZATION, "Bearer {access_token}")
),
preprocessResponse(prettyPrint()), // responsePreprocessor
requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("인증 토큰")), // snippets
responseFields(
fieldWithPath("status").description("API 상태"),
fieldWithPath("message").description("API 메시지").type(JsonFieldType.STRING).optional(),
fieldWithPath("data").description("응답 데이터"),
fieldWithPath("data[].id").description("사용자 ID"),
fieldWithPath("data[].account").description("계정"),
fieldWithPath("data[].name").description("이름"),
fieldWithPath("data[].age").description("나이").type(JsonFieldType.NUMBER).optional(),
fieldWithPath("data[].type").description("사용자 유형"),
fieldWithPath("data[].createdAt").description("가입일")
)
)
) }
}
}
Java
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
public class AdminControllerTest {
private final TokenProvider tokenProvider;
private final MemberRepository memberRepository;
private final MockMvc mockMvc;
@Autowired
public AdminControllerTest(TokenProvider tokenProvider, MemberRepository memberRepository, MockMvc mockMvc) {
this.tokenProvider = tokenProvider;
this.memberRepository = memberRepository;
this.mockMvc = mockMvc;
}
@Test
void 모든_사용자_찾기() throws Exception {
// given
String token = "Bearer " + tokenProvider.createAccessToken("admin:ADMIN");
List<String> uuids = memberRepository.saveAll(List.of(
Member.builder()
.account("colabear754")
.password("1234")
.name("콜라곰")
.type(MemberType.USER)
.build(),
Member.builder()
.account("ciderbear754")
.password("1234")
.name("사이다곰")
.type(MemberType.USER)
.build(),
Member.builder()
.account("fantabear754")
.password("1234")
.name("환타곰")
.type(MemberType.USER)
.build()
)).stream()
.map(member -> member.getId().toString())
.toList();
// when
ResultActions action = mockMvc.perform(get("/admin/members").header(HttpHeaders.AUTHORIZATION, token));
// then
action.andExpect(status().isOk())
.andExpect(jsonPath("$.status", Matchers.is(ApiStatus.SUCCESS.name())))
.andExpect(jsonPath("$.message", Matchers.nullValue()))
.andExpect(jsonPath("$.data", Matchers.hasSize(3)))
.andExpect(jsonPath("$.data[*].id", Matchers.containsInAnyOrder(uuids.get(0), uuids.get(1), uuids.get(2))))
.andExpect(jsonPath("$.data[*].name", Matchers.containsInAnyOrder("콜라곰", "사이다곰", "환타곰")))
.andExpect(jsonPath("$.data[*].type", Matchers.containsInAnyOrder("USER", "USER", "USER")));
// Spring REST Docs
action.andDo(document(
"admin/members", // identifier
preprocessRequest( // requestPreprocessor
modifyUris()
.scheme("https")
.host("api.demo.com")
.removePort(),
modifyHeaders()
.set(HttpHeaders.AUTHORIZATION, "Bearer {access_token}")
),
preprocessResponse(prettyPrint()), // responsePreprocessor
requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("인증 토큰")), // snippets
responseFields(
fieldWithPath("status").description("API 상태"),
fieldWithPath("message").description("API 메시지").type(JsonFieldType.STRING).optional(),
fieldWithPath("data").description("응답 데이터"),
fieldWithPath("data[].id").description("사용자 ID"),
fieldWithPath("data[].account").description("계정"),
fieldWithPath("data[].name").description("이름"),
fieldWithPath("data[].age").description("나이").type(JsonFieldType.NUMBER).optional(),
fieldWithPath("data[].type").description("사용자 유형"),
fieldWithPath("data[].createdAt").description("가입일")
)
));
}
}
다른 의존성을 사용하지 않는 테스트 코드를 작성하는 경우라면 @SpringBootTest
와 @AutoConfigureMockMvc
대신 @WebMockMvc
를 사용할 수도 있습니다.
@AutoConfigureRestDocs
는 Spring REST Docs를 활성화하고 자동 설정을 적용하는 어노테이션입니다. 테스트 클래스에 해당 어노테이션을 적용해야 테스트 코드 실행 시 자동으로 스니펫이 작성됩니다.
actionDsl.andExpect
까지는 기존의 컨트롤러 테스트와 동일합니다.
이후 actionDsl.andDo
부분에서 자동으로 작성되는 스니펫에 대해 설정합니다.
첫번째 파라미터인 identifier
는 위의 그래들 설정에서 snippetsDir
로 설정한 경로 내에 API 문서가 저장될 경로를 지정합니다. 위 테스트 코드에서는 build/generated-snippets/admin/members
디렉토리 내부에 /admin/members
API 해당하는 .adoc
파일이 생성됩니다.
두번째 파라미터인 requestPreprocessor
는 API의 요청 파라미터를 문서화하기 전에 전처리를 진행할 내용을 넣어줍니다. 위 테스트 코드에서는 API의 호스트를 변경하고, 요청 헤더의 Authorization
을 변경했습니다. modifyUris()
를 설정하지 않으면 요청 문서의 호스트가 기본값인 localhost:8080
으로 작성됩니다.
스키마와 호스트, 포트 정보의 경우 아래와 같이 테스트 클래스의 @AutoConfigureRestDocs
에서 공통으로 처리할 수도 있습니다 uriPort
값을 0으로 주면 포트 정보를 제거하고, 그 외에는 호스트 뒤에 포트번호가 작성됩니다.
@AutoConfigureRestDocs(uriScheme = "https", uriHost = "api.demo.com", uriPort = 0)
세번째 파라미터인 responsePreprocessor
는 응답 파라미터를 문서화하기 전에 전처리를 진행할 내용을 넣어줍니다. 위 테스트 코드에서 인자로 넣어준 preprocessResponse(prettyPrint())
는 응답 JSON 데이터를 JSON 형식에 맞게 정렬해주는 전처리 프로세서입니다.
마지막으로 snippets
은 요청, 응답 데이터의 내용을 정의한 스니펫을 전달받는 가변 인자입니다. snippets
로 요청 필드나 응답 필드를 정의한 스니펫을 전달하면 각각 request-fields.adoc
또는 response-fields.adoc
파일이 추가로 생성됩니다.
각 필드의 타입은 요청/응답 필드의 타입에 맞춰 자동으로 지정되는데 null
인 응답은 별도로 타입을 지정하지 않으면 Null 타입으로 지정됩니다. 이 때 optional()
을 설정하지 않으면 테스트에 실패하여 문서가 작성되지 않습니다.
requestFields
로 요청 필드를 정의하거나 responseFields
로 응답 필드를 정의할 때는 하나라도 누락된 필드가 있다면 예외가 발생하여 테스트에 실패합니다. 원하는 필드만 정의를 하려면 relaxedRequestFields
와 relaxedResponseFields
를 사용해야 합니다.
요청/응답 필드 스니펫을 커스터마이징하고 싶다!
현재 자동으로 생성된 요청/응답 필드 스니펫에는 필드명, 타입, 설명만 적혀있습니다. 요청 필드의 필수값 여부나 응답 필드의 Nullable 여부까지 표시하고 싶습니다.
인텔리제이 프로젝트 탐색기에서 External Libraries
를 펼쳐서 쭉 내려가다보면 Gradle: org.springframework.restdocs:spring-restdocs-core
라는 라이브러리가 있습니다.
이 라이브러리를 다시 펼쳐서 org.springframework.restdocs
안의 templates/asciidoctor
디렉토리를 펼쳐보면 수많은 .snippet
파일이 존재하는 것을 볼 수 있습니다.
이 .snippet
파일들이 테스트 코드를 실행하면 자동으로 생성되는 .adoc
파일들의 포맷이 됩니다.
요청과 응답 필드 스니펫의 포맷을 변경할거니까 src/test/resources
디렉토리 내부에 org/springframework/restdocs/templates
디렉토리를 만들어서 default-request-fields.snippet
의 내용을 복사한 request-fields.snippet
과 default-response-fields.snippet
의 내용을 복사한 response-fields.snippet
을 만들어서 내용을 추가해줍니다.
request-fields.snippet
response-fields.snippet
노란 음영 부분이 변경된 부분입니다.
공통적으로 Path를 Name으로 바꾸고 요청 스니펫에는 Required를, 응답 스니펫에는 Nullable을 추가했습니다.
요청과 응답의 optional
여부를 표시할 수 있도록 7번째 줄을 추가했습니다. 요청의 {{^optional}}
은 optional
이 false
일 때 태그 안에 있는 문자열을 표시한다는 뜻이고, 응답의 {{#optional}}
은 optional
이 true
일 때 태그 안에 있는 문자열을 표시한다는 뜻입니다.
이제 다시 테스트 코드를 실행해서 생성된 스니펫을 보면 커스터마이징한 형태로 작성된 것을 확인할 수 있습니다.
request-fields.adoc
response-fields.adoc
.snippet
파일은 Mustache라는 템플릿을 사용합니다.
스니펫을 합쳐서 API 문서로 만들자!
스니펫 생성까지 완료되었다면 이제 스니펫들을 토대로 완성된 API 문서가 작성되도록 설정해줘야 합니다. 빌드 환경에 따라 아래 경로에 API 문서가 될 .adoc
파일을 작성하면 해당 파일을 토대로 같은 이름을 가진 .html
파일이 생성됩니다.
빌드 환경 | 원본 파일 경로 | 생성 파일 경로 |
Gradle | src/docs/asciidoc/*.adoc | build/asciidoc/*.html |
Maven | src/main/asciidoc/*.adoc | target/generated-docs/*.html |
공식 문서에서는 Gradle 환경에서의 html 파일 생성 경로가 build/asciidoc/html5/*.html이라고 적혀있으나 실제로는 build/ascii/*.html에 생성되고 있었습니다.
예를 들어 api-docs.adoc
라는 파일을 작성한 후 그래들의 asciidoctor
명령을 실행하게 되면 자동으로 api-docs.html
이라는 파일이 생성됩니다.
지정된 경로에 .adoc
파일을 작성합니다.
:snippets: build/generated-snippets
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:sectlinks:
[[common]]
== API 명세서
=== 공통 정보
|===
| 환경 | Host
| 개발서버 | `https://dev-api.demo.com`
| 운영서버 | `https://api.demo.com`
|===
[[admin]]
=== 관리자용 API
[[member-list]]
==== 회원 목록 조회(관리자 전용)
===== Request Header
include::{snippets}/admin/members/request-headers.adoc[]
====== Request Example
include::{snippets}/admin/members/http-request.adoc[]
===== Response Body
include::{snippets}/admin/members/response-fields.adoc[]
====== Response Example
include::{snippets}/admin/members/http-response.adoc[]
1~6번째 줄은 문서의 속성을 정의하고 있습니다.
1번째 줄에서는 snippets
라는 속성을 정의하고 있습니다. 이 값은 테스트 코드를 실행해서 생성된 스니펫의 생성 경로인 build/generated-snippets
로 지정해줍니다.
2번째 줄에서는 생성되는 API 문서의 형식을 지정합니다. asciidoc 공식 문서 상에서는 article
, book
, manpage
, inline
을 지원한다고 하는데, inline
은 별도의 규칙이 적용된다고 합니다. 기본값은 article
입니다.
3번째 줄에서는 소스코드를 강조하기 위한 라이브러리를 지정하고 있습니다.
4번째 줄에서는 목차의 위치를 지정하고 있습니다. auto
, left
, right
중 하나를 사용할 수 있으며, auto
는 문서의 최상단에, left
와 right
는 각각 문서의 왼쪽과 오른쪽에 사이드바 형태로 목차가 나타납니다.
5번째 줄에서는 목차에 표시할 제목의 단계를 지정하고 있습니다. 위 예시에서는 3단계 제목까지 표시합니다.
6번째 줄에서는 각 섹션의 제목을 하이퍼링크로 설정하고 있습니다.
이후 include::
를 사용하여 테스트 코드를 실행해서 생성된 스니펫의 내용을 API 문서에 추가해주면!
이렇게 완성된 API 문서가 작성됩니다!
추가설정까지 진행했다면 이렇게 작성된 API 문서가 지정한 디렉토리로 복사된 것도 확인할 수 있으며, 빌드된 jar 파일을 실행하여 해당 경로로 이동했을 때 API 문서를 열어볼 수 있습니다.
마치며
Spring REST Docs는 테스트 코드를 기반으로 API 문서가 자동으로 작성된다는 점, 프로덕션 코드에 전혀 영향이 없다는 점으로 인해 API 문서가 필요할 때 선택하기 좋은 수단이라고 생각합니다.
하지만 정적인 API 문서이다 보니 스웨거와 다르게 직접 API를 실행해볼 수 없다는 점이나 여러가지 설정이 복잡하다는 점은 조금 아쉽습니다.
본문에서는 가장 기본적인 형태에 약간의 변화만 줘서 작성했지만 그 외에도 몇가지 커스터마이징이 가능합니다. 해당 내용은 추후 별도로 다루고자 합니다.
전체 코드는 Github(Kotlin, Java)에서 확인하실 수 있습니다.
참조 링크
'Development > Spring & Spring Boot' 카테고리의 다른 글
[Spring Scheduler] 서버 이중화 또는 증설 시 ShedLock을 사용하여 스케쥴러 중복 실행 방지하기 (2) | 2024.09.09 |
---|---|
[Spring/Spring Boot] 파일 다운로드와 multipart/form-data 업로드 컨트롤러 메소드 테스트 코드를 작성해보자 (0) | 2024.08.14 |
[Spring Boot] 스프링 인터셉터(Spring Interceptor)를 활용해서 API 로그를 DB에 기록해보자 (0) | 2024.03.01 |
[Spring Boot] AOP를 이용해서 DB에 API 로그를 남겨보자 (2) | 2024.02.06 |
[Spring Boot] @Value 대신 생성자 바인딩을 통해 프로퍼티 값을 바인딩하고 관리해보자 (0) | 2023.12.21 |
댓글