REST Docs를 시작으로 개발해보자

나민혁·2024년 9월 6일
0
post-thumbnail

왜 REST Docs로 시작할까 ?

지금까지 어떻게 개발해왔나 ?

나는 지금까지 API 명세서를 엑셀이나 노션같은곳에서 작성해왔다. 설계를 통해서 직접 어떤 데이터가 필요할지 확인하여 API를 만들어왔다.

지금까지 모두 코드가 하나도 없는 상태에서 API 를 만들어왔다. 설계한 ERD와 화면만을 보고 그냥 이렇겠거니 하고 만든거다 심지어는 오타도 많았고, json에 들어갈 key값도 어디는 name이었다가 어디는 productName이었다가 중구난방이었다.

작성을 다 하고난 이후에도 크로스체크를 할 필요가있었고, 사소한 오타 하나때문에 프론트와 연동이 안되는 문제도 발생하였다.

스웨거는 안써봤냐고 ?

조금 부끄럽지만 안써봤다. 왜 안써봤을까 ? REST Docs에 대해 알고 학습하기 전까지도 나는 Swagger가 단순히 문서 자동화만 해주는줄 알았다. 아마도 알았더라면 Swagger를 한번쯤은 이용해서 명세를 작성하지않았을까?
그래서 지금까지는 이미 API 명세가 완성되어있었고, 그에 따라 개발을 했기 때문에 Swagger를 이용할 필요가 없다고 생각했다. (유지보수할 일이 없었다...)
그리고 지금 생각해보면 아무리 명세서가 있다고 해도 Swagger 정도는 이용했어야한다고 생각한다. 실제로 API에 대한 테스트도 진행할 수 있기 때문에 설정해두었다면 프론트와의 소통에 사용하는 시간을 조금이라도 줄일 수 있지않았을까? 하지만 이미 지나간 일이니 다음번에는 테스트를 못하게 된다면 스웨거라도 꼭 만들자

난 왜 REST Docs 쓰려는걸까 ?

테스트에 관심이 깊어진 만큼 테스트를 통과해야지만 생성되는 REST Docs에 관심을 가지게 되었다.
REST Docs를 이용하면 오타를 일으킬 일도, 내 코드가 테스트를 통해 검증받고 있기 때문에 실제 어플리케이션의 API 명세와 같다고 생각이 들었다.
테스트코드로 보장받고 있는 어플리케이션의 API를 명세해주는데 안쓸이유가 있을까 ? 그래서 직접 공부해서 REST Docs로 명세해보려고 한다.

프로덕션 코드가 없는데 테스트는 어떻게 하고 설계가 가능해 ?

일단 내가 가지는 의문점 다 빼고 말하자면 가능하다. 물론 진행하면서 프로덕션 코드를 작성해야한다. Response와 Reqeust를 정의해야하고, 컨트롤러와 서비스에서 사용할 메서드명정도는 정해야한다. 하지만 이정도는 어렵지않지않은가? 그냥 코드없이 작성할 때도 메서드명 빼고는 다 작성했었다. 그럴거면 차라리 의미있게 코드가 하나라도 생기는게 좋지

엑셀이든 노션이든 아무 문서든 문서화하는거와 다르지 않다. Request를 정하고 Response를 정해서 문서화하면 된다. 기왕 다 정할거면 코드로 하는게 나은거같다.

given과 willReturn 하면 잘못된 문서가 만들어지는거 아닐까?

솔직히 말하면 나도 조금 의문이 생기는점은 있다. 설계를 위한 테스트코드는 실제 검증이 이루어지지 않는다.

아래와 같은 상황에서는 문제가 발생할 것 같은데 설계니까 꼼꼼하게 하고 넘어가는걸까 ? 아니면 추후 프로덕션 코드가 생기면 이곳에 검증을 추가하는걸까 ? 나도 글을 쓰는 지금은 설계까지밖에 안해서 모르겠다. 하여튼 문제상황을 살펴보자

long id = 1L;

given(productService.updateProduct(eq(id), any(ProductUpdateServiceRequest.class)))
            .willReturn(ProductResponse.builder()
                .id(id)
                .name("이디야 커피")
                .category("커피")
                .price(40000L)
                .description("국산")
                .build());

실제로 내가 작성한 코드 중 일부이다.

willReturn을 통해서 응답을 빌더로 만들고 있다. 그리고 놀랍게도 지금은 id를 사용하고있지만 명시적으로 updateProduct에 인자값으로 eq(1L)을 넣고 willReturn에 2L을 넣어도 테스트는 통과한다.

사진에서 위 코드가 변경된 것을 볼 수 있다. 명시적으로 update 메서드에서는 id값은 당연히 변경되면 안되는데 변경이 가능하다 이말은 다른말로 하면 테스트를 통해 보장받고 있지 않다는 것 아닐까 ?

처음 시작하는 과정이다보니 끝에 다다라서는 의문이 해결 될 수 있지만 아직은 의문으로 남아있다.

REST Docs 시작하기

아무래도 REST Docs의 진입장벽은 2개다. 그냥 공부하는 입장에선 정말 벽이 느껴졌다.
1. 테스트를 작성해야 한다.
2. 설정이 복잡하고 어렵다.

Gradle 설정

설정이 사람들 마다 너무 많다.. 정말 한 4개 시도했나 했는데 다 안되더라 난 그냥 따라하기만 하니까 못하는거다. 누군가에게는 쉬운 설정일 수도 있으나 나는 그저 인텔리제이에서 implements만 눌러서 설정해보고 jwt같은거나 넣어봤던 입장에선 아직도 이게 뭔가 싶다.

추가가 필요한 부분만 작성하도록 하겠다.

plugins {
    id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
    asciidoctorExt
}

dependencies {
    // REST Docs
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

ext {
    snippetsDir = file('build/generated-snippets')
}

test {
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'

    sources {
        include("**/index.adoc")
    }
    baseDirFollowsSourceFile()
    dependsOn test
}

bootJar {
    dependsOn asciidoctor
    from("${asciidoctor.outputDir}") {
        into 'static/docs'
    }
}

테스트 코드 준비하기

사실 gradle에 추가만 한다고 끝나는게 아니라 이것저것 해야할게 많다..

@ExtendWith(RestDocumentationExtension.class)
public abstract class RestDocsSupport {

    protected MockMvc mockMvc;
    protected ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void setUp(RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.standaloneSetup(initController())
            .apply(documentationConfiguration(provider))
            .addFilters(new CharacterEncodingFilter("UTF-8", true)) // 한글 깨짐 방지
            .build();
    }

    protected abstract Object initController();

추상 클래스로 하여 공통적으로 사용할 사항들을 상속받아 사용 할 수 있게끔 한다.

공통적으로 모든 클래스에서 MockMvc로 작성 할 것이기 때문에 mockMvc를 설정해주고, 아래 코드는 그냥 따라하자.. 아직까진 이해를 못했다.

applicationContext로 하면 문서화 할 때도 SpringBoot를 띄워야한다고 한다.. 아직까진 이해가 부족하다 좀 더 공부를 해야겠다. 일단 따라한다.

이렇게 하면 개별 컨트롤러로 할 수 있게된다.

attribute를 추가해서 docs에 다양한 속성을 만들기 위해서 config를 만들어준다.

@TestConfiguration
public class RestDocsConfig {

    public static final Attribute field(
        final String key,
        final String value){
        return new Attribute(key,value);
    }
}

이쯤이면 됐다. snippet도 넣고 해야되긴하는데 그냥 잘 모르겠으니 Spring Rest Docs Repository의 default snippet 이곳에 가면 default로 사용할 snippet들이 있다. 자기가 기술할 snippet들을 받아서 사용하면 된다.


이렇게 넣으면 된다.

테스트 코드 작성

드디어 시작해본다 테스트 코드 작성을...

일반적으로 테스트에서 이용되는 것 제외하고 REST Docs에서 사용되는 코드들에 대해서만 설명해보려고 한다. 하나씩 다 보기엔 너무 많으니

public class OrderControllerDocsTest extends RestDocsSupport {

    private final OrderService orderService = mock(OrderService.class);

    @Override
    protected Object initController() {
        return new OrderController(orderService);
    }

    @DisplayName("신규 주문을 생성하는 API")
    @Test
    void createOrder() throws Exception {

        OrderCreateRequest request = OrderCreateRequest.builder()
            .email("test@gmail.com")
            .address("서울시 강남구")
            .postcode("125454")
            .productsIds(List.of(1L, 2L))
            .build();

        given(orderService.createOrder(any(OrderCreateServiceRequest.class)))
            .willReturn(
                OrderResponse.builder()
                    .id(1L)
                    .orderStatus(ORDERED)
                    .email("test@gmail.com")
                    .address("서울시 강남구")
                    .postcode("125454")
                    .orderDetails(
                        List.of(
                            OrderDetailResponse.builder()
                                .price(1000L)
                                .quantity(1)
                                .category("원두")
                                .build()
                            ,
                            OrderDetailResponse.builder()
                                .price(2000L)
                                .quantity(2)
                                .category("음료")
                                .build()
                        ))
                    .build());

        mockMvc.perform(
                post("/api/v1/orders/new")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)
                    ))
            .andDo(print())
            .andExpect(status().isOk())
            .andDo(document("order-create",
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                requestFields(
                    fieldWithPath("email").type(JsonFieldType.STRING)
                        .description("주문자 이메일")
                        .attributes(field("constraints",  "최대 50자"))
                        .attributes(field("format", "XXXX@gamil.com 과 같은 이메일 형식")),
                    fieldWithPath("address").type(JsonFieldType.STRING)
                        .description("주문자 주소")
                        .attributes(field("constraints", "최대 200자")),
                    fieldWithPath("postcode").type(JsonFieldType.STRING)
                        .description("주문자 우편번호")
                        .attributes(field("constraints", "최대 20자")),
                    fieldWithPath("productsIds").type(JsonFieldType.ARRAY)
                        .description("주문 상품 ID를 담은 배열")
                ),
                responseFields(
                    fieldWithPath("code").type(JsonFieldType.NUMBER)
                        .description("코드"),
                    fieldWithPath("status").type(JsonFieldType.STRING)
                        .description("상태"),
                    fieldWithPath("message").type(JsonFieldType.STRING)
                        .description("메시지"),
                    fieldWithPath("data").type(JsonFieldType.OBJECT)
                        .description("응답 데이터"),
                    fieldWithPath("data.id").type(JsonFieldType.NUMBER)
                        .description("주문 ID"),
                    fieldWithPath("data.email").type(JsonFieldType.STRING)
                        .description("주문자 이메일"),
                    fieldWithPath("data.address").type(JsonFieldType.STRING)
                        .description("주문자 주소"),
                    fieldWithPath("data.postcode").type(JsonFieldType.STRING)
                        .description("주문자 우편번호"),
                    fieldWithPath("data.orderStatus").type(JsonFieldType.STRING)
                        .description("주문 상태"),
                    fieldWithPath("data.orderDetails").type(JsonFieldType.ARRAY)
                        .description("주문 상세"),
                    fieldWithPath("data.orderDetails[].category").type(JsonFieldType.STRING)
                        .description("주문한 상품의 카테고리"),
                    fieldWithPath("data.orderDetails[].price").type(JsonFieldType.NUMBER)
                        .description("주문한 상품의 가격"),
                    fieldWithPath("data.orderDetails[].quantity").type(JsonFieldType.NUMBER)
                        .description("주문한 상품의 수량")
                )
            ));
    }
}

구조 자체는 간단하다. 그냥 내가 주려고하는 request 작성하고 response 받아서 테스트를 통해서 확인하면 된다. 이 과정에서 당연히 코드가 아무것도 없으니 빨간불만 오지게 뜰거다 그냥 빨간불뜨면 만들면된다. 내가 명세할 정도로만

mockMvc.perform(
	// codes
	)
    .andDo()
    .andExpect()

여기 부분까지는 그냥 컨트롤러 테스트랑 똑같다. 당연히 얘도 컨트롤러 테스트니까(?)
그리고 아래에서 생기는 부분부터만 기억해서 작성하면 된다.

주석을 통해서 기억해보자 response랑 request 둘다 너무 기니까 적당히만 2~3개만 남기도록하겠다.

.andDo(document("order-create",	//
                preprocessRequest(prettyPrint()),	// JSON 파일 예쁘게 보여줌
                preprocessResponse(prettyPrint()),	// JSON 파일 예쁘게 보여줌
                requestFields(	// RequestBody (MultiPart 로 받으면 다름)
                    fieldWithPath("email").type(JsonFieldType.STRING) // 필드 명과 타입이 일치하지 않으면 테스트 통과 못한다 다 맞게 작성해야함
                        .description("주문자 이메일")	// 설명을 적어주면 된다.
                        .attributes(field("constraints",  "최대 50자")) // 여기는 snippet에 설명을 추가해서 제약사항을 추가로 작성해주었다.
                        .attributes(field("format", "XXXX@gamil.com 과 같은 이메일 형식")), 
                    fieldWithPath("address").type(JsonFieldType.STRING)
                        .description("주문자 주소")
                        .attributes(field("constraints", "최대 200자"))
                        // 이하 request 필드
                ),
                responseFields(	// ResponseBody 
                    fieldWithPath("data").type(JsonFieldType.OBJECT) // 여기도 request와 마찬가지로 일치해야한다.
                        .description("응답 데이터") // 마찬가지로 설명 작성하면 된다.,
                    fieldWithPath("data.id").type(JsonFieldType.NUMBER) // data OBJECT 안에 있기 때문에 .을 통해 이어가면 된다.
                        .description("주문 ID"),
                    fieldWithPath("data.email").type(JsonFieldType.STRING) // email은 STRING 이므로 타입을 맞춰준다.
                        .description("주문자 이메일"),
                    fieldWithPath("data.orderDetails").type(JsonFieldType.ARRAY)
                        .description("주문 상세"),
                    fieldWithPath("data.orderDetails[].category").type(JsonFieldType.STRING) // 배열의 경우 []로 묶고 하위를 작성한다.
                        .description("주문한 상품의 카테고리"),
                    fieldWithPath("data.orderDetails[].price").type(JsonFieldType.NUMBER)
                        .description("주문한 상품의 가격")
                        // 이하 resposne 필드 
                )
            ));

이래도 너무 길다. 솔직히 requestFields와 responseFields여기 부분은 블로그를 찾아보는거보다 공식문서를 보는게 나을거다.
공식문서 Documenting-your-api
블로그가 한글이고 뭐 사람들이 친절하다고 설명해놨다고 해도 그냥 공식문서가 제일 정확하고 제일 좋은거같다. 여기에 뭐 형식지정이며 뭐며 다 나온다 익숙하지 않은게 나오면 그냥 가서 읽어보고 해보면 된다.

대충 기억할 만한거는
preprocessRequest(prettyPrint()),preprocessResponse(prettyPrint()),requestFields(),responseFields(),fieldWithPath() 이정도지 않을까 그리고 생각보다 친절(?) 하다 해야되나 그냥 에러나면 타입이랑 맞게 고쳐주면 된다. 막상 문서화 하기는 어렵진않은거같다. 그냥 좀 귀찮을 뿐

결과


살짝 뿌듯하다.. 해낸거같다

저기있는 API가 7개인데 놀랍게도 7개를 API를 만드는게 아니라 설계로 문서화하는데 7시간이 걸린거같다. ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ 어이가없다. 심지어는 해결하지 못한 문제들도 있다. 앞으로 해결하면 다루지 않을까? 싶다.

해결못한걸 다뤄서 뭐해 해결하고 다뤄야지 문제 상황은 LocalDateTime이 타입이 String이 아니라 Array로 나온다.. (막상 API 만들어서 요청보내면 String임) 직렬화와 역직렬화 문제인거같은데 참 지식이 부족한건지 해결을 못하겠다.

그리고 솔직히 문서화 되긴했는데 패스밸류 부분도 /api/v1/product/{id} 이런식으로 나오는게 아니라 그냥 /api/v1/product/1 이런식으로 나와서 흠... 이부분도 모르겠다. 이건 문제는 아니고 어떤 방법이 있지않을까 ?

그리고 requestparam으로 이메일을 받으니

요래된다 api/v1/orders?email=test@gmail.com 이어야 되는데 깨지는거처럼 나온다. 이것도 문제다.
그리고 파생된 고민이 필드 1개고 공개되어도 되니까 requestParam을 쓸까? 아니면 그래도 그냥 requestBody 로 작성할까? 도 고민된다. 평소였으면 그냥 바디에 넣는데 문서화에서 requestparam 한번 해보고싶어서 해봤던 건데 개발에 맞는건 없지만 뭐가 일반적일까도 궁금하다

느낀점

너무 힘들고 느렸지만, 해내서 기뻤다. 그래도 테스트에 한걸음 다가섰다고 해야되나? 얘도 어쨋든 테스트를 통한 문서고 이걸 작성하면서 메서드들 만들고 객체들 만들고 하니까 TDD 라고 해도되나 ?
해야지 는다. 그리고 의도적으로 바뀌려고 해야한다. 계속 해오던 CRUD만 하면 뭐가 늘겠나 그리고 지금처럼 여유가 있을 때 테스트를 하고, 테스트 공부를 하고, 테스트하는 습관을 들여야된다.

이제 구현은 지금부터 시작이다 TDD를 한다고 다짐하고 해보자

참조

Spring REST Docs 공식페이지
Spring REST Docs 깃허브
Spring - REST Docs 적용 및 최적화하기
Spring Rest Docs 적용
Practical Testing: 실용적인 테스트 가이드

0개의 댓글