안녕하세요 NOT-ERROR-064팀 백엔드 개발자 강시혁(제임스)입니다.😎
서론
오늘부로 다들 슬슬 구현을 시작했을 것입니다. 저와 백엔드 팀도 마찬가지고요.ㅎㅎ
하지만 그렇게 기다리고 기다리던 코드 구현인데, 막상 시작하려 하니 막막하신 분들도 있을 것입니다. 아마 그 이유 중에는 Spring Rest Docs를 사용해서 API 문서를 만들어야 하기 때문도 있을 것입니다. 저 또한 Spring REST Docs 기술을 적용할 때 어려웠던 부분들이 있었습니다. 가령 Test 방식으로 API 문서를 자동화해야 하는데 어디서부터 어디까지 구현을 해야 할지 감이 잡히지 않았습니다. 또한 프런트엔드 팀에게 문서를 빠르게 전달해야 하는데 테스트 코드 작성이 어려웠을 것입니다.
그래서 오늘 제가 그 방법에 대해서 포스팅하도록 하겠습니다.
Spring REST Docs 개요
Spring Rest Docs는 테스트 코드를 기반으로 API 문서를 자동으로 작성할 수 있게 도와주는 프레임워크입니다.
우리 팀이 Spring REST Docs를 사용하는 이유
방법을 알아보기 전에 우리 팀이 왜 Spring REST Docs를 써야하는지 알아보겠습니다. 사실 API 문서를 만드는 방법은 다양합니다. Swagger, 포스트맨 등을 활용할 수도 있으며, 노션에 직접 작성할 수도 있습니다. 그럼에도 왜 우리는 Spring REST Docs를 선택했을까요?
그 이유는 코드로 작성한 내용을 자동으로 API 문서로 만들어주는 기능 때문이라고 할 수 있습니다. 더 자세하게는 추후에 필드나 이 외에 데이터 통신 규칙이 변경되었을 때, 몇 가지 코드를 추가하고 테스트만으로 문서 내용을 수정하기 위함입니다. 두 번째로는 테스트 코드를 활용할 수 있기 때문입니다. Mock을 통해서 Controller로 주입된 빈들을 끊었기 때문에, 이후에 Controller 단위 테스트로 활용할 수도 있습니다.
이제 방법에 대해서 자세하게 알아보겠습니다.
Spring REST Docs | Swagger | |
장점 | 제품 코드에 영향이 없음 | API를 테스트할 수 있는 화면 제공 |
테스트 코드 재활용 | 쉬운 적용 | |
단점 | 어려운 적용 | 모든 제품코드마다 어노테이션 추가 |
제품코드와 동기화가 안됨 |
1. build.gradle 설정
- 코드 한번에 보기
코드 한번에 보기
plugins {
// 생략
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
group = 'com.noterror'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
snippetsDir = file('build/generated-snippets')
}
configurations {
asciidoctorExtensions
}
dependencies {
// 생략
//rest docs
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
implementation 'org.springframework.restdocs:spring-restdocs-asciidoctor'
implementation 'com.google.code.gson:gson'
}
tasks.named('test') {
outputs.dir snippetsDir
useJUnitPlatform()
}
tasks.named('asciidoctor') {
configurations "asciidoctorExtensions"
inputs.dir snippetsDir
dependsOn test
}
task copyDocument(type: Copy) {
dependsOn asciidoctor
println "asciidoctor output: ${asciidoctor.outputDir}"
from file("${asciidoctor.outputDir}")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
bootJar {
dependsOn copyDocument
from ("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
1) plugin 설정
* 주의 : Gradle 버전에 따라서 사용해야 하는 plugin이 다릅니다.
(1) Gradle 7 버전 이하 : `org.asciidoctor.conver` , `version 2.4.0`
(2) Gradle 7 버전 이상 : `org.asciidoctor.jvm.conver` , `version 3.3.2`
2) 외부 프로퍼티 설정
ext를 통해서 외부 프로퍼티를 설정한 것입니다. 즉, snippetsDir 생성 경로를 만들어준 것입니다.
그리고 configurations를 만들어서 위와 같이 넣어주세요.
3) dependencies 세팅하기
4) 기타 세팅
해당 설정을 진행하면, 자동으로 여러 가지 파일들이 생성됩니다.
(1) 테스트를 진행하면, build > generated-snippets 안에 api 문서에 관련된 파일들이 생성됩니다.
(2) 빌드를 진행하면, docs > asciidoc 안에 adoc 파일을 찾고 html 파일로 만듭니다.
(3) resources > static > docs 안에 html 파일이 생성된 것을 볼 수 있습니다. (자세하게는 빌드 경로에 만든 html을 복상한 것입니다.)
2. API 문서로 만들 Controller 구현
저는 단일 제품을 조회하는 GET 기능의 API 문서를 만들었습니다.
코드 한번에 보기
코드 한번에 보기
@RestController
@CrossOrigin
@Slf4j
@RequestMapping("/product")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
/**
* 제품 개별 조회
* @param : productId
*/
@GetMapping("/detail/{product-id}")
public ResponseEntity getProduct(@PathVariable("product-id") Long productId) {
Product product = productService.findProduct(productId);
return new ResponseEntity(
new SingleProductResponse(product),
HttpStatus.OK);
}
API 문서를 만들려는 것이기 때문에 Controller를 어렵게 만들 필요가 없습니다. 항상 아래 그림과 논리를 기억해주세요.
클라이언트가 정해진 HTTP 메서드와 URL/endpoint로 정해진 요청 데이터를 보내면, 서버는 그에 맞는 응답 데이터를 반환한다.
단, GET과 같은 요청에서는 Request 데이터가 없을 수 있습니다.
그리고 이 모든 내용은 서버와 클라이언트가 서로 약속한 정해진 규칙을 기반으로 통신하는 것입니다.
그래서 위와 같이 저는 논리의 맞는 수준으로 개별 제품 조회 기능을 Controller에 구현했습니다.
자세하게 보자면, GET 메서드와 /product/detail/{제품 식별자} 엔드포인트로 request 데이터 없이 요청을 보내면, 약속한 제품 데이터를 JSON 형태로 보내고, OK 신호를 전송하겠다.라고 해석할 수 있습니다.
굉장히 간단하게 구현했습니다.
3. 필요한 재료(주입) 최소한으로 구현
위에 작성한 컨트롤러 코드를 보면 product 객체와 ProductService가 주입된 것을 볼 수 있습니다. 그 말은 해당 클래스들이 필요하다는 뜻입니다. 하지만 구체적이게 필요하지는 않습니다. Service 같은 경우에는 테스트를 구현할 때 MockBean으로 가짜 객체를 만들 것이기 때문입니다. 제가 구현한 정도는 아래와 같습니다.
1) Product 엔티티
지금 당장 JPA를 사용해서 Entity로 만들 필요 없습니다. 객체가 가질 수 있는 모든 필드를 작성합니다.
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
@Getter @Setter @NoArgsConstructor
@AllArgsConstructor @Builder
public class Product {
private Long productId;
private String productName;
private int price;
private int quantity;
private LocalDateTime signDate;
private String thumbnailImage;
private String detailImage;
// TODO : 식재료
private List<String> ingredients;
// TODO : 카테고리
private List<String> categories;
}
2) (Interface) ProductService, (Class) ProductServiceImpl
말 그대로 껍데기만 만들어줍니다.
import com.noterror.app.api.domain.entity.Product;
/**
* 제품 서비스
* @method findProduct : 제품 조회 기능
* -> @return Product
*/
public interface ProductService {
Product findProduct(Long productId);
}
import com.noterror.app.api.domain.entity.Product;
import org.springframework.stereotype.Service;
@Service
public class ProductServiceImpl implements ProductService{
@Override
public Product findProduct(Long productId) {
return null;
}
}
3) 추가로 response 데이터를 객체에 파씽하기 위한 클래스 생성
우리 팀은 response 데이터를 구현하는 도메인 이름으로 파씽해서 전송합니다. 저는 제품 도메인과 관련된 response를 전달하기 때문에 Product 객체에 파씽해줍니다. 구현하는 방법과 결과 모습은 아래를 참고해주세요.
4. 테스트 코드 작성
1) 관련 Import
테스트 관련 비슷한 import가 많아서 헷갈릴 수 있습니다. 혹시나 잘못된 라이브러리로 import 한다면 문제가 생길 수 있습니다.
import com.noterror.app.api.domain.entity.Product;
import com.noterror.app.api.domain.product.controller.ProductController;
import com.noterror.app.api.domain.product.service.ProductService;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
2) 테스트 코드 작성
@WebMvcTest(ProductController.class) //(1)
@AutoConfigureRestDocs //(2)
class ProductControllerRestDocs {
@Autowired
private MockMvc mockMvc; // (3)
@MockBean
private ProductService productService; //(4)
// (5)
@Test
void getProduct() throws Exception {
// (6)
Product productData
= Product.builder()
.productId(1L)
.productName("카레라면")
.price(10000)
.quantity(3)
.thumbnailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
.detailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
.signDate(LocalDateTime.now())
.ingredients(List.of("야채류","해조류","균류","어패류","난류"))
.categories(List.of("간편식","조미용식"))
.build();
// (7)
given(productService.findProduct(Mockito.anyLong())).willReturn(productData);
// (8)
mockMvc.perform(
get("/product/detail/{product-id}"
,productData.getProductId()))
.andExpect(status().isOk())
.andDo(document("get-product", // (9)
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
pathParameters(
parameterWithName("product-id").description("제품 식별자")
),
responseFields(
List.of(
fieldWithPath("product").type(JsonFieldType.OBJECT).description("제품 결과 데이터"),
fieldWithPath("product.productId").type(JsonFieldType.NUMBER).description("제품 식별자"),
fieldWithPath("product.productName").type(JsonFieldType.STRING).description("제품명"),
fieldWithPath("product.price").type(JsonFieldType.NUMBER).description("제품가격"),
fieldWithPath("product.quantity").type(JsonFieldType.NUMBER).description("수량"),
fieldWithPath("product.thumbnailImage").type(JsonFieldType.STRING).description("썸네일 이미지"),
fieldWithPath("product.detailImage").type(JsonFieldType.STRING).description("상세 정보 이미지"),
fieldWithPath("product.signDate").type(JsonFieldType.VARIES).description("제품 등록 날짜"),
fieldWithPath("product.ingredients").type(JsonFieldType.ARRAY).description("식재료 정보"),
fieldWithPath("product.categories").type(JsonFieldType.ARRAY).description("카테고리")
)
)
));
}
}
(1) MockMVC 테스트할 클래스 명시
(2) RestDocs 자동화를 위한 애너테이션
(3) 과정을 수행해줄 MockMVC 클래스를 주입
(4) 컨트롤러에 주입된 빈들을 가짜 빈으로 대체
(5) api 문서화할 기능의 테스트 코드 작성
(6) response로 반환할 형태로 데이터 저장 후 객체 생성
(7) given 명령어를 통해서 가짜 빈의 시나리오를 임의로 작성(조작 개념) -> 이러한 이유로 service는 껍데기만 있어도 됩니다.
(8) 컨트롤러에서 구현한 논리대로 시나리오를 실행하고, andExpect 를 통해서 테스트 결과 예상
(9) andDo에 인자로 document()를 사용해서 api 문서 소스 생성
이때 테스트에 통과해야만 소스가 생성됩니다.
document 내부에 있는 코드들에 대한 설명은 잠시 생략하겠습니다.
참고 링크
https://velog.io/@hydroniumion/BE2%EC%A3%BC%EC%B0%A8-Spring-Rest-Docs-%EC%A0%81%EC%9A%A9%EA%B8%B0-2
https://tecoble.techcourse.co.kr/post/2020-08-18-spring-rest-docs/
'[ BE ] 기술' 카테고리의 다른 글
[BE-기술] 채식쇼핑몰 '채식이들' 프로젝트 백엔드 개발 설계 후기 (0) | 2022.10.06 |
---|---|
[BE-기술] Spring Data JPA의 DB 초기화 (0) | 2022.10.02 |
[BE-기술] RestController와 Controller (1) | 2022.10.02 |
[BE-기술] 백엔드 개발자의 필수 과제, '순환 참조(Circular Reference)' 문제 해결 (2) | 2022.09.18 |
[BE-기술] JPA에서 Spring Data의 Audit 기능 적용하기 (0) | 2022.09.10 |