서점 프로젝트를 진행하면서 MSA기반으로 하다보니 팀원들이 어떤 API를 구현해놨는지 알기 어렵기 때문에 결국 API 명세서를 작성해서 만들기로 했습니다. 그러던중 Swagger, Rest Docs를 접하게되었고 어떤 기술을 사용할지 고민했습니다. 이 글에선 어떤 생각으로 두가지를 같이 쓰기로했는지 설명할까합니다.
Swagger
장점
- API를 테스트해 볼 수 있는 화면을 제공
- API문서가 자동으로 생긴다
- 어노테이션을 통해 문서가 생성되기 때문에 API 문서화가 쉽다(Rest Docs에 비해서)
단점
- 프로덕션 코드에 문서화를 위한 코드가 들어간다. (코드가 많이 복잡해진다, 안그래도 어노테이션이 덕지덕지 붙어있는데 더 붙는다)
- 서버가 실행됨가 동시에 문서가 만들어지기 때문에 API 스펙만 분리해서 관리하기 어렵다
Rest Docs
장점
- 제품코드에 영향이 없다.
- 테스트가 성공해야 문서가 작성된다.
단점
- 적용하기 어렵다
- 시각화가 어렵다
Swagger + Rest Docs 도입 배경
두개의 장점을 모두 사용하기 위해 Swagger와 Rest Docs를 같이 사용하기로 했습니다.
- 서로의 장점을 사용하여 단점을 보완
- 제품코드에 따로 API 명세서 코드가 안들어가도된다.
- 테스트 코드가 꼼꼼해진다
- swagger ui를 사용할 수 있다.
Rest Docs + Swagger 문서 제작 로직
- 기존 처럼 테스트 코드(Rest doc 형태로 만든 테스트) 를 통해 docs문서를 생성
- docs 문서를 Open API3 스펙으로 변환
- 만들어진 Open API3 스펙을 Swagger UI로 생성
- 생성된 SwaggerUI를 static 패키지에 복사 및 정적리소스로 배포
의존성 추가
<!-- swagger ui dependency -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>
<!-- spring rest docs 생성을 위한 디펜던시 -->
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<scope>test</scope>
</dependency>
<!-- restdocs spec(openapi spec) 문서를 생성하기 위한 디펜던시 -->
<dependency>
<groupId>com.epages</groupId>
<artifactId>restdocs-api-spec</artifactId>
<version>0.18.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.epages</groupId>
<artifactId>restdocs-api-spec-mockmvc</artifactId>
<version>0.18.2</version>
<scope>test</scope>
</dependency>
<!-- restdocs spec(openapi spec) 문서를 생성하기 위한 디펜던시 -->
html 문서 생성을 위한 플러그인 정의
<!-- restdocs-spec 문서 생성을 위한 플러그인 정의 -->
<plugin>
<groupId>org.asciidoctor</groupId>
<artifactId>asciidoctor-maven-plugin</artifactId>
<version>2.2.1</version>
<executions>
<execution>
<id>generate-docs</id>
<phase>prepare-package</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
<configuration>
<backend>html</backend>
<doctype>book</doctype>
<sourceDirectory>${project.basedir}/src/docs/asciidoc</sourceDirectory>
<sourceDocumentName>index.adoc</sourceDocumentName>
<outputDirectory>${project.build.directory}/classes/static/docs</outputDirectory>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-asciidoctor</artifactId>
<version>${spring-restdocs.version}</version>
</dependency>
</dependencies>
</plugin>
Rest Docs spec문서 생성을 위한 플러그인 정의
<plugin>
<groupId>io.github.berkleytechnologyservices</groupId>
<artifactId>restdocs-spec-maven-plugin</artifactId>
<version>0.22</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<specifications>
<specification>
<type>OPENAPI_V2</type>
</specification>
<specification>
<type>OPENAPI_V3</type>
<format>JSON</format>
</specification>
<specification>
<type>POSTMAN_COLLECTION</type>
<filename>postman-collection</filename>
</specification>
</specifications>
<name>${project.artifactId}</name>
<version>${project.version}</version>
<host>localhost:8081</host>
<schemes>
<scheme>http</scheme>
</schemes>
<snippetsDirectory>
${project.build.directory}/generated-snippets
</snippetsDirectory>
<outputDirectory>
${project.build.directory}/classes/static/docs
</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
application.yml
springdoc:
swagger-ui:
url: /docs/openapi-3.0.json
path: /docs/swagger
테스트 코드
베이스 테스트 클래스
@Disabled
@WebMvcTest(
//아래에 테스트 코드를 작성할 controller 클래스 정의
controllers = {
BookController.class
}
)
@ExtendWith(RestDocumentationExtension.class)
@AutoConfigureMockMvc
@AutoConfigureRestDocs
public abstract class BaseDocumentTest {
@Autowired
protected ObjectMapper objectMapper;
@Autowired
protected MockMvc mockMvc;
protected final String snippetPath = "{class-name}/{method-name}";
@BeforeEach
void setUp(final WebApplicationContext context, final RestDocumentationContextProvider provider) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(MockMvcRestDocumentation.documentationConfiguration(provider)
//요청 body 의 payload 를 보기 좋게 출력
.operationPreprocessors().withRequestDefaults(Preprocessors.prettyPrint())
.and()
//응답 body 의 payload 를 보기 좋게 출력
.operationPreprocessors().withResponseDefaults(Preprocessors.prettyPrint()))
//테스트 결과를 항상 print
.alwaysDo(MockMvcResultHandlers.print())
//한글 깨짐 방지
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.build();
}
protected String createJson(Object dto) throws JsonProcessingException {
return objectMapper.writeValueAsString(dto);
}
protected Attributes.Attribute attribute(final String key, final String value){
return new Attributes.Attribute(key,value);
}
}
베이스 테스트 클래스를 상속 받아서 진행
class BookControllerTest extends BaseDocumentTest {
@MockBean
private BookService bookService;
@MockBean
private BookImageService bookImageService;
@MockBean
private BookTagService bookTagService;
@MockBean
private BookCategoryService bookCategoryService;
@Test
void createBook() {
}
@DisplayName("책 디테일 뷰 가져오기")
@Test
void readBook() throws Exception {
CategoryParentWithChildrenResponse categoryParentWithChildrenResponse1 = CategoryParentWithChildrenResponse.builder()
.id(1L)
.name("Test Category1")
.build();
CategoryParentWithChildrenResponse categoryParentWithChildrenResponse2 = CategoryParentWithChildrenResponse.builder()
.id(2L)
.name("Test Category1")
.childrenList(List.of(categoryParentWithChildrenResponse1))
.build();
ReadBookResponse readBookResponse = ReadBookResponse.builder()
.id(1L)
.title("test Title")
.description("Test description")
.publishedDate(ZonedDateTime.now())
.price(10000)
.quantity(10)
.sellingPrice(10000)
.viewCount(777)
.packing(true)
.author("Test Author")
.isbn("1234567890123")
.publisher("Test Publisher")
.imagePath("Test Image Path")
.categoryList(List.of(categoryParentWithChildrenResponse2))
.tagList(List.of(ReadTagByBookResponse.builder().name("Test tag").build()))
.build();
given(bookService.readBookById(anyLong())).willReturn(readBookResponse);
this.mockMvc.perform(RestDocumentationRequestBuilders.get("/bookstore/books/{bookId}", 1L)
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk())
.andDo(document(snippetPath,
"아이디 기반 멤버 정보를 조회하는 API",
pathParameters(
parameterWithName("bookId").description("책 아이디")
),
responseFields(
fieldWithPath("header.resultCode").type(JsonFieldType.NUMBER).description("결과 코드"),
fieldWithPath("header.successful").type(JsonFieldType.BOOLEAN).description("성공 여부"),
fieldWithPath("body.data.id").type(JsonFieldType.NUMBER).description("책 아이디"),
fieldWithPath("body.data.title").type(JsonFieldType.STRING).description("책 제목"),
fieldWithPath("body.data.description").type(JsonFieldType.STRING).description("책 설명"),
fieldWithPath("body.data.publishedDate").type(JsonFieldType.STRING).description("출판 날짜"),
fieldWithPath("body.data.price").type(JsonFieldType.NUMBER).description("책 가격"),
fieldWithPath("body.data.quantity").type(JsonFieldType.NUMBER).description("수량"),
fieldWithPath("body.data.sellingPrice").type(JsonFieldType.NUMBER).description("판매 가격"),
fieldWithPath("body.data.viewCount").type(JsonFieldType.NUMBER).description("조회수"),
fieldWithPath("body.data.packing").type(JsonFieldType.BOOLEAN).description("포장 여부"),
fieldWithPath("body.data.author").type(JsonFieldType.STRING).description("저자"),
fieldWithPath("body.data.isbn").type(JsonFieldType.STRING).description("ISBN 번호"),
fieldWithPath("body.data.imagePath").type(JsonFieldType.STRING).description("책의 메인 이미지"),
fieldWithPath("body.data.publisher").type(JsonFieldType.STRING).description("책의 출판사"),
fieldWithPath("body.data.categoryList").type(JsonFieldType.ARRAY).description("카테고리 리스트"),
fieldWithPath("body.data.categoryList[].id").type(JsonFieldType.NUMBER).description("카테고리 아이디"),
fieldWithPath("body.data.categoryList[].name").type(JsonFieldType.STRING).description("카테고리 이름"),
fieldWithPath("body.data.categoryList[].childrenList").type(JsonFieldType.ARRAY).description("하위 카테고리 리스트"),
fieldWithPath("body.data.categoryList[].childrenList[].id").type(JsonFieldType.NUMBER).description("하위 카테고리 아이디"),
fieldWithPath("body.data.categoryList[].childrenList[].name").type(JsonFieldType.STRING).description("하위 카테고리 이름"),
fieldWithPath("body.data.categoryList[].childrenList[].childrenList").type(JsonFieldType.NULL).description("더 하위 카테고리 리스트"),
fieldWithPath("body.data.tagList").type(JsonFieldType.ARRAY).description("태그 리스트"),
fieldWithPath("body.data.tagList[].name").type(JsonFieldType.STRING).description("태그 이름")
)
));
}
}
테스트 시 모든 데이터에 대한 결과가 필요합니다.
'Project > MSA기반 서점' 카테고리의 다른 글
[서점프로젝트] ClientSide 로드밸런싱을 이용한 게이트웨이 (0) | 2024.08.31 |
---|---|
[서점프로젝트] 인증 JWT 도입 (0) | 2024.08.30 |
[서점프로젝트] 비회원, 회원 영구저장 장바구니 구현 (1) | 2024.08.30 |
확장성을 고려한 쿠폰 ERD 설계 (0) | 2024.08.10 |
Redis로 장바구니 데이터 캐싱, 영구저장하기 (4) | 2024.07.30 |