일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- 클린코드
- 자바
- 코딩
- 알고리즘
- 백준
- JPA
- API
- 코드
- 코딩테스트
- Spring
- 애자일프로그래밍
- 읽기쉬운코드
- framework
- 스프링
- 그리디
- cleancode
- 그리디알고리즘
- 데이터베이스
- 엘라스틱서치
- 프레임워크
- 개발
- kotlin
- ES
- database
- spring boot
- Java
- 애자일기법
- Elasticsearch
- 개발자
- Baekjoon
- Today
- Total
튼튼발자 개발 성장기🏋️
kotlin + spring boot + kotest + rest docs + swagger 본문
일반적으로 Swagger는 실질적인 로직에 침투적으로써 코드가 지저분해지며 문서의 신뢰성이 저하되는 등 단점이 너무나도 많다. 하지만 Spring Restdocs는 테스트 코드로부터 문서화 하기 때문에 swagger의 단점을 보완하며 정적 문서 형태로 풍부하게 작성할 수 있는 등 swagger보다 더 널리 사용된다.
kopring에서 Spring restdocs의 장점만을 이끌어내면서 swagger까지 함께 사용할 수 있는 방법을 알아본다.
Hint: Spring restdocs와 swagger는 모두 Springdoc OpenAPI를 통해 사용된다.
1. build.gradle.kts 작성
가장 먼저 OpenAPI를 사용하기 위한 기틀을 마련해보자. 플러그인과 종속성 및 Task 설정부터 시작한다.
import groovy.lang.Closure
import io.swagger.v3.oas.models.servers.Server
import org.hidetake.gradle.swagger.generator.GenerateSwaggerUI
plugins { // (1)
// ...
id("org.asciidoctor.jvm.convert") version "3.3.2"
id("com.epages.restdocs-api-spec") version "0.19.4"
id("org.hidetake.swagger.generator") version "2.19.2"
}
// ...
swaggerSources { // (2)
create("swaggerSource") {
setInputFile(file("${openapi3.outputDirectory}/openapi3.yaml"))
}
}
val snippetsDir = file("build/generated-snippets")
val asciidoctorExt: Configuration by configurations.creating
// ...
dependencies {
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework:spring-test")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest:kotest-framework-datatest:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("com.ninja-squad:springmockk:4.0.2")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("com.epages:restdocs-api-spec:0.19.4")
testImplementation("com.epages:restdocs-api-spec-mockmvc:0.19.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
asciidoctorExt("org.springframework.restdocs:spring-restdocs-asciidoctor")
swaggerUI("org.webjars:swagger-ui:5.22.0")
}
openapi3 { // (3)
val local = closureOf<Server> {
url("http://localhost:8080")
description("Local Server")
} as Closure<Server>
val dev = closureOf<Server> {
url("https://{도메인}:{포트}")
description("dev Server")
} as Closure<Server>
// ...
setServers(listOf(local, dev))
title = "$project_name private API"
description = "$project_name api docs"
version = "1.0.0"
format = "yaml"
outputDirectory = openapi3.outputDirectory
}
tasks { // (4)
test {
useJUnitPlatform()
outputs.dir(snippetsDir)
}
asciidoctor {
dependsOn(test)
inputs.dir(snippetsDir)
configurations(asciidoctorExt.name)
baseDirFollowsSourceFile()
}
withType<GenerateSwaggerUI> {
dependsOn("openapi3")
doFirst {
val swaggerUIFile = file("${openapi3.outputDirectory}/openapi3.yaml")
val securitySchemesContent = """
securitySchemes:
Authorization:
type: apiKey
name: Authorization
scheme: bearer
bearerFormat: JWT
in: header
security:
- Authorization: []
""".trimIndent()
swaggerUIFile.appendText(securitySchemesContent)
}
}
build {
dependsOn(asciidoctor, generateSwaggerUI)
doFirst {
delete("src/main/resources/static")
}
doLast {
copy {
from("build/docs/asciidoc")
into("src/main/resources/static/docs")
}
copy {
from("build/swagger-ui-swaggerSource")
into("src/main/resources/static/docs/swagger")
}
}
}
}
(1) Plugins
플러그인 | 설명 | 추가한 이유 |
Asciidoctor | AsciiDoc 형식으로 작성된 문서를 HTML, PDF 등 다양한 형식으로 변환하는 도구 | 개발 문서나 API 문서를 AsciiDoc으로 작성한 이후, 이를 쉽게 변환하여 배포 가능한 형태로 만들기 위해 사용 |
Restdocs API Spec | Spring Restdocs와 결합하여 API 명세를 자동으로 생성 | API의 스펙을 자동으로 문서화할 수 있어 문서를 일관성 있게 유지 |
Swagger Generator | Swagger 파일(.yaml 또는 .json)을 기반으로 클라이언트 코드나 서버 스텁을 자동 생성 | Swagger 스펙을 기반으로 개발 초기 단계에서 API의 사용 예제를 쉽게 생성하고 클라이언트 및 서버 양쪽의 코드를 생성 |
(2) swaggerSources
Swagger Generator 플러그인을 사용할 때 Swagger 또는 OpenAPI 명세 파일을 기반으로 소스 코드를 생성할 때 정의한다.
즉 우리는 Swagger 소스를 설정한다는 이야기다. 이 설정은 여러 개의 소스를 한 번에 정의할 수 있고 각각의 소스로부터 클라이언트 코드나 서버 스텁 등을 생성할 수 있다.
여기서는 실제 사용될 OpenAPI 명세 파일의 경로를 설정한다.
${openapi3.outputDirectory}는 OpenAPI 명세 파일이 생성된 기본 출력 디렉토리이고 그 안의 openapi3.yaml 파일을 사용하겠다는 의미다.
(3) openapi3
OpenAPI 명세 파일을 생성하기 위한 설정이다.
코드를 보면 local과 dev에 대한 서버 정보를 정의한다.
[그림 1]과 같이 Swagger UI 내 타겟 서버들의 정보가 될거다.
그 외 나머지 설정은 아래와 같다.
title: API의 제목
description: API 문서의 설명
version: API의 버전
format: 생성될 명세 파일의 형식
outputDirectory: 명세 파일이 생성될 디렉토리 경로
(4) Tasks
1. asciidoctor Task
dependsOn(test): test 작업이 완료된 후에 실행한다.
inputs.dir(snippetsDir): Restdocs 스니펫을 Asciidoctor 입력으로 사용한다.
configurations(): Asciidoctor 확장을 위한 설정을 지정한다.
baseDirFollowsSourceFile(): 문서의 기본 경로를 소스 파일 경로로 설정한다.
2. generateSwaggerUI Task
dependsOn("openapi3"): openapi3 작업이 완료된 후에 실행된다.
doFirst {}: 작업이 시작되기 전에 실행할 동작을 정의한다.
swaggerUIFile: OpenAPI 명세 파일의 경로를 지정한다. ((2)와 중복)
securitySchemesContent: 보안 스키마를 정의하는 yaml 내용이다. api를 사용하기 위한 인증 방식 OAuth2 등을 설정할 수 있다.
appendText(): openapi3.yaml 파일 끝에 추가한다.
3. build Task
dependsOn(asciidoctor, generateSwaggerUI): asciidoctor, generateSwaggerUI Task가 완료된 후에 실행된다.
doFirst { }: 실행 전에 src/main/resources/static 디렉토리를 삭제한다.
doLast { }: 실행 후에 Asciidoctor와 Swagger UI 결과물을 복사한다.
위 Tasks를 보았을 때 아래 command를 실행하면 [그림 2]와 같이 문서화가 잘 이루어졌는지 알 수 있다.
gradle clean build
2. Kotest 테스트 코드 작성
앞서 Spring Restdocs는 테스트 코드 기반으로 문서화를 한다고 했다. 이제 문서화를 위해서 테스틑 코드를 작성한다. 단, 나의 경우에는 Kotest를 사용해서 테스트 코드를 작성하지만 다른 방식으로 작성해도 좋다.
문서화에 사용되는 queryparams, request body, request header, response body, response header 등에 대한 정의는 kotlin의 infix 함수를 사용해서 가독성을 높였다.
2.1 QueryParams
class RestDocsParam(
val descriptor: ParameterDescriptor
) {
private var default: String
get() = descriptor.attributes["default"] as String
set(value) {
descriptor.attributes["default"] = value
}
infix fun pathMeans(description: String): RestDocsParam {
descriptor.description(description)
return this
}
}
infix fun String.isRequired(isRequired: Boolean): RestDocsParam {
return createField(this, isRequired)
}
private fun createField(
value: String,
isRequired: Boolean = false
): RestDocsParam {
val descriptor = RequestDocumentation
.parameterWithName(value)
if (!isRequired) descriptor.optional()
return RestDocsParam(descriptor)
}
fun pathParameters(vararg params: RestDocsParam): PathParametersSnippet {
return RequestDocumentation.pathParameters(params.map { it.descriptor })
}
fun queryParameters(vararg params: RestDocsParam): QueryParametersSnippet {
return RequestDocumentation.queryParameters(params.map { it.descriptor })
}
2.2 Headers
class RestDocsHeader(
val descriptor: HeaderDescriptor
)
infix fun String.headerMeans(
description: String
): RestDocsHeader {
return createField(this, description)
}
private fun createField(
value: String,
description: String,
optional: Boolean = false
): RestDocsHeader {
val descriptor = HeaderDocumentation
.headerWithName(value)
.description(description)
if (optional) descriptor.optional()
return RestDocsHeader(descriptor)
}
fun requestHeaders(vararg params: RestDocsHeader): RequestHeadersSnippet {
return HeaderDocumentation.requestHeaders(params.map { it.descriptor })
}
2.3 Body
class RestDocsField(
val descriptor: FieldDescriptor
) {
private var default: String
get() = descriptor.attributes["default"] as String
set(value) {
descriptor.attributes["default"] = value
}
private var example: String
get() = descriptor.attributes["example"] as String
set(value) {
descriptor.attributes["example"] = value
}
infix fun means(value: String): RestDocsField {
descriptor.description(value)
return this
}
infix fun attributes(block: RestDocsField.() -> Unit): RestDocsField {
block()
return this
}
infix fun withDefaultValue(value: String): RestDocsField {
this.default = value
return this
}
infix fun example(value: String): RestDocsField {
this.example = value
return this
}
infix fun isRequired(value: Boolean): RestDocsField {
val att: Attribute = Attribute("required", value)
descriptor.attributes(att)
return this
}
infix fun isIgnored(value: Boolean): RestDocsField {
if (value) descriptor.ignored()
return this
}
}
infix fun String.type(
docsFieldType: DocsFieldType
): RestDocsField {
return createField(this, docsFieldType.type)
}
private fun createField(
value: String,
type: JsonFieldType,
optional: Boolean = true
): RestDocsField {
val descriptor = PayloadDocumentation.fieldWithPath(value)
.type(type)
.description("")
if (optional) descriptor.optional()
return RestDocsField(descriptor)
}
fun requestBody(vararg fields: RestDocsField): RequestFieldsSnippet {
return PayloadDocumentation.requestFields(fields.map { it.descriptor })
}
fun responseBody(vararg fields: RestDocsField): ResponseFieldsSnippet {
return PayloadDocumentation.responseFields(fields.map { it.descriptor })
}
여기까지 코드를 본다면 우리는 테스트 코드를 작성할 때 아래와 같이 문서화 코드를 작성할 수 있다는 것을 알아 차릴수 있다!
Spring Restdocs의 단점 중 하나가 중복된 코드가 많아서 읽기 불편하다는 점이 있는데 위와 같이 kotlin의 infix 함수를 활용하면 보완할 수 있다.
pathParameters(
"Id" isRequired true pathMeans "식별자"
// ...
),
responseBody(
"id" type NUMBER means "식별자" isRequired true,
// ...
)
나의 사이드 프로젝트에서 간단한 예제를 가져와보았다.
describe("PUT : $baseUrl") {
val url = baseUrl
val requestBody = ModifyUserRequest(
profileImageId = 0,
instagramId = "instagramId",
)
val user = User(
userId = 0,
socialId = "socialId",
providerType = ProviderType.KAKAO,
roleType = RoleType.ADMIN,
userName = "userName",
crewId = 0,
crewName = "crewName",
instagramId = "instagramId",
imageId = 0,
profileImageUri = "profileImageUri",
lastLoginAt = LocalDateTime.now(),
isAdmin = true,
isManager = false,
yellowCardQty = 1,
isInstagramIdPublic = true,
)
context("정보를 수정 하면") {
val requestJson = toJson(requestBody)
val request = request(HttpMethod.PUT, url)
.contentType(APPLICATION_JSON)
.content(requestJson)
it("201 Created") {
every { proxy.modify(any(), any<ModifyUserRequest>()) } returns user
mockMvc.perform(request)
.andExpect(status().isOk)
.andDocument(
"modify-users",
requestBody(
"profileImageId" type NUMBER means "프로필 이미지 식별자" isRequired false,
"instagramId" type STRING means "인스타그램 아이디" isRequired false
),
responseBody(
"userName" type STRING means "유저명" isRequired false,
"crewName" type STRING means "소속 크루명" isRequired false,
"instagramId" type STRING means "인스타그램 아이디" isRequired false,
"profileImageUri" type STRING means "프로필 이미지 uri" isRequired false
)
)
}
}
}
이렇게 플러그인을 잘 사용하면 테스트 코드만으로도 Restdocs와 swagger를 동시에 사용할 수 있다. 개인적으로 Restdocs만으로도 충분하다고 생각하는데, 프론트 개발자와 협업을 할 때 일회성으로 테스트하고 싶어하는 분들이 꽤 많이 봐왔다. 그래서 이번 사이드 프로젝트에서 "두 문서 모두 OpenAPI 기반일텐데 방법이 있지 않을까"하며 고민을 하다가 찾아낸 방법이다.
3. 동작 원리
- Restdocs‑api‑spec 플러그인이 테스트에서 생성된 Restdocs 스니펫(JSON)을 읽어 OpenAPI 명세(YAML/JSON)로 변환
- Hidetake Swagger Generator가 OpenAPI 파일로부터 Swagger UI 정적 HTML/CSS/JS 페이지를 생성
- asciidoctor는 스니펫을 기반으로 human-readable 문서(HTML, PDF 등) 생성
- build 단계에서 생성물들을 함께 정적 리소스로 패키징
즉, [스니펫 테스트] > [API 명세] > [문서/UI]까지 자동화된 CI/CD 파이프라인 흐름을 구축할 수 있다.
'Framework > spring' 카테고리의 다른 글
spring boot + docker + ssl cert (2) | 2025.06.07 |
---|---|
part2. 동시성 이슈의 해결 방법 (2+1)가지 (0) | 2023.09.06 |
part1. 동시성 이슈의 해결 방법 (2+1)가지 (1) | 2023.09.05 |
배치 처리 테스트하기 (1) | 2022.09.22 |
클라우드 네이티브 배치 (0) | 2022.09.21 |