Spring 진영에서 API 문서화에 관심이 있다면 Swagger와 RestDocs를 한번쯤은 사용해봤거나 들어는 봤을 것이다.
이번 문서에서는 Swagger, RestDocs 이 두가지의 장점만을 모아 Kotlin DSL로 리팩토링한 과정에 대해서 소개합니다.
커스텀 DSL 미리보기



Swagger 라이브러리 문제점
Swagger 사용법에 대해 검색해 보면, 컨트롤러나 사용하는 요청DTO, 응답DTO에 애너테이션을 선언하는 방식을 주로 설명하는 것을 확인할 수 있었다.
이 방법은 간단하지만, 경험상 몇가지 문제가 있다는 것을 느낄 수 있었다.
- 컨트롤러나 DTO를 봤을 때, 수 많은 애너테이션으로 인해 가독성 저하가 발생한다.
- 수기 작성이다 보니 문서화에 필요한 타입이나 값들이 검증되지 않은 상태로 등록될 수 있다.
- 문서와 API 규격이 동일하다는 것이 보장되지 않는다.
이런 문제로 테스트 코드로 API를 문서화할 수 있게 도와주는 라이브러리인 RestDocs를 사용하기로 결정했다.
Swagger 라이브러리 대신 RestDocs 사용
RestDocs로 넘어오면서 스웨거의 문제점을 모두 해결할 수 있을 것이라고 생각했지만 그렇지 않았다.
RestDocs로 API 문서화를 하려면 다음과 같은 작업을 거쳐야 한다.
- RestDocs를 이용한 테스트 코드 작성
- 테스트 통과 후 스니펫이 생성됨
- adoc(asciidoc) 파일에 생성된 스니펫 경로를 지정한다.
- 별도의 작업(gradle task)을 거쳐 문서화된 html 파일을 얻을 수 있다.
RestDocs를 사용하면 신뢰할 수 있는 API 문서를 제공 받을 수 있지만, 스웨거 화면에 비하면 턱없이 부족하다고 느꼈다.
또한 개발자가 테스트 코드까지 마치고 adoc 파일을 작성/수정 해야하므로 상당히 귀찮은 작업이 될 수 있다.
OpenAPI Specification 변환
더 좋은 방법은 없을까 하고 구글링 도중.. 카카오페이의 기술 블로그에서 방법을 소개하고 있었다.
https://tech.kakaopay.com/post/openapi-documentation/
OpenAPI Specification을 이용한 더욱 효과적인 API 문서화 | 카카오페이 기술 블로그
사실상의 표준으로 발돋움 중인 OpenAPI Specification을 이용한 API 문서화 방법(Swagger와 Spring REST Docs의 장점을 합치는 방법)을 공유드립니다.
tech.kakaopay.com
기술 블로그 내용을 요약하자면
- 나와 같은 불만을 가지고 있다.
- RestDocs로 테스트 코드를 통과 시키면 OpenApi Sepicification(OAS) 규격 파일을 생성할 수 있다.
- 스웨거 의존성 없이 Swagger-UI standalone(HTML/CSS/JS 포함)을 사용하면 스웨거와 동일한 화면을 볼 수 있게된다.
요약하자면 테스트 코드로 신뢰할 수 있는 API 규격을 만들 수 있고, 생성된 OAS 규격으로 Swagger에서 예쁜(?) 화면을 이용할 수 있다.
꼭 Swagger가 아닌 OAS 규격을 지원하는 다른 도구를 사용해도 좋습니다.
반복적인 코드 개선
지금까지 방법도 좋았지만, API가 점점 추가되면서 테스트 코드를 작성하다보니 반복되는 코드들이 많아졌다.
중복을 줄이기 위해 공통 함수를 만들어도 RestDocs가 제공하는 함수를 사용하므로 가독성이 썩 좋지 않았다.
이 부분도 더 좋은 방법이 있을거라 생각하고 구글링을 해보니..
토스에서 RestDocs의 함수들을 Kotlin DSL로 래핑한 작업을 소개하고 있었다.
Kotlin으로 DSL 만들기: 반복적이고 지루한 REST Docs 벗어나기
토스페이먼츠에서는 API docs를 REST Docs를 사용해서 작성할 수 있도록 권장하고 있습니다. 이 글에서는 DSL을 통해서 반복적인 REST Docs 테스트 코드 작성을 줄일 수 있는 방법을 알아봅니다.
toss.tech
tosspayments-restdocs: 선언형 문서 작성 라이브러리
REST Docs 를 최소한의 코드로 작성하면서 변화에도 더 유연하게 대처할 수 있는 tosspayments-restdocs 라이브러리와, 라이브러리에 녹인 기술들을 소개합니다.
toss.tech
아쉽게도 토스 기술 블로그에서 소개하는 라이브러리는 사내에서만 제공하고 있는 듯 했다.
그래도 많은 영감을 얻었고, 우리 서비스에도 적용하면 좋을 것 같아 개발을 시작하게되었다.
커스텀 DSL 만들기
DSL을 개발하기 전 내가 사용할 문법을 미리 작성 해본다.
기획 화면이 나와야 API를 수월하게 만들 수 있듯이, DSL 청사진을 만들어놓고 개발하면 좀 더 정확하게 만들 수 있기 때문이다.
document {
tag = "샘플"
summary = "요약"
descrition = "설명"
pathVariables {
param("id", "샘플 ID")
}
requestHeaders {
header("X-Sample-Hader", "샘플 커스텀 헤더")
}
requestBodyWith<SampleRequest> {
field("name", "샘플 이름")
field("description", "샘플 설명", optional = true)
}
responseBodyWith<SampleResponse> {
field("id", "샘플 ID")
field("name", "샘플 이름")
field("description", "샘플 설명", optional = true)
}
}
이것만으로 API 문서화가 끝나도록 만드는 것이 목적이다.
Swagger, RestDocs를 그대로 사용했을 때와 비교해 너무 깔끔해 보인다. (API 규격은 억지로 만들었다.)
분석
DSL을 만들기 전 RestDocs에서 사용하는 개념을 알아야 한다.
바로 Snippet과 Descriptor 이다.
- Snippet: API 문서의 일부분을 의미한다.
- 요청 영역, 응답 영역, 파라미터 등
- Descriptor: 각 요소(요청 필드, 응답 필드, 파라미터, ..)에 대한 설명을 제공한다.
이 개념을 적용하여 커스텀 Snippet DSL을 만들어 보자.
커스텀 DSL을 만들기 전, 수신 객체 지정 람다에 대한 개념 이해가 필요할 것 같아 간단하게 설명하고자 한다.
Kotlin DSL의 이해도가 있다면 넘어가도 좋다.
수신 객체 지정 람다란
람다 함수의 수신 객체를 명시하여, 람다 내부에서 해당 수신 객체의 메서드와 속성을 간편하게 사용할 수 있도록 한다.
이를 통해 더 간결하고 직관적인 DSL을 작성할 수 있게 도와준다.
그래도 텍스트로는 설명이 어려우므로 예제를 통해 알아보자
class Person {
var name: String = ""
var age: Int = 0
fun walk(init: Walking.() -> Unit) {
val walking = Walking()
walking.init()
walking.walk("$name-$age")
}
}
class Walking {
var method: String = ""
fun walk(who: String) {
println("$who $method 걸어다닌다.")
}
}
fun createPerson(init: Person.() -> Unit): Person {
val person = Person()
person.init()
return person
}
fun main() {
createPerson {
name = "noose"
age = 30
walk {
method = "손으로"
}
}
}
createPerson 함수 후행 람다 부분에 Person 내부 필드, 함수에 접근하여 객체를 생성하는 것을 볼 수 있다.
실행하면 어떤 결과가 나올지 예상해 보길 바란다. 이 같은 원리로 나만의 DSL을 만들어 볼 수 있다.
다시 돌아와서
요청 및 응답 필드를 선언할 때 사용하는 BodySnippetDsl, 헤더를 선언하는 HeaderSnippetDsl, 그리고 경로 변수와 쿼리 파라미터를 선언하는 ParameterSnippetDsl을 만들어보자.
BodySnippetDsl
BodySnippetDsl은 요청과 응답의 필드를 선언하는 데 사용됩니다. 예를 들어, 아래와 같이 사용할 수 있습니다
requestBodyWith<Type> {
field("field 이름", "field 설명", optional = true)
...
}
responseBodyWith<List<Type>> {
pageable = true // 페이징 응답 필드 자동 추가
field("[].field 이름", "[].field 설명", optional = true)
...
}
위와 같이 field 함수로 선언할 수 있게 DSL을 만들었다.
@RestDocsMarker
class BodySnippetDsl : AbstractSnippetDsl() {
private val descriptors: MutableList<FieldDescriptor> = mutableListOf()
var pageable: Boolean = false
fun <T : Enum<T>> field(fieldName: String, description: String = "", optional: Boolean = false, constraint: KClass<T>, ignored: Boolean = false) {
val descriptor = fieldWithPath(fieldName).description(description)
.type("enum")
.attributes(key("enumValues").value(getConstraints(constraint)))
.attributes(key("itemsType").value("type"))
addOptions(descriptor, optional, getConstraintsText(constraint), ignored)
descriptors.add(descriptor)
}
// Descriptor를 선언하는 함수
fun field(fieldName: String, description: String = "", optional: Boolean = false, constraint: String = "", ignored: Boolean = false) {
val descriptor = fieldWithPath(fieldName).description(description) // Descriptor 생성
addOptions(descriptor, optional, constraint, ignored) // 입력받은 옵션을 Descriptor에 적용
descriptors.add(descriptor) // 컬렉션에 Descriptor를 저장한다.
}
// 생성된 Descriptor를 스니펫으로 만드는 함수
fun requestSnippet(): Snippet {
return requestFields(descriptors)
}
// 페이지 필드 Descriptor 리스트를 미리 선언한다.
companion object {
private val PAGE_FIELDS: List<FieldDescriptor> = listOf(
fieldWithPath("pageable.pageNumber").description("요청 페이지 번호"),
fieldWithPath("pageable.pageSize").description("요청 페이지 크기"),
fieldWithPath("pageable.sort.empty").description(""),
...
)
}
}
HeaderSnippetDsl
HeaderSnippetDsl은 요청 및 응답 헤더를 선언하는 데 사용됩니다. 다음과 같이 사용할 수 있습니다.
requestHeaders { // Snippet 선언
header("header 이름", "header 설명") // 첫번째 Descriptor 선언
header("header 이름", "header 설명") // 두번째 Descriptor 선언
}
responseHeaders {
header("header 이름", "header 설명")
...
}
ParameterSnippetDsl
ParameterSnippetDsl은 경로 변수와 쿼리 파라미터를 선언하는 데 사용됩니다.
pathVariables {
param("이름", "설명")
...
}
queryParams {
pageable = true // 페이징 요청 필드도 자동으로 추가될 수 있게 pageable 속성이 필요하다.
param("이름", "설명")
...
}
이렇게 크게 3개의 SnippetDSL 추가했다.
Snippet은 여러 Descriptor의 목록을 가질 수 있도록 내부 리스트를 선언하였고,
모든 Snippet 마다 각각의 Descriptor의 설명, Optional 여부, Ignored 여부를 설정할 수 있게 만들어야 했다.
이는 중복이므로 AbstractSnippetDsl 만들고 각각의 SnippetDSL이 상속받아 처리하였다.
마지막으로 위 여러 DSL을 통해 각 Snippet을 생성하고 관리하는 RestDocsDsl을 구현했다.
@DslMarker
annotation class RestDocsMarker
@RestDocsMarker
class RestDocsDsl {
var tag: String? = null
var summary: String? = null
var description: String? = null
var deprecated: Boolean = false
var requestTypeString: String? = null
var responseTypeString: String? = null
private val snippets: MutableList<Snippet> = mutableListOf()
fun pathVariables(init: ParameterSnippetDsl.() -> Unit) {
val parameters = ParameterSnippetDsl()
parameters.init()
snippets.add(parameters.pathParameterSnippet()) // 스니펫 저장
}
fun queryParams(init: ParameterSnippetDsl.() -> Unit) {
val parameters = ParameterSnippetDsl()
parameters.init()
snippets.add(parameters.queryParameterSnippet()) // 스니펫 저장
}
fun requestParts(init: PartSnippetDsl.() -> Unit) {
val part = PartSnippetDsl()
part.init()
snippets.add(part.snippet()) // 스니펫 저장
}
inline fun <reified T> requestBodyWith(init: BodySnippetDsl.() -> Unit) {
val body = BodySnippetDsl()
body.init()
requestTypeString = simplifyType(typeOf<T>())
addSnippet(body.requestSnippet()) // 스니펫 저장
}
fun requestHeaders(init: HeaderSnippetDsl.() -> Unit) {
val header = HeaderSnippetDsl()
header.init()
snippets.add(header.requestHeaderSnippet()) // 스니펫 저장
}
fun responseHeaders(init: HeaderSnippetDsl.() -> Unit) {
val header = HeaderSnippetDsl()
header.init()
snippets.add(header.responseHeaderSnippet()) // 스니펫 저장
}
inline fun <reified T> responseBodyWith(init: BodySnippetDsl.() -> Unit) {
val body = BodySnippetDsl()
body.init()
responseTypeString = simplifyType(typeOf<T>())
addSnippet(body.responseSnippet()) // 스니펫 저장
}
fun simplifyType(type: KType): String {
return when (val classifier = type.classifier) {
is KClass<*> -> {
val typeName = classifier.simpleName
val arguments = type.arguments
if (arguments.isNotEmpty()) {
val argumentTypes = arguments.joinToString(", ") { arg ->
arg.type?.let { simplifyType(it) } ?: "*"
}
"$typeName<$argumentTypes>"
} else {
typeName ?: "Unknown"
}
}
else -> "Unknown"
}
}
fun addSnippet(snippet: Snippet) {
snippets.add(snippet)
}
fun handler(previousHandler: ResultHandler): ResultHandler {
if (previousHandler is BearerHeaderExtractor && previousHandler.hasBearer()) {
val authHeader = HeaderDocumentation.headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer 토큰")
snippets.add(HeaderDocumentation.requestHeaders(authHeader))
}
return MockMvcRestDocumentationWrapper.document(
identifier = "{class-name}/{method-name}",
resourceDetails = makeResourceDetails(),
snippets = snippets.toTypedArray() // 저장된 스니펫 리스트 적용
)
}
private fun makeResourceDetails(): ResourceSnippetDetails {
val resourceDetails = MockMvcRestDocumentationWrapper.resourceDetails()
.tag(tag ?: "")
.summary(summary)
.description(description ?: summary)
requestTypeString?.let { type -> resourceDetails.requestSchema(schema(type)) }
responseTypeString?.let { type -> resourceDetails.responseSchema(schema(type)) }
if (deprecated) {
resourceDetails.deprecated(true)
}
return resourceDetails
}
}
MockMvc 결합
MockMvc에는 테스트 중간에 추가적인 작업을 할 수 있도록 도와주는 andDo 메소드를 제공한다.
andDo 함수 내부에 Action 핸들러를 넣어줄 수 있었지만, 이것또한 반복적인 코드가 될 수 있다고 생각하여
andDocument라는 확장함수를 만들었다.
fun ResultActionsDsl.andDocument(init: RestDocsDsl.() -> Unit) {
val restDocsDsl = RestDocsDsl()
restDocsDsl.init()
val extractor = BearerHeaderExtractor() // Bearer 토큰 추출기
andDo { handle(extractor) }
andDo { handle(restDocsDsl.handler(extractor)) }
}
추가로 테스트 코드에서 Bearer 토큰을 식별하여 인증 헤더를 자동으로 선언하게 만들어 볼 수 있다.
최종 결과

이렇게 컨트롤러 테스트 코드와 함께 신뢰할 수 있는 API 문서를 간편하게 만들 수 있게 되었습니다.
게다가
- MockMvc와 결합하여 문서화 작업을 자동화
- 블록 내 자동완성이 지원되어 함수명을 외우지 않아도 작성이 가능
- Bearer 토큰을 포함한 헤더 스니펫이 자동으로 추가됩니다.
- 요청/응답이 페이지 대상인지
- deprecated API 인지
이런 기능과 표현들을 DSL 내에서 해결할 수 있어 좋았습니다.
API 문서화를 위한 DSL 구현에 대한 더 자세한 코드는 레포지토리에서 확인할 수 있습니다.
비교
이전
mockMvc.perform(
get("/api/...")
.characterEncoding("utf-8"))
.andExpect(status().isOk())
.andDo(
document(
resourceDetails()
.summary("계약 목록 조회")
.description("계약 목록 조회")
.tag("계약"),
pageRequestParams(
parameterWithName("name").description("계약 이름").optional(),
parameterWithName("no").description("계약 번호").optional(),
parameterWithName("status").description("계약 상태").optional()
.attributes(constraintsAttribute(ContractStatus::class.java)),
parameterWithName("type").description("계약 타입").optional()
.attributes(constraintsAttribute(ContractType::class.java)),
parameterWithName("detailType").description("계약 상세 타입").optional()
.attributes(constraintsAttribute(ContractDetailType::class.java)),
...
),
pageResponseFields(
fieldWithPath("content[].status").description("계약 상태"),
fieldWithPath("content[].id").description("계약 ID"),
fieldWithPath("content[].basicInfo.name").description("계약 이름"),
...
)
)
)
이후
mockMvc.GET("/api/...") {
bearerToken()
}.andExpect {
status { isOk() }
}.andDocument {
tag = "계약"
summary = "계약 목록 조회"
description = "계약 목록 조회"
queryParams {
pageable = true
param("name", "계약 이름", optional = true)
param("no", "계약 번호", optional = true)
param("status", "계약 상태", optional = true, constraint = ContractStatus::class)
param("type", "계약 타입", optional = true, constraint = ContractType::class)
param("detailType", "계약 상세 타입", optional = true, constraint = ContractDetailType::class)
}
responseBodyWith<Page<ContractSimpleResponse>> {
pageable = true
field("content[].status", "계약 상태")
field("content[].id", "계약 ID")
field("content[].basicInfo.name", "계약 이름")
field("content[].basicInfo.no", "계약 번호")
}
}
어려웠던 점
MockMvc에서 DSL 사용 시 문서화 실패하는 문제
위 방식을 참고하여 확장함수를 만들었다.
spring-rest-docs-kotlin/mockmvc/src/main/kotlin/com/ninjasquad/springrestdocskotlin/mockmvc/MockMvcDsl.kt at master · Ninja-Squ
A Spring-REST-Docs Kotlin DSL . Contribute to Ninja-Squad/spring-rest-docs-kotlin development by creating an account on GitHub.
github.com
스키마 객체 이름 정의
API 문서화에서 스키마 이름을 명확히 지정하지 않으면 문서에 랜덤한 이름이 출력된다.
그렇다고 해서 스키마 이름을 직접 타이핑하기에는 귀찮고 실수할 수 있는 문제가 있다.
코틀린에서는 reified 키워드를 통해 제네릭 타입을 런타임에 참조할 수 있는데 이 부분을 활용하여 객체 타입을 넘기면 스키마 이름을 생성하도록 하여 문제를 해결했다.