Spring REST Docs에 날개를... (feat: Popup)
안녕하세요? 우아한형제들에서 정산시스템을 개발하고 있는 이호진입니다.
2018년 12월 Spring REST Docs를 주제로 사내 블로그를 작성 후… 1년 이상이 지났습니다.
Spring REST Docs를 적용 후 빠르게 타 시스템에 API 스펙을 제공할 수 있다는 장점을 누렸던 반면
API 가 늘어날수록 단점이 부각 되었고 이 단점을 해소하기 위해 방법(꼼수)을 연구 했습니다.
또 이걸 우려 먹을 생각을 하니 민망(두근두근)하지만
운영하면서 불편한 점을 깨닫고 변경 했다는 점과
독특하지만 제 생각에는 나름 괜찮은 방법이라고 생각하여 글을 씁니다.
Spring REST Docs의 기본 셋팅과 기초적인 문법은 여길 참조해 주세요.
1. 문제점
1년 반동안 회사 시스템이 확장되면서 문제점이 보였습니다.
- 첫째, 느린 빌드 속도
- 둘째, 응답 필드에서 허용되는 코드 확인시 문서 상단에 위치하여 보기 불편
- 셋째, 문서 상단 코드 목록들이 많아져서 실제 중요한 문서내용 확인 불편
- 넷째, 문서 내에 반복되는 코드들로 해당 코드의 의미 파악 저해
- 다섯째, 버저닝으로 문서 응답이 다를시 확인 불편
등등 입니다.
일전에 블로그에 사용했던 예제로는 불편함을 느낄 수 없습니다.
hello world 정도로는 해당 언어의 좋고 나쁨을 알기 힘든것 처럼 말이죠.
문제점과 문제점을 개선하는 과정을 같이 체험하려고 합니다.
그래서 예제 페이지 링크가 필요한 부분에 연결 하겠습니다.
그럼 제가 어떻게 문제를 해결했는지 계속 읽어 주시기 바랍니다.
초기 불편함을 느낀 REST Docs 체험
2-1. 느린 빌드 속도 해결
테스트 빌드시 Spring Context 가 계속적으로 생성되서
Spring REST Docs 파일이 많아질수록 테스트 수행 속도가 느려집니다.
기존 코드를 어떻게 변경 했는지 보여드리고
속도 차이는 어떤지 비교해 보겠습니다.
개선 전 UnitCreateV2DocumentationTests.java
@RunWith(SpringRunner.class)
@WebMvcTest(controllers = {UnitControllerV2.class}) // (1)
@AutoConfigureRestDocs // (2)
public class UnitCreateV2DocumentationTests {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
protected UnitService unitService;
@Test
public void add() throws Exception {
// ...
}
}
(1) webMvcTest 를 위한 어노테이션입니다.
(2) REST Docs 를 사용하기 위한 어노테이션입니다.
한 두개의 REST Docs를 작성하기 위한 테스트가 있을때는 체감할 수 없습니다.
개선 후 ApiDocumentationTest.java 생성
@RunWith(SpringRunner.class)
@WebMvcTest(controllers = {
UnitControllerV1.class,
UnitControllerV2.class,
UnitControllerV3.class,
UnitController.class,
EnumViewController.class
}) // (1)
@AutoConfigureRestDocs // (2)
public abstract class ApiDocumentationTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
protected UnitService unitService;
}
(1) WebMvcTest 선언을 한번만 합니다.
(2) REST Docs 선언도 한번만 합니다.
개선 후 UnitCreateV2DocumentationTests.java
public class UnitCreateV2DocumentationTests extends ApiDocumentationTest { // (1)
@Test
public void add() throws Exception {
// ...
}
}
(1) 이제 문서를 위한 테스트 작성시 상속을 받으면 됩니다.
이렇게 되면 매번 Spring Context 를 띄우지 않습니다.
테스트 빌드 비교 시간을 참고해주세요.
문서 테스트가 많아질수록 시간 차이가 많이 납니다.
2-2. 응답 필드 코드 확인시 보기 불편 해결
html 에 a tag 를 사용시 외부 링크 뿐만 아니라 hash 를 이용한 내부 링크가 가능합니다.
이점을 이용한 방법 입니다.
개선 전 UnitCreateV2DocumentationTests.java
public class UnitCreateV2DocumentationTests extends ApiDocumentationTest {
@Test
public void add() throws Exception {
// ...
fieldWithPath("job").type(JsonFieldType.STRING).description("직업 코드") // (1)
// ...
}
}
(1) 직업 코드라고만 명시가 되어있기때문에 문서 상단으로 스크롤해서 확인을 해야합니다.
개선 후 index.adoc
[[job]] (1)
include::{snippets}/common/custom-response-fields-jobs.adoc[]
(1) adoc 으로 생성되는 문서에 id=job 이라는 형태로 div가 생성됩니다.
개선 후 UnitCreateV2DocumentationTests.java
public class UnitCreateV2DocumentationTests extends ApiDocumentationTest {
@Test
public void add() throws Exception {
// ...
fieldWithPath("job").type(JsonFieldType.STRING).description("<<job,직업 코드>>") // (1)
// ...
}
}
(1) href=”url#job” 으로 a tag가 생성됩니다.
변경 후에는 응답 필드에 링크를 클릭하면 해당 코드 목록으로 이동하게 됩니다.
링크 개선된 REST Docs 체험
2-3. 많은 공통 코드로 인한 보기 불편 해결
2-2번으로 링크를 누르면 공통 코드 쪽으로 화면 이동이 되지만 다시 보던 화면으로 돌아가려면 불편 합니다.
이 불편한 이유는 공통 코드가 차지 하는 영역이 넓기 때문 입니다.
저는 클릭을 했을때 상단으로 이동 되는 것이 아닌
다른 창에 내용이 나오면 좋겠다 라는 생각을 했습니다.
ascii 문법을 보면
link:index.html[Docs]
이렇게 Relative 한 링크를 걸수 연결할 수 있습니다.
link:index.html#job[직업 코드]
개선 후 UnitCreateV2DocumentationTests.java
public class UnitCreateV2DocumentationTests extends ApiDocumentationTest {
@Test
public void add() throws Exception {
// ...
fieldWithPath("job").type(JsonFieldType.STRING).description("link:#job[직업 코드,window=\"_blank\"]") // (1)
// ...
}
}
(1) 위에 언급한 방법대로 외부 링크를 작성 하고 새창 열기를 위해 window=”_blank” 를 선언 합니다.
이렇게 선언 하면 아래 이미지와 같이 html 이 구성 됩니다.
2-4. 그래도 보기 불편 해결
대부분의 문제가 해결이 되었지만 UX 관점에서 몇가지 문제가 있습니다.
- 새창 또는 새탭으로 열리기에 기존 화면으로 돌아가기에 불편
- 보고자 하는 코드 뿐만 아니라 다른 코드 목록 들도 같이 보이기 때문에 불필요한 정보 노출
그래서 해당하는 코드 목록만 노출하는 팝업으로 해결하려 합니다.
하지만 아쉽게도 asciidoc에서는 팝업을 제공하지 않아서
꼼수를 쓸 수 밖에 없었습니다.
asciidoctor의 docinfo 라는게 있습니다.
adoc 파일에 html 파일을 주입 할 수 있게 해주는 속성 입니다.
- docinfo 는 private, shared, head, footer 등의 조합을 할 수 있습니다.
- private 시 특정 파일 이름을 선언해서 사용 가능합니다.
- shared 선언 시 docinfo.html 을 기본적으로 가져다 사용합니다.
- head 는 private-head 또는 shared-head 로 선언이 가능하며 선언 시 head 위치에 붙습니다.
-
footer 는 head 와 반대입니다.
- docinfo1, docinfo2 등등 도 있는데 이것은 alias 입니다.
a tag 에 class 속성을 넣고 클릭 시 html에 선언한 javascript로 팝업을 띄울 예정 입니다.
참고로 head에는 style도 넣을 수 있기 때문에 자신만의 독특한 스타일의 문서를 만들수 있습니다만
저는 미적 감각이 떨어져서 사용을 안했습니다.
개선 후 index.adoc
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
= API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:sectlinks:
:docinfo: shared-head // (1)
// (2)
팝업을 사용할 adoc 에만 선언
(1) docinfo 선언
(2) job 에 해당하는 공통코드 삭제
개선 후 common/job.adoc 생성
include::{snippets}/common/custom-response-fields-jobs.adoc[]
hash가 아닌 별도 파일로 변경합니다. 이렇게 하는 이유는 팝업에서 해당 내용만 보여주기 위함입니다.
개선 후 docinfo.html 생성
<script>
function ready(callbackFunc) {
if (document.readyState !== 'loading') {
// Document is already ready, call the callback directly
callbackFunc();
} else if (document.addEventListener) {
// All modern browsers to register DOMContentLoaded
document.addEventListener('DOMContentLoaded', callbackFunc);
} else {
// Old IE browsers
document.attachEvent('onreadystatechange', function () {
if (document.readyState === 'complete') {
callbackFunc();
}
});
}
}
function openPopup(event) {
const target = event.target;
if (target.className !== "popup") { //(1)
return;
}
event.preventDefault();
const screenX = event.screenX;
const screenY = event.screenY;
window.open(target.href, target.text, `left=${screenX}, top=${screenY}, width=500, height=600, status=no, menubar=no, toolbar=no, resizable=no`);
}
ready(function () {
const el = document.getElementById("content");
el.addEventListener("click", event => openPopup(event), false);
});
</script>
해당 파일은 기본옵션으로 만들었기 때문에 docinfo.html 이라는 이름이 지정되었고
해당 이름과 경로는 옵션으로 변경 가능합니다.
각 페이지마다 스타일과 스크립트가 다르다면 옵션으로 만드시고
그게 아니라면 저처럼 하나만 작성하면 됩니다.
(1) class 가 popup 인 경우 팝업 생성
개선 후 UnitCreateV2DocumentationTests.java
public class UnitCreateV2DocumentationTests extends ApiDocumentationTest {
@Test
public void add() throws Exception {
// ...
fieldWithPath("job").type(JsonFieldType.STRING).description("link:common/job.html[직업 코드,role=\"popup\"]") // (1)
// ...
}
}
(1) role 은 doc 파일을 생성하면 class 가 됩니다. blank는 이제 필요없으니 제거합니다.
2-5. 이게 끝?
제가 보여 주고 싶었던 코드는 다 설명했습니다.
하지만 "link:common/job.html[직업 코드,role=\"popup\"]"
이런 부분이 반복적이며
글자 타이핑 하다가 오타가 발생할 수 있으니 코드로 관리하게 변경 하겠습니다.
DocumentLinkGenerator.java 생성
public interface DocumentLinkGenerator {
static String generateLinkCode(DocUrl docUrl) {
return String.format("link:common/%s.html[%s %s,role=\"popup\"]", docUrl.pageId, docUrl.text, "코드"); // (1)
}
static String generateText(DocUrl docUrl) {
return String.format("%s %s", docUrl.text, "코드명"); // (2)
}
@RequiredArgsConstructor
enum DocUrl {
JOB("job", "직업"),
JOBV1("jobV1", "직업"),
JOBV2("jobV2", "직업"),
JOBV3("jobV3", "직업"),
GENDER("gender", "성별"),
;
private final String pageId; // (3)
private final String text; // (4)
}
}
해당 파일은 테스트에서만 사용하니 테스트 패키지에 작성해 놓습니다.
(1) "link:common/job.html[직업 코드,role=\"popup\"]"
이 부분으로 변경 해주는 코드입니다.
(2) 링크가 없는 단순 코드 명이 노출될 수 도 있으니 해당 유틸도 만들어줍니다.
(3) DocUrl 이라는 enum 에서 pageId 는 common 폴더에 있는 파일 명입니다.
(4) text 는 문서에 노출 되는 텍스트입니다.
UnitCreateV2DocumentationTests.java 수정
public class UnitCreateV2DocumentationTests extends ApiDocumentationTest {
@Test
public void add() throws Exception {
// ...
fieldWithPath("job").type(JsonFieldType.STRING).description(generateLinkCode(JOB)),
fieldWithPath("jobName").type(JsonFieldType.STRING).description(generateText(JOB))
// ...
}
}
적용은 이렇게 하시면 됩니다.
이 후에는 유틸 클래스를 사용하면서 손쉽게 팝업을 적용 할 수 있습니다.
팝업 개선된 REST Docs 체험
마무리
이렇게 몇 단계를 거치며 개선을 하였습니다.
이것처럼 처음에는 못 느끼던 문제를 확장 되면서 느낄 때 가 많습니다.
개선하는 과정은 즐거웠고 결과물 도 생각보다 잘 나온 것 같아 좋습니다.
다들 보시고 스타일도 적용해 보시고 원하시는 스크립트도 적용해 보시기 바랍니다.
미흡한 글 읽어주셔서 감사합니다.
해당 소스는 깃헙에 있습니다.
참고 :
https://asciidoctor.org/docs/asciidoc-syntax-quick-reference https://github.com/asciidoctor/asciidoctor.org/blob/master/docs/_includes/docinfo.adoc