RESTful service에서 (그리고 아마도 GraphQL service에서도) api의 사용법을 안내하는 것은 중요하다.
각 메소드의 목적과 사용방법이 무엇인지 추측하면서 작업을 진행하는 것은 너무나 비효율적인 일이기 때문이다.
그렇기 때문에 백엔드 개발자로서 API 문서를 만들고 유지보수하는 것은 필수적이지만, 솔직한 말로 굉장히 귀찮은 일이 아닐 수 없다.
괜히 업데이트가 잘 되지 않은 문서들이 떠돌아다니는 것이 아니다. 문서 잘 만든다고 프로그램의 동작이 나아지는 것도 아니니까 아무래도 관심이 덜 갈 수 밖에 없다.
문서화 도구 중 유명한 것은 Swagger
일 것이다.
Swagger는 여러모로 인기를 끌기 좋은 도구이다.
일단 ui가 이쁘고, 가독성이 좋은 편이고, 테스트 요청을 지원한다.
이 중에서 테스트 요청을 지원한다는 것이 꽤 크다고 생각한다.
설명을 읽고 적당히 이해했더라도, 이게 정확히 내가 생각한 대로 동작하는지 문서 페이지에서 바로 테스트 해 볼 수 있다는 것은 상당히 큰 장점이다.
또한 코드 레벨에서의 조작이 가능하다는 점도 매력적인 부분이다.
다만, Swagger에는 개인적으로 큰 단점이 두 개 있다고 생각하는데,
하나는 문서화용 코드를 직접 추가해야 한다는 것이고,
다른 하나는 그렇게 문서화용 코드를 추가하다보면 코드가 심히 더러워진다는 것이다.
기능에 영향을 주지 않은 문서화용 어노테이션이 난잡하게 있다보면 쓸데없이 코드가 길어지고, 가독성이 떨어지게 된다 (이 부분은 사바사 일 수 있다)
그렇기 때문에 문서화는 비즈니스 로직과 분리되었으면 좋겠다.
다른 프레임워크도 비슷한 것이 있을 수 있겠지만, Spring 생태계에는 Spring REST Docs
라고 하여 REST api 명세 작성 자동화에 크나큰 기여를 해주는 툴이 있다.
Spring REST Docs의 큰 장점 중 하나는 테스트 코드 작성 강제로 인한 신뢰성이라고 생각한다.
테스트 코드 작성이 강제되는 이유는, api 문서에 포함 될 snippet이 필요하기 때문이다.
테스트 코드가 없다면 snippet이 없고, snippet이 없다면 api 문서에 포함될 수 없다.
테스트가 실패하면? 당연히 api 문서에 포함될 수 없다.
이렇게 성공한 테스트의 snippet을 기반으로 api 문서가 만들어지기에, 해당 문서에 대한 신뢰도를 보장할 수 있다.
snippet의 포맷으로는 기본적으로 asciidoc
를 이용한다.
asciidoc은 마크다운과 같은 언어의 한 종류이다.
설정에 따라 마크다운을 사용하게 할 수도 있는데, asciidoc이 보다 많은 기능을 지원하므로 굳이 바꿀 필요는 없을 것이다.
그렇게 만들어진 snippet들은 asciidoctor
라는 플러그인에 사용되어 html문서로 렌더링 된다. 이 과정을 통해 이름 그대로 조각조각난 snippet들이 합쳐져 api 문서를 구성하게 된다.
API 문서 작성 자동화에 있어 다음의 목표를 설정하고자 한다.
API 콜에 대한 문서이기에 문서 작성의 대상이 되는 테스트 클래스는 Controller 클래스가 된다.
Controller 단에 대한 테스트를 하는 방법에는 MockMvc, REST Assured 2가지가 있다.
REST Assured의 경우에는 대개 통합 테스트를 목적으로 하기에 일단 무겁다.
그리고 API 문서를 작성하는데 있어 유닛 테스트인지 통합 테스트인지는 전혀 중요하지 않으므로, MockMvc를 이용해 API 문서를 작성하도록 한다.
기본적으로 평범한 MockMvc 테스트를 작성해주면 된다. 다만, 문서화를 할 것이기 때문에 이에 관련된 설정을 추가로 덧붙여줘야 할 필요가 있다.
@WebMvcTest(MemberController.class)
@ExtendWith(RestDocumentationExtension.class)
public class MemberControllerTest {
private MockMvc mockMvc;
@BeforeEach
public void setUp(WebApplicationContext webApplicationContext,
RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
}
위와 같은 방법으로 MockMvc를 쓰기 위해 context를 잡아주는 것에 더해 문서화를 위한 모듈을 확장 기능으로 잡아주고 그에 대한 설정을 적용해주면 되지만, 조금 귀찮을 수 있다.
그러면 아래와 같이 해주면 된다.
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@WebMvcTest(MemberController.class)
class MemberControllerTest {
@Autowired
private MockMvc mockMvc;
}
테스트 작성 예시는 다음과 같다.
email
password
를 입력으로 보내면, id
값을 더한 회원 객체를 응답으로 주는 api이다.
뭐든 상관없으니 별 생각 없이 회원가입으로 했는데, 패스워드를 리턴하는 꼴을 보니 다른 걸로 할 걸 그랬다는 생각이 든다.
@Test
@DisplayName("회원가입 성공")
public void success_signup() throws Exception {
Member member = Member.builder().email("abc@gmail.com").password("1234").build();
MemberForm form = new MemberForm();
form.setEmail("email");
form.setPassword("1111");
// given
given(memberService.signup(any()))
.willReturn(member);
// when
ResultActions result = mockMvc.perform(post("/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(form)));
// then
result.andExpectAll(
status().isOk(),
jsonPath("$.email").value(member.getEmail()),
jsonPath("$.password").value(member.getPassword())
) // 여기까진 평범한 테스트. 아래부터 문서화
.andDo(document("member-signup",
requestFields(
fieldWithPath("email").description("이메일"),
fieldWithPath("password").description("비밀번호")
),
responseFields(
fieldWithPath("id").description("회원 id"),
fieldWithPath("email").description("회원 이메일"),
fieldWithPath("password").description("회원 비밀번호")
)));
}
만약 있을 수도 있고 없을 수도 있는 값이라면 뒤에 .optional()
을 붙여 표현해준다.
fieldWithPath("nickname").description("닉네임").optional()
build.gradle
로 가서 몇 가지 설정을 해줘야 하는데, Spring REST Docs 버전에 따라 이미 되어있을 수도 있다.
ext { // snippetsDir의 경로 설정
set('snippetsDir', file("build/generated-snippets"))
}
tasks.named('test') { // snippet 출력용 dir 설정
outputs.dir snippetsDir
}
이 부분을 추가해줘야 한다.
이렇게 하고 gradle->test를 실행해보면 generated-snippets
폴더 아래에
document("member-signup", ...)
요기서 사용한 api 명의 폴더가 생성되어 있고,
그 아래에 snippet들이 모여 있는 것을 확인할 수 있다.
생성된 snippet들을 확인해보면 말그대로 조각나있어 영 가독성이 좋지 않음을 볼 수 있다.
그래서 이 파일들을 합쳐 일단은 api 문서처럼 보이는 무언가를 만들어야 한다.
먼저 snippet의 확장자를 보면 .adoc
이라고 되어 있다.
그리고 해당 파일을 보면 마크다운 비슷하지만 조금 다른 문법으로 내용이 작성되어 있는 것을 확인할 수 있다.
이는 asciidoc
의 문법을 따른 것으로, 브라우저를 통해 해당 파일을 열게 되면 html
markdown
문서와 같이 그 모습이 바뀌어 보이게 된다.
만약 Intellij Idea를 활용하고 있다면 Plug-in 중 Asciidoc
을 설치하여 그 결과물을 확인할 수 있다.
이 단계에서 해결해야 할 과제는 2가지가 있다.
1. snippet들을 합친다
2. html 파일을 생성한다
snippet들은 요청을 어떻게 보내는 지, request의 parameter or body의 형식은 어떻게 되는지, response body의 형식은 어떻게 되는 지 등의 정보를 각각 담고 있다.
독자 입장에서 그 정보 하나하나를 확인하기 위해 일일이 다른 snippet을 찾는 것은 매우 불편하다. 그렇기에 이를 하나로 모을 필요가 있다.
흩어진 snippet들을 모으기 위해서는 어떻게 모을지에 대한 양식을 asciidoc으로 작성해주어야 한다.
이 파일의 위치는 아래에서 언급할 것인데, 일단은 마음에 드는 곳에 대충 처박아두자.
Member에 관련한 api이므로 이름은 대충 member.adoc
이라고 해두겠다.
:snippets: {docdir}/build/generated-snippets
= RESTful Notes API Guide
:doctype: member
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectnums:
:sectlinks:
:sectanchors:
[[api]]
== Member Api
include::{snippets}/member-signup/curl-request.adoc[]
include::{snippets}/member-signup/http-request.adoc[]
include::{snippets}/member-signup/request-fields.adoc[]
include::{snippets}/member-signup/http-response.adoc[]
include::{snippets}/member-signup/response-fields.adoc[]
Asciidoc의 문법은 여기를 참고하자.
docdir
의 설명은 current directory라고 되어 있는데, 아무래도 runtime 시의 현재 위치는 root로 잡힐 것이기에 경로를 저렇게 해도 되는 건가? 하는 생각이 든다.
그런데 다른 예제를 인터넷에서 보면 해당 .adoc
파일이 있는 위치를 경로로 잡는 경우도 있는 것 같다. 버전 차이인 것 같으니 몇 번 돌려보면서 올바른 경로를 찾으면 좋겠다.
아무튼 이렇게 작성된 파일을 브라우저나 플러그인을 통해 확인해보면 api 문서가 이쁘게 작성된 것을 확인할 수 있다.
또한 생성된 api 문서는 배포 시 동봉되어야 제공이 가능하다. 헌데 밑에서 snippet을 모으는 과정을 보면 이미 존재하는 snippet들을 참조해 짜집기를 한다. 그래서 이 형태 그대로 제공하기 위해서는 생성된 snippet들까지 배포 파일에 동봉될 필요가 있다.
하지만 생각해보면, 우리가 제공해야 하는 것은 고작 한 페이지의 웹 문서이다. 굳이 asciidoc을 고집할 필요도 없고, 파일을 여러개로 분할해 둘 필요도 없다.
그렇기에 합쳐진 snippet들을 html문서로 변환할 것이다.
여기서 asciidoctor
라는 도구를 사용할 것인데, 이를 위한 의존성을 gradle 파일에 추가해 줄 필요가 있다. 참고로 이 도구는 꼭 gradle의 플러그인으로 사용되어야 하는 것은 아니다. 다양한 방향으로 지원을 하고 있기에 여기에서 확인해 보면 좋을 것 같다.
아무튼 gradle의 플러그인으로 사용하기 위해서는 다음을 추가해준다.
plugins {
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
'org.asciidoctor.convert'
를 추가하라고 나온 글도 있을 것인데, 이는 구 버전을 위한 것이고 depreciated 되었으니 꼭 필요하다면 버전을 잘 확인하고 이용하도록 하자.
Spring REST Docs의 버전에 따라서는 임의로 추가해주지 않아도 자동 추가가 되어있을 수도 있다. 그런 경우에는 굳이 건드릴 필요가 없다.
해당 의존성을 추가했다면, task 설정도 추가해준다.
tasks.named('asciidoctor') { // snippet 입력용 dir 및 실행 순서 (test 뒤) 설정
inputs.dir snippetsDir
dependsOn test
}
test 시에 snippet이 생성되니, 이를 이용하기 위해서는 반드시 test 뒤에 실행되어야 한다.
여기까지 했다면, 바로 윗 단계에서 작성한 *.adoc
파일을 적절한 위치로 옮기기만 하면 asciidoctor 플러그인이 알아서 해당 파일을 감지하고 html 파일을 생성한다.
그 적절한 위치는 여기에서 찾을 수 있다.
이 예제에서는 gradle을 사용하고 있으므로, src
-> docs
-> asciidoc
폴더 밑에 아까 생성한 *.adoc
파일을 옮겨준다.
그 후, gradle 도구를 이용해 프로젝트를 build 해보면 build/docs/asciidoc
아래에 html 파일이 생성된 것을 볼 수 있다.
더불어, 문서에서 나온 default location과 다른 곳에 생성이 되었다는 것도 함께 확인할 수 있는데...문서화에 관련된 문서가 정작 제대로 문서화가 제대로 되어있지 않다는 아이러니함을 느낄 수 있다.
이대로 문서를 제공해도 기능 파악에는 문제가 없다.
빌드 시에 .jar 패키지에 포함이 될 수 있도록
tasks.named('bootJar') {
dependsOn asciidoctor
copy {
from file("build/docs/asciidoc/")
into file("src/main/resources/static/docs")
}
}
이런 느낌으로 파일을 복사해 준 뒤, 해당 html 파일을 이용한 페이지를 제공할 수 있는 문서용 api를 생성하면 자동화는 끝이다.
HTML 대신 openapi-spec을 이용해 swagger 페이지를 생성할 수도 있다.
다만, 이 경우 swagger에게 어떤 식으로 페이지를 생성해야 하는지를 알려줘야 한다.
java용 swagger 의존성을 사용해 어노테이션으로 이를 알려줄 수도 있지만, 다른 방법도 있다.
Openapi specification
을 이용해 API의 구조를 알려줄 수 있는데, epage
사의 REST Docs API spec
을 사용하면 된다.
즉, snippet을 생성하는 대신 OAS를 생성해 swagger를 통해 페이지를 구성하는 것이다.
이 방식의 특징은 다음과 같다
이런 방법이 있고, 그 특징에 대해서만 짚고 넘어가도록 하겠다.
추가한다면, 다음 기회에.