프로젝트에서 흩어진 문서 조각을 합치는 방법에 대해 학습 및 구현하며 작성한 글입니다. 학습 과정에서 작성된 글이기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적이나 피드백은 언제든 환영입니다.
1. 흩어진 API 문서조각
멑리모듈로 프로젝트에서 RestDocs를 채택해 API 문서화를 진행하고 있었습니다. RestDocs는 채택한 이유는 테스트 코드를 기반으로 API 문서를 생성하기 때문에 코드와 API 명세가 다를 일이 없고, 항상 문서의 최신 상태를 유지할 수 있기 때문입니다. 하지만 멀티 모듈로 프로젝트를 구성하다 보니 각 모듈에 API 문서가 흩어져서, 이를 한 곳에서 보기 힘든 문제가 발생했습니다.
이를 해결하기 위해서는 각 모듈의 흩어진 API 문서들을 한 곳에서 관리할 수 있어야 했습니다.
또한 각 모듈은 하나의 도메인, 혹은 배포 단위를 나타내기 때문에 전체 API 문서는 한곳에 모아서 관리하지만, 각 도메인 별로는 API 문서를 구분할 수 있어야 했습니다. 이를 어떻게 해결할까? 고민하다 이전 프로젝트에서 Swagger의 Tag 기능을 통해 도메인별로 API 문서를 구분했던 것이 떠올랐습니다. 해당 키워드로 해결책을 찾던 중 Swagger-UI는 yml/json 파일을 통해 UI를 만든다는 것과 RestDocs의 스니펫으로 부터 yml/json 파일을 추출할 수 있다는 것을 알게 되었습니다.
이렇게 되면 테스트 코드를 기반으로 API 스펙을 추출하고, 추출된 스펙으로부터 yml/json 파일을 생성해 Swagger와 연동하면 될 것 같았습니다. 찾다 보니 이미 이를 시도한 기업들이 있어서 어느 정도 확신을 가질 수 있었습니다.
- 내가 만든 API를 널리 알리기 - Spring REST Docs 가이드편
- MSA 환경에서 API 문서 관리하기: 생성부터 배포까지
지금까지 어떤 문제가 있었고, 어떤 요구사항을 충족시켜야 하는지 살펴보았습니다. 이를 정리해 보면 다음과 같습니다.
- 코드와 API 스펙의 갭이 없어야 한다.
- 각 모듈의 흩어진 API 문서를 한 곳에서 볼 수 있어야 한다.
- 문서는 한 곳에서 볼 수 있어야 하지만, 각 모듈별로 API 문서를 분리해서 볼 수 있어야 한다.
2. 해결
각 요구 사항을 해결하는 과정을 살펴보겠습니다. RestDocs를 기반으로 openapi3.yml/json을 추출하는 방법은 해당 레포지토리를 참조해 주세요.
2-1.코드와 API 스펙의 갭
코드와 API 스펙의 갭은 RestDocs를 사용하고 있었기 때문에 이미 해결된 상태였습니다. 테스트 코드 기반으로 API 문서가 생성되고 있었기 때문에 만들어진 API 문서가 실제 코드와 다를 일이 없기 때문입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@DisplayName("[DocumentationTest] 주문 상세조회 API 테스트")
class OrderDetailSearchDocumentationTest extends IntegrationTestBase {
@Test
@DisplayName("주문 조회가 성공하면 200 OK가 반환된다.")
void order_detail_search_test() {
Order newOrder = persistenceHelper.persist(createOrder(PAYMENT_REQUEST));
given(this.specification)
.contentType(APPLICATION_JSON)
.get("/api/orders/{orderId}", newOrder.getId())
.then()
.statusCode(equalTo(HttpStatus.OK.value()))
.body(notNullValue())
.log()
.all();
}
}
2-2.흩어진 API 문서 모으기
흩어진 API 문서는 Spring REST Docs API specification Integration와 openapi3를 통해 해결할 수 있습니다. Spring REST Docs API specification Integration은 Spring REST Docs의 결과물에 API Specification을 추가하는 extension으로 OpenAPI 2.0, OpenAPI 3.0.1 등의 API Specification을 지원합니다. 해당 라이브러리는 아래와 같은 생각을 가지고 시작됐는데, 즉 테스트 코드를 기반으로 API 스펙을 뽑아내는 것을 도와주기 위함입니다.
소스 코드를 보면 ResourceSnippet을 사용해 API 스펙을 yml/json으로 만드는 것을 볼 수 있습니다. ResourceSnippet는 Snippet을 상속하고 있습니다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class ResourceSnippet(private val resourceSnippetParameters: ResourceSnippetParameters) : Snippet {
private val objectMapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT)
private val propertyPlaceholderHelper = PropertyPlaceholderHelper("{", "}")
override fun document(operation: Operation) {
val context = operation
.attributes[RestDocumentationContext::class.java.name] as RestDocumentationContext
DescriptorValidator.validatePresentParameters(resourceSnippetParameters, operation)
val placeholderResolverFactory = RestDocumentationContextPlaceholderResolverFactory()
val model = createModel(operation, placeholderResolverFactory, context)
(
StandardWriterResolver(
placeholderResolverFactory, Charsets.UTF_8.name(),
JsonTemplateFormat
)
)
.resolve(operation.name, "resource", context)
.use { it.append(objectMapper.writeValueAsString(model)) }
}
private fun createModel(operation: Operation, placeholderResolverFactory: PlaceholderResolverFactory, context: RestDocumentationContext): ResourceModel {
val operationId = propertyPlaceholderHelper.replacePlaceholders(operation.name, placeholderResolverFactory.create(context))
val hasRequestBody = operation.request.contentAsString.isNotEmpty()
val hasResponseBody = operation.response.contentAsString.isNotEmpty()
val securityRequirements = SecurityRequirementsHandler().extractSecurityRequirements(operation)
val tags =
if (resourceSnippetParameters.tags.isEmpty())
Optional.ofNullable(getUriComponents(operation).pathSegments.firstOrNull())
.map { setOf(it) }
.orElse(emptySet())
else resourceSnippetParameters.tags
return ResourceModel(
operationId = operationId,
summary = resourceSnippetParameters.summary ?: resourceSnippetParameters.description,
description = resourceSnippetParameters.description ?: resourceSnippetParameters.summary,
privateResource = resourceSnippetParameters.privateResource,
deprecated = resourceSnippetParameters.deprecated,
tags = tags,
request = RequestModel(
path = getUriPath(operation),
method = operation.request.method.name(),
contentType = if (hasRequestBody) getContentTypeOrDefault(operation.request.headers) else null,
headers = resourceSnippetParameters.requestHeaders.withExampleValues(operation.request.headers),
pathParameters = resourceSnippetParameters.pathParameters.filter { !it.isIgnored },
queryParameters = resourceSnippetParameters.queryParameters.filter { !it.isIgnored },
formParameters = resourceSnippetParameters.formParameters.filter { !it.isIgnored },
schema = resourceSnippetParameters.requestSchema,
requestFields = if (hasRequestBody) resourceSnippetParameters.requestFields.filter { !it.isIgnored } else emptyList(),
example = if (hasRequestBody) operation.request.contentAsString else null,
securityRequirements = securityRequirements
),
response = ResponseModel(
status = operation.response.status.value(),
contentType = if (hasResponseBody) getContentTypeOrDefault(operation.response.headers) else null,
headers = resourceSnippetParameters.responseHeaders.withExampleValues(operation.response.headers),
schema = resourceSnippetParameters.responseSchema,
responseFields = if (hasResponseBody) resourceSnippetParameters.responseFields.filter { !it.isIgnored } else emptyList(),
example = if (hasResponseBody) operation.response.contentAsString else null
)
)
}
......
}
yml/json를 생성하기 위해 build 과정에 포함시키면, 즉 openapi3 Task를 build에 포함시키면 빌드 과정에서 각 모듈마다 다음과 같은 yml 또는 json 파일이 생성되며, 이렇게 생성된 yml/json을 Swagger로 보여주면 됩니다.
참고로 openapi3는 Documentations라는 Tasks에 포함 돼 있지만 Task가 아닌 extension입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
./gradlew swith-me-payment-api:tasks
......
Documentation tasks
-------------------
javadoc - Generates Javadoc API documentation for the main source code.
openapi - Aggregate resource fragments into an OpenAPI 2 specification
openapi3 - Aggregate resource fragments into an OpenAPI 3 specification
postman - Aggregate resource fragments into an OpenAPI 3 specification
......
위 과정을 다시 한번 살펴보면 build 과정에 openapi3 Task를 포함시키고 이를 통해 yml/json 파일을 생성하는 것입니다.
2-3. 각 도메인 별 문서 분리 및 통합
마지막으로 이렇게 추출된 API 스펙을 어떻게 보여줄지가 남았는데, 이는 각 모듈의 API 스펙을 한곳으로 모으고 Swagger에서 해당 저장소를 바라보도록 해주면 됩니다. Swagger에서는 yml/json으로 API 문서를 생성할 수 있기 때문입니다.
추출된 각 모듈의 API 문서는 S3와 같은 정적 파일 저장소를 사용해 모을 수 있는데, 정적 파일 저장소는 보통 /를 통해 폴더를 구분합니다. 이 특징을 활용하면 각 도메인마다 폴더를 생성해 도메인/모듈별로 문서를 구분할 수도 있습니다.
문서 저장 장소로는 S3 외에도 다양한 방법이 존재하며, 이는 배포 방식에도 영향을 받을 수 있습니다. 자신의 상황에 맞는 가장 적합한 방법을 채택하면 될 것 같습니다.
이는 아래와 같이 배포 과정에서 yml/json 파일을 정적 저장소로 업로드 하도록 구현할 수 있습니다.
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
32
# Gitaction
name: Upload file to S3
......
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
......
// 파일 복사
- name: Upload openapi3.json to S3
run: |
aws s3 cp ${MODULE_PATH} s3://$/${S3_PATH}
env:
AWS_ACCESS_KEY_ID: $
AWS_SECRET_ACCESS_KEY: $
AWS_REGION: ap-northeast-2
- name: Notify Build Status to Slack.
if: always()
uses: 8398a7/action-slack@v3
with:
status: $
fields: repo,commit,author,action,eventName,workflow,job,took
env:
SLACK_WEBHOOK_URL: $
마지막으로 문서 관리 서버에 도커만 띄워주면 되는데, 도커 파일은 다음과 같습니다. URL 부분에 정적 저장소의 위치(URL)를 기입해주면 됩니다.
1
2
3
4
5
6
7
8
9
version: "3.8"
services:
swagger:
image: swaggerapi/swagger-ui
environment:
- URLS=[{url:"{URL}", name:"{DOMAIN}"}]
ports:
- ${IN_PORT}:${OUT_PORT}
위 과정을 정리해보면 흩어진 API 문서를 정적 저장소에 모으고, 문서 관리 서버에서 이를 바라보게 만드는 것입니다.
이를 통해 한 곳에서 모든 API 문서를 관리하면서 Tag 기능을 통해 각 도메인을 구분할 수 있게 됩니다. 테스트 코드를 기반으로 API 문서를 만들기 때문에 코드와 문서 간의 갭이 발생할 일이 없습니다.
3. 한계점
문제는 해결됐지만 한계 또한 명확한데, 크게 Swagger와 RestDocs의 완벽하지 않은 호환, 여전히 존재하는 반복적인 문서화 코드, 문서화를 위한 추가 서버 필요 입니다.
3-1. 완벽하지 않은 호환
아직 Swagger와 RestDocs의 완벽한 호환을 지원하지 않기 때문에 Swagger에 RestDocs의 스펙을 반영하지 못하는 부분이 존재합니다. 예를 들어 POST 요청에서 RequestBody의 값을 Swagger에 반영할 수 없습니다. 이런 부분은 어느 정도 감수하거나, RestDocs를 제공해 상세 스펙을 명시해 줘야 합니다.
3-2. 여전히 존재하는 반복 코드
두 번째는 여전히 존재하는 반복 코드 입니다. API 명세를 openapi3.yml/json으로 추출하기 위해서는 RestDocs기반의 테스트 코드를 작성해야 하므로 RestDocs의 문서화를 위한 코드가 여전히 존재합니다. 이 부분은 KotlinDSL과 같은 도구를 사용해 코드량을 줄일 수 있지만, 마찬가지로 어느 정도 타협해야 하는 부분입니다.
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
32
33
34
interface ProductSnippet {
companion object {
private const val IDENTIFIER: String = "{class-name}/{method-name}"
private val PRODUCT_SEARCH_BY_ID_PATH_PARAMETER: ParameterDescriptorWithType =
ParameterDescriptorWithType.fromParameterDescriptor(
RequestDocumentation.parameterWithName("id")
.description("Product id.")
).type(SimpleType.NUMBER)
private val PRODUCT_SEARCH_BY_ID_RESPONSE_FIELD_DESCRIPTORS = listOf(
fieldWithPath("id").type(JsonFieldType.NUMBER)
.description("Product id."),
fieldWithPath("name").type(JsonFieldType.STRING)
.description("Product name.")
)
val PRODUCT_SEARCH_BY_ID_HANDLER = document(
IDENTIFIER,
snippets = arrayOf(
ResourceDocumentation.resource(
ResourceSnippetParameters.builder()
.summary("Product details searching.")
.description("API for searching product details by id.")
.tags("Product")
.pathParameters(PRODUCT_SEARCH_BY_ID_PATH_PARAMETER)
.responseFields(PRODUCT_SEARCH_BY_ID_RESPONSE_FIELD_DESCRIPTORS)
.build()
)
)
)
}
}
3-3. 추가 서버 구성
마지막 문제는 문서를 관리하기 위한 별도의 서버나 저장소가 필요하다는 점입니다. 결국 흩어진 문서 조각을 한 곳에서 관리하기 위해서는 이를 관리해 줄 서버가 필요하며, 이 과정에서 서버 비용이 증가하게 됩니다. 또한 해당 API 스펙이 외부에 공개되어도 되는 정보인지, 안 되는 정보인지에 따라 서버의 Inbound/Outbound에 대한 설정도 추가해야 하므로 관리 포인트도 증가합니다.
4. 정리
Swagger와 RestDocs를 조합해 여러 서버에 흩어진 API 문서를 한곳으로 모으는 방법에 대해 살펴보았습니다. 이를 통해 흩어진 API 문서 조각은 모을 수 있었지만, Swagger와 RestDocs가 완벽히 호환되지 않는다는 점, 별도의 문서 관리 서버가 필요하다는 점 등 한계가 명확합니다. 무작정 이를 도입하기보다 현재 서버가 단일 서버인지, 멀티 서버인지를 먼저 고려하며, API 문서를 관리할 추가 서버도 마련할 수 있을 때 이를 도입할 것을 추천합니다.