
본 포스팅은 Kotlin+Spring 환경에서 Spring REST Docs를 편리하게 활용하기 위해 직접 개발한 오픈소스와 관련된 내용을 담고 있습니다. 오픈소스의 소스코드는 다음 링크에서 확인하실 수 있습니다.
Spring Framework를 활용할 때 API 명세에 대하여 Swagger를 활용하는 것이 좋을지, Spring REST Docs를 활용하는 것이 좋을지 고민하던 때가 있었다. 필자는 현재 회사에서는 팀의 의견에 따라 Swagger를 활용하고 있는데, 개인적으로는 Spring REST Docs 쪽에 손을 들고 있다.
Swagger 편에서 이야기 하면 비교적 이쁜(?) 디자인과 curl call 테스트 지원을 장점으로 뽑을 수 있을 것 같다. 하지만 Swagger 어노테이션이 비즈니스 코드에 침투한다는 치명적인 단점을 갖고 있다.
반면 Spring REST Docs는 비즈니스 코드에 아무런 영향을 주지 않고, 테스트를 강제하면서 테스트 실패를 통해 API 인터페이스 변경을 감지해준다. 필자가 Restdocs를 선호하는 이유도 위와 같은데, 만약 Swagger가 제공하는 디자인과 기능이 마음에 든다면 Restdocs로 명세를 하고, Swagger로 변환시켜주면 두 마리 토끼를 모두 잡을 수 있다.
. . .
2025년에는 오픈소스를 하나 개발해서 배포해보자는 목표를 세웠다. 어떤 주제가 좋을지 고민하던 중 API 명세에 대해, 더 나아가 Restdocs에 대해 관심을 갖게 되었다. Restdocs는 정말 좋은 오픈소스지만, 코드 반복으로 인해 생산성이 떨어지고, 가독성 또한 좋지 않다는 아쉬움도 품고 있다. 나는 이러한 문제를 해결하고 싶었고, 이전에 Kotlin DSL을 통해 보다 편하게 활용했던 기억을 떠올렸다.
그리고 Spring REST Docs에 올라온 이슈 중 관련된 것이 있는지 검토해보았다. 다음은 관련된 이슈 목록이다.
그 결과, 검토는 이루어졌으나 확장된 라이브러리에서 개발하는 쪽으로 의견이 기울어지고 있음을 확인했다. (아닐 수도 있음) 따라서 Kotlin DSL을 활용하여 Spring REST Docs의 단점을 지워보자는 취지로 오픈소스 개발을 시작하였고, 본격적인 개발에 앞서 Spring REST Docs가 어떤 식으로 API를 명세하는지 파헤쳐보기로 했다.
. . .
mockMvc.perform(get(API_URL))
.andExpect(status().isOk())
.andDo(document(INDENTIFIER),
// 생략
);
. . .
일반적으로 Spring REST Docs를 활용할 때 위와 같이 코드가 작성될 것이다. 따라서 우리는 MockMvc부터 살펴볼 필요가 있다.
(MockMvc 말고도 RestAssured, WebTestClient 등을 활용할 수도 있지만 MockMvc를 활용하는 것을 MVP 모델로 설정하고 시작하였기에 이 부분을 먼저 살펴보았고, 다른 도구를 활용해도 비슷하게 플로우로 동작하는 것을 확인했다.)
public final class MockMvc {
. . .
public ResultActions perform(RequestBuilder requestBuilder) throws Exception {
// 생략
return new ResultActions() {
public ResultActions andExpect(ResultMatcher matcher) throws Exception {
matcher.match(mvcResult);
return this;
}
public ResultActions andDo(ResultHandler handler) throws Exception {
handler.handle(mvcResult);
return this;
}
public MvcResult andReturn() {
return mvcResult;
}
};
}
. . .
}
MockMvc의 perform 메서드 안에는 RequestBuilder를 buildRequest()하고, Response를 초기화하는 등의 로직을 담고 있지만, 여기서는 ResultActions 인터페이스를 익명 클래스로 생성하고 있다.
public interface ResultActions {
. . .
ResultActions andDo(ResultHandler handler) throws Exception;
. . .
}
MockMvc의 perform() 메서드가 리턴하는 ResultActions에서 andDo()를 주의깊게 봐야한다. andDo는 ResultHandler를 인자로 받는 메서드이고, 이를 통해 Rest Docs 내부에 ResultHandler 인터페이스의 구현체가 있을 것이라 예상해 볼 수 있다.
그럼 이제, Rest Docs 내부를 들여다보자.
MockMvcRestDocumentation의 document()는 ResultActions의 andDo() 메서드의 인자로 활용된다. 이 말은 즉, document()를 통해 Rest Docs의 ResultHandler 구현체가 전달된다는 것이다.
public abstract class MockMvcRestDocumentation {
. . .
public static RestDocumentationResultHandler document(String identifier, Snippet... snippets) {
return new RestDocumentationResultHandler(
new RestDocumentationGenerator<>(identifier, REQUEST_CONVERTER, RESPONSE_CONVERTER, snippets));
}
public static RestDocumentationResultHandler document(String identifier,
OperationRequestPreprocessor requestPreprocessor, Snippet... snippets) {
return new RestDocumentationResultHandler(new RestDocumentationGenerator<>(identifier, REQUEST_CONVERTER,
RESPONSE_CONVERTER, requestPreprocessor, snippets));
}
public static RestDocumentationResultHandler document(String identifier,
OperationResponsePreprocessor responsePreprocessor, Snippet... snippets) {
return new RestDocumentationResultHandler(new RestDocumentationGenerator<>(identifier, REQUEST_CONVERTER,
RESPONSE_CONVERTER, responsePreprocessor, snippets));
}
public static RestDocumentationResultHandler document(String identifier,
OperationRequestPreprocessor requestPreprocessor, OperationResponsePreprocessor responsePreprocessor,
Snippet... snippets) {
return new RestDocumentationResultHandler(new RestDocumentationGenerator<>(identifier, REQUEST_CONVERTER,
RESPONSE_CONVERTER, requestPreprocessor, responsePreprocessor, snippets));
}
}
내부 코드를 보면 그 핸들러의 이름은 RestDocumentationResultHandler이고, RestDocumentationResultHandler 객체를 생성하여 전달하고 있음을 확인할 수 있다.
public class RestDocumentationResultHandler implements ResultHandler {
. . .
private final RestDocumentationGenerator<MockHttpServletRequest, MockHttpServletResponse> delegate;
. . .
@Override
public void handle(MvcResult result) {
this.delegate.handle(result.getRequest(), result.getResponse(), retrieveConfiguration(result));
}
. . .
}
andDo() 메서드에서는 ResultHandler의 handle() 메서드를 호출하는데, 이는 곧, RestDocumentationResultHandler의 RestDocumentationGenerator의 handle 메서드를 호출하는 것이다.
public final class RestDocumentationGenerator<REQ, RESP> {
. . .
public void handle(REQ request, RESP response, Map<String, Object> configuration) {
Map<String, Object> attributes = new HashMap<>(configuration);
OperationRequest operationRequest = preprocessRequest(this.requestConverter.convert(request), attributes);
OperationResponse operationResponse = preprocessResponse(this.responseConverter.convert(response), attributes);
Operation operation = new StandardOperation(this.identifier, operationRequest, operationResponse, attributes);
try {
for (Snippet snippet : getSnippets(attributes)) {
snippet.document(operation);
}
}
catch (IOException ex) {
throw new RestDocumentationGenerationException(ex);
}
}
. . .
}
RestDocumentationGenerator의 handle() 메서드는 요청과 응답을 통해 .snippet 문서 생성에 필요한 데이터를 담고있는 Operation 객체를 생성한 후, Snippet 인터페이스의 document() 메서드를 호출하여 문서를 생성한다.
public interface Snippet {
void document(Operation operation) throws IOException;
}
public abstract class TemplatedSnippet implements Snippet {
. . .
@Override
public void document(Operation operation) throws IOException {
RestDocumentationContext context = (RestDocumentationContext) operation.getAttributes()
.get(RestDocumentationContext.class.getName());
WriterResolver writerResolver = (WriterResolver) operation.getAttributes().get(WriterResolver.class.getName());
try (Writer writer = writerResolver.resolve(operation.getName(), this.snippetName, context)) {
Map<String, Object> model = createModel(operation);
model.putAll(this.attributes);
TemplateEngine templateEngine = (TemplateEngine) operation.getAttributes()
.get(TemplateEngine.class.getName());
writer.append(templateEngine.compileTemplate(this.templateName).render(model));
}
}
. . .
}
Snippet 인터페이스의 구현체인 TemplatedSnippet 내부를 보자. document() 메서드에서는 operation을 통해 model을 생성하고, templateEngine.compileTemplate(this.templateName).render(model)을 통해 .snippet 파일을 생성한다.
public interface TemplateEngine {
Template compileTemplate(String path) throws IOException;
}
public class MustacheTemplateEngine implements TemplateEngine {
private final TemplateResourceResolver templateResourceResolver;
. . .
@Override
public Template compileTemplate(String name) throws IOException {
Resource templateResource = this.templateResourceResolver.resolveTemplateResource(name);
return new MustacheTemplate(
this.compiler.compile(new InputStreamReader(templateResource.getInputStream(), this.templateEncoding)),
this.context);
}
. . .
}
위에서 사용된 TemplateEngine과 구현체인 MustacheTemplateEngine 이다. TemplateResourceResolver를 통해 템플릿 리소스를 찾고, Mustache 엔진으로 템플릿 컴파일 및 MustacheTemplate 객체를 생성하여 반환한다.
public interface TemplateResourceResolver {
Resource resolveTemplateResource(String name);
}
public class StandardTemplateResourceResolver implements TemplateResourceResolver {
private final TemplateFormat templateFormat;
public StandardTemplateResourceResolver(TemplateFormat templateFormat) {
this.templateFormat = templateFormat;
}
@Override
public Resource resolveTemplateResource(String name) {
Resource formatSpecificCustomTemplate = getFormatSpecificCustomTemplate(name);
if (formatSpecificCustomTemplate.exists()) {
return formatSpecificCustomTemplate;
}
Resource customTemplate = getCustomTemplate(name);
if (customTemplate.exists()) {
return customTemplate;
}
Resource defaultTemplate = getDefaultTemplate(name);
if (defaultTemplate.exists()) {
return defaultTemplate;
}
throw new IllegalStateException("Template named '" + name + "' could not be resolved");
}
private Resource getFormatSpecificCustomTemplate(String name) {
return new ClassPathResource(String.format("org/springframework/restdocs/templates/%s/%s.snippet",
this.templateFormat.getId(), name));
}
private Resource getCustomTemplate(String name) {
return new ClassPathResource(String.format("org/springframework/restdocs/templates/%s.snippet", name));
}
private Resource getDefaultTemplate(String name) {
return new ClassPathResource(String.format("org/springframework/restdocs/templates/%s/default-%s.snippet",
this.templateFormat.getId(), name));
}
}
위 코드를 통해 snippet 템플릿 가져오는 것을 확인할 수 있다.
public interface Template {
String render(Map<String, Object> context);
}
public class MustacheTemplate implements Template {
private final org.springframework.restdocs.mustache.Template delegate;
. . .
@Override
public String render(Map<String, Object> context) {
Map<String, Object> combinedContext = new HashMap<>(this.context);
combinedContext.putAll(context);
return this.delegate.execute(combinedContext);
}
}
MustacheTemplate의 render() 메서드에서는 기본 컨텍스트(this.context)를 복사하여 새로운 combinedContext를 생성한다. 그리고 새로 전달된 context의 내용을 combinedContext에 병합하여 Mustache 템플릿에 적용할 수 있도록 한 후, Mustache 템플릿을 랜더링하여 템플릿을 처리한 문자열 결과를 반환한다.
이렇게 Spring REST Docs를 파헤쳐보았다. 다음 포스팅에서는 오픈소스를 개발하는 과정과 배포하는 과정을 다룰 예정이다.