노트북을 열고.

나의 첫 SpringRestDocs 적용기 part 1 본문

SpringBoot

나의 첫 SpringRestDocs 적용기 part 1

ahndy84 2020. 5. 16. 18:14

※ 모든 코드는 저의 Github 에서 확인하실 수 있습니다.

0. 코드로 말합니다.


개발업무에 있어 형상관리업무만큼은 지극히 최소한으로 유지하는 게 좋다고 생각합니다.

과거엔 코드를 유지보수할 때에 Doc주석을 추가하거나 별도에 수기문서로 기록하며 관리를 하였지만 지금은 GitHub와 같은 소스 관리 도구를 통해 그 역할을 대신할 수 있습니다. 우리가 작성한 코드를 가리켜 참고해야 할 기록이 우후죽순 늘어날수록 우리가 신경 써야 할 것들도 함께 늘어나게 됩니다. 서비스를 유지 보수하면서 이러한 관리 포인트를 최소화하기 위한 노력이 무엇이 있는지 생각해보았습니다.

 

1. 코드를 최대한 간결하고 우아하게 구현하는데 관심이 있어야 합니다.

2. 코드의 무결성을 검증하는것에도 중요하죠.

3. 이러한 과정을 거친 일련의 정보를 문서 기반으로 일연 목연 하게 자동화가 된다면 더할 나위 없겠죠.

세 가지 요소는 서로가 의존하고 있습니다. 코드가 간결하지 않으면 그것을 검증하는데 어려움이 있고 검증되지 않는 코드를 기반으로 문서화가 되고 있다는 것은 결국 배가 산으로 가고 있다는 걸 방증합니다. 코드의 무결성을 검증하기 위해 더 간결하고 탄탄하게 서비스를 구현을 하게 합니다. 그 탄탄함을 근거로 일연목연한 문서를 생성하고 유지 관리해 갈 수 있게 하고요 (그것도 알아서 척척 자동으로요.)

추천영화 슈팅 라이크 베컴 (원제는 Bend it like beckham)

1. SpringRestDocs like Doctor


최근 외주업체와 협업을 진행해오며 백엔드 API 서비스구현을 담당하였습니다.

외주업체는 우리의 API서비스를 호출하여 클라이언트 페이지를 구현하는 프런트엔드를 담당하였죠. 개발착수 직전 서비스에 대한 스펙은 협의되었지만 개발을 진행해가면서 항목의 네이밍이나 타입 등과 같은 소소한 스펙이 변경되는 경우가 잦았습니다.

 

그럴 때마다 백엔드(API 서버)에 의존하는 프런트엔드 개발업무 특성상 무엇이 어떻게 변경되었는지 스펙에 대한 정보를 적시에 확인할 수 있어야 했죠. 사전 정의된 스펙 문서에 대한 정보를 그때마다 업데이트를 하지 않거나 실수로 누락하는 경우가 빈번해지면서 협업을 해가는데 있어 점차 피로가 가중되었습니다.

 

인간은 늘 실수하지만 그 실수를 반복하지 않기 위한 개선을 필요로 합니다.

 

현재의 상황을 진단하고 타개해 나갈 수 있는 무언가의 도움을 필요했습니다.

이때 등장한 이가 있었으니, 바로 오늘 이 시간에 나눌 SpringRestDocs입니다. SpringRestDocs는 우리가 구현한 API서비스를 깔끔한 HTML 형태의 문서로 생성해주는 프레임워크입니다. 현재 서비스되는 API의 스펙을 알아서 자동으로 최신화를 시켜줍니다.

 

이 프레임워크를 도입하기 위한 매우 중요한 선결조건이 있습니다.

1. 테스트코드를 작성해야만 합니다.

2. 테스트코드검증이 통과되어야 합니다.

SpringRestDocs는 우리가 구현한 코드레벨에 긴밀하게 의존합니다. 요청에 대한 응답이 검증을 할 수 있는 과정이 있어야 하고 그 결과가 정상이어야 한다는 전제를 지니고 있습니다.

 

이는 곧 테스트주도개발의 법칙을 준수하며 신뢰성 있는 서비스 개발을 지향해 갈 수 있습니다.

1. 실패한 단위 테스트를 만들기 전에는 제품코드를 만들지 않는다.

2. 컴파일이 안되거나 실패한 단위 테스트가 있으면 더 이상의 단위 테스트를 만들지 않는다.

3. 실패한 단위 테스트를 통과하는 이상의 제품코드는 만들지 않는다.

- Robert C. Martin 저. The Clean Coder  5장테스트주도 개발 130P.


이로써 기대할 수 있는 장점은 다음과 같습니다.

첫째, 용기가 올라갑니다.
믿음직한 테스트 묶음이 있으면 변경에 대한 두려움이 사라집니다.

둘째, 좋은 설계를 가져올 수 있습니다.
여러 함수를 테스트 불가능한 덩어리로 뭉치는 일을 막아줍니다.
이것은 의존성을 낮은 좋은 설계를 만드는데 힘이 됩니다.

셋째, 올바른 문서가 관리됩니다. 그것도 자동으로.

누구나 신뢰할 수 있고 이해할 수 있는 문서형태로 관리되고 제공해줄 수 있습니다.

 

이러한 업무효율 향상을 가져다줄 수 있는 SpringRestDocs은 프로젝트업무에 선택사항이 아닌 필수사항으로 봐도 무방하다고 생각합니다.

 

2. 구조를 살펴봅시다.


SpringRestDocs를 통해 검증된 TestCase가 RestDocs문서로 생성되는 과정은 다음과 같습니다.

SpringRestDocs Document가 생성되기까지

1

각 API서비스 컨트롤러에 대한 TestCase를 수행합니다. 모든 TestCase가 성공하면 다음으로 넘어가고 실패하면 종료됩니다. 즉 빌드에 실패합니다.

2

애플리케이션을 빌드하면 /build/generated-snipet/에 TestCase단위 별 API스펙 명세서가 adoc파일로 자동 생성됩니다. 우리는 이것을 스니펫(Snippet)이라 부릅니다.

3

스니펫은 /src/docs/asciidoc/에 사용자가 정의한 aodc파일에 include 하여 하나의 문서형태로 편집할 수 있습니다.

4

3번 에서 정의한 adoc파일은 빌드 시점에 Asciidoctor라는 Task과정을 거쳐 /build/asciidoc/html5에 html문서 형태로 생성됩니다.

5

/build/asciidoc/html5 경로에 html형식의 API스펙문서로 저장됩니다.

 

각 경로에 위치한 파일을 그림으로 표현하면 다음과 같습니다.

1

/build/generated-snipet/테스트명/*. adoc

testCase를 기준으로 test단위로 자동 생성되는 명세 조각
우리는 이것을 스니펫(Snippet)이라 부릅니다.

2

/src/docs/asciidoc/*. adoc

사용자가 정의한 API스펙 문서 1번 명세 파일 중 필요한 정보만 include 하여 정의

3

/build/asciidoc/html5/*. html

2의 *. adoc을 *. html로 변환

 

3. 적용해 봅시다.


적용에 진행할 개발스펙은 다음과 같습니다.

 

Open JDK 11.0.5 : 
오라클의 라이선스 정책이 변경되면서 Oracle JDK 사용을 지양하고 무료인 OpenJDK를 주로 사용합니다.

 

Spring Boot Framework 2.1.8 : 
버전업이 되어갈수록 막강해지는 SpringBoot를 사용합니다.

 

kotlin :

Java보다 간결한 문법과 강력한 TypeSafe특성을 지닌 Kotlin을 사용합니다.

 

Gradle 5.1 : 

개인적으론 Maven보다 가독성이 좋은 Gradle을 선호합니다.

 

 

다음의 순서대로 SpringRestDocs를 실제 서비스에 적용해보도록 하겠습니다.

Step 1. SpringRestDocs를 사용할 수 있는 의존성을 구성합니다.

Step 2. REST API 서비스를 구현합니다.

Step 3. 구현한 API 서비스의 TestCase를 작성합니다.

Step 4. 성공된 TestCase가 AsciDoctorTask 처리를 거쳐 html문서 형식을 생성합니다.

Step 5. 생성된 html문서를 기반으로 REST API 서버에 서비스가 가능하도록  합니다.

 

Step 1. SpringRestDocs를 사용할 수 있는 의존성을 구성


plugins {
    id("org.springframework.boot") version "2.2.8.BUILD-SNAPSHOT"
    id("io.spring.dependency-management") version "1.0.9.RELEASE"
    id("org.jetbrains.kotlin.jvm") version '1.3.71'
    id("org.jetbrains.kotlin.plugin.spring") version '1.3.71'
    id("org.jetbrains.kotlin.plugin.jpa") version '1.3.71'
    id("org.jetbrains.kotlin.plugin.noarg") version '1.3.71'
    id("org.jetbrains.kotlin.plugin.allopen") version '1.3.71'
    id("org.asciidoctor.convert") version '1.5.10'  // 1
}

apply plugin: 'io.spring.dependency-management'
apply plugin: "org.springframework.boot"
apply plugin: 'kotlin'
apply plugin: "kotlin-kapt"
apply plugin: "kotlin-spring"
apply plugin: "kotlin-jpa"
apply plugin: "kotlin-noarg"
apply plugin: "kotlin-allopen"
apply plugin: "idea"

group = "com.salt.sample"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
    mavenCentral()
    maven { url = uri("https://repo.spring.io/milestone") }
    maven { url = uri("https://repo.spring.io/snapshot") }
}

ext {
    set('snippetsDir', file("build/generated-snippets"))  // 2
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")  // 3
    testImplementation("com.nhaarman:mockito-kotlin:1.6.0")  // 4
    runtimeOnly("com.h2database:h2")
    runtimeOnly("mysql:mysql-connector-java")
}

test {  // 5
    useJUnitPlatform()
    outputs.dir snippetsDir
}

asciidoctor {  // 6
    inputs.dir snippetsDir
    dependsOn test
}

compileKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}
compileTestKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}

jar.enabled = false
bootJar.enabled = true
bootJar.mainClassName = 'com.salt.sample.restdocs.Application'

asciidoctor.doFirst {  // 7
    println("---------------- delete present asciidoctor.")
    delete file('src/main/resources/static/docs')
}

asciidoctor.doLast {  // 8
    println("---------------- asciidoctor is deleted!")
}

task copyHTML(type: Copy) {  // 9
    dependsOn asciidoctor
    from file("build/asciidoc/html5")
    into file("src/main/resources/static/docs")
}

build {  // 10
    dependsOn copyHTML
}

bootJar {  // 11
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into "BOOT-INF/classes/static/docs"
    }
}
1 TestCase에 대한 명세는 adoc 로 생성됩니다. (참고로 markdown 방식도 지원합니다.)
이렇게 생성된 *.adoc를 실제 웹서비스가 가능한 html로 변환시켜 줍니다.
2 스니펫(Snippet) 이라 블라우는 일종의 명세조각(request, response, header...)이 생성될 경로를 지정할 수 있습니다.
3 오늘의 주인공입니다. 
4 테스트케이스를 작성 시, 실제 ServiceLayer가 아닌 그와 같은 Mocking된 ServiceLayer를 사용합니다. 왜냐하면 실제 서비스 레이어를 사용하기 위해선 전체 컨텍스트를 로드하여 Bean을 주입해야 하기에 테스트를 수행할 때마다 실행속도가 현저히 느려지게 됩니다.
SpringRestDocs는 실제 빌드할 때마다 TestCase를 수행해야하기 때문에 전체 컨텍스트의 Bean을 주입하기 보다는 실제에 준하는 Mocking Bean을 이용하여 성능의 부하를 줄입니다.
5 2번에 지정해준 스니펫(Snippet)저장경로(build/generated-snippets)에 JunitTest 처리를 실행합니다.
6 6번에서 처리된 결과를 Asciidoctor형식으로 2번에 지정해 준 스니펫(Snippet)경로에 인입시킵니다.
DependsOn Test 라 명시되어 있듯 Test라는 태스크(Task)를 의존하고 있습니다.
(Test 태스크 이후에 실행됩니다.) 
7 7번의 전(Pre)처리기입니다.
처리를 시작한다는 알림로그를 만들고 기존의 만들어진 asciiDoctor형식의 문서를 초기화(삭제)시킵니다.
8 7번의 후(Post)처리기입니다.
처리를 정상적으로 마쳤다는 알림성 로그를 만듭니다.
9 DependsOn asciiDoctor 라 명시되어 있듯 asciiDoctor라는 Task를 의존하고 있습니다. (asciiDoctor 이후에 실행됩니다.) 
빌드 컨텍스트경로(build/asciidoc/html5 )에 있는 html문서를 src/main/resources/static/docs 로 Copy합니다. 이
렇게함으로 실제 애플리케이션이 실행되었을 때 생성된 명세문서를 서비스할 수 있습니다.
10 DependsOn copyDocument 라 명시되어 있듯 copyDocument라는 Task를 의존하고 있습니다.
(copyDocument 이후에 실행됩니다.)  copyDocument 가 끝나면 비로소 애플리케이션을 빌드합니다.
11 DependsOn asciidoctor 라 명시되어 있듯 asciidoctor라는 Task를 의존하고 있습니다.(asciidoctor 이후에 실행됩니다.) 
Jar로 빌드가 되면서 실제 배포시 생성된 명세 html파일의 경로를 찾아 BOOT-INF/classes/static/docs 맵핑시켜 줍니다.

 

Step 2. REST API 서비스를 구현


회원을 대상으로 등록, 조회, 수정, 삭제 서비스의 간단한 API 서비스를 구현합니다.

 

Member

@Entity
@Table(name = Member.TABLE_NAME)
class Member(memberBody: MemberBody) : BaseEntity() {

    @Id
    var id: Long = memberBody.id
    val name: String? = memberBody.name
    val joinDate: LocalDate? = memberBody.joinDate

    companion object {
        const val TABLE_NAME = "member_info"
    }
}

 

MemberController

@RestController
@RequestMapping("/member")
class MemberController(
        private val memberService: MemberService
) {

    @PostMapping
    fun createMember(@RequestBody memberBody: MemberBody): ResponseEntity<ApiResponse<Long>> {
        val response = ApiResponse.success(memberService.create(memberBody))
        return ResponseEntity.ok().body(response)
    }

    @GetMapping("/{memberId}")
    fun retrievalMember(@PathVariable memberId: Long): ResponseEntity<ApiResponse<Member>> {
        val response = ApiResponse.success(memberService.retrieval(memberId))
        return ResponseEntity.ok().body(response)
    }

    @PutMapping("/{memberId}")
    fun updateMember(
            @PathVariable memberId: Long,
            @RequestBody memberBody: MemberBody): ResponseEntity<ApiResponse<Long>> {
        val response = ApiResponse.success(memberService.update(memberBody))
        return ResponseEntity.ok().body(response)
    }

    @DeleteMapping("/{memberId}")
    fun deleteMember(@PathVariable memberId: Long): ResponseEntity<ApiResponse<Long>> {
        val response = ApiResponse.success(memberService.delete(memberId))
        return ResponseEntity.ok().body(response)
    }
}

 

MemberService

@Service
class MemberService {
    fun create(Member: Member): Long? = null
    fun update(Member: Member): Long? = null
    fun retrieval(MemberId: Long): Member? = null
    fun delete(MemberId: Long): Long? = null
}

 

Step 3. 구현한 API 서비스의 TestCase를 작성


MemberTestController (1/4)

앞서 구현한 총 4개의 회원서비스에 대한 TestCase와 SpringRestDocs이 생성되어지는 클래스를 정의합니다.

@WebMvcTest(MemberController::class)    // 1
@AutoConfigureRestDocs    // 2
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)    // 3
class MemberControllerTest(
    private var mockMvc: MockMvc,
    private var objectMapper: ObjectMapper    // 4
) {
    @MockBean
    lateinit var memberService: MemberService    // 5

    @Test
    fun `member-create`() {

        val memberId = 1L
        val memberBody = MemberBody(memberId, "salt", LocalDate.now())

        // given
        given(memberService.create(memberBody))    // 6
            .willReturn(memberId)

        // when
        val resultActions = mockMvc.perform(    // 7
            RestDocumentationRequestBuilders.post("/member")
                .header("api-key", "salt12345aaa")
                .accept(MediaType.APPLICATION_JSON_UTF8)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(objectMapper.writeValueAsString(memberBody))    // 8
        ).andDo(MockMvcResultHandlers.print())

        // then
        resultActions    // 9
            .andExpect(status().isOk)
            .andDo(
                document(
                    "member-create",    // 10
                    getDocumentRequest(),    // 11
                    getDocumentResponse(),    // 12
                    requestHeaders(    // 13
                        headerWithName("api-key")
                            .description("API 키")
                    ),
                    responseFields(    // 14
                        fieldWithPath("code")
                            .type(JsonFieldType.NUMBER)
                            .description("응답 코드"),
                        fieldWithPath("message")
                            .type(JsonFieldType.STRING)
                            .description("응답 메세지"),
                        fieldWithPath("data")
                            .type(JsonFieldType.NUMBER)
                            .description("응답데이터").optional()
                    )
                )
            )
    }
}
1 Mocking된 컴포넌트를 사용하기 위한 환경을 설정하기 위해 @WebMvcTest을 선언합니다.
2 SpringRestDocs와 관련된 코드를 손쉽게 추가할수 있게 해줍니다.
3 어노테이션을 통해서 테스트 코드에서도 생성자 주입을 가능하게 합니다.
4 RequestBody로 바인딩하고자 할때 간편하게 변환시킬 수 있게 하는 ObjectMapper를 생성자주입으로 가져옵니다.
5 실제 구현한 서비스레이어, memberService을 MockMvc를 통해 Mocking된 Bean으로 등록해줍니다.
MemberService가 TestContext에서도 존재하나 그것은 실제와 다른 Mocking(모형의) Componenet인 셈이죠.
6 Mocking된 MemberService에서 create함수에 대한 given조건을 정의합니다.
given함수에 MemberService.create(조건 파리미터) 를 인자로 받고 willReturn함수에 반환되는 결과를 정의해줍니다.
이렇게되면 실제 TestCase가 실행되면 given함수에 정의한 스펙을 기준으로 MemberService.create()가 동작하게 됩니다.
7 mvcMock을 통해 API서비스 URI를 호출하게 합니다.
실제 API 요청과 마찬가지로 AcceptionType, ContentType, content(Request정보)등 값을 부여합니다.
요청정보는 위에서 정의한 MemberController를 통해 given 조건에 정의된 조건을 기준으로 기능을 검증한다. 
8 Serialization(Object->JsonString)으로 자동으로 변환해줍니다.
9 API의 호출결과에 대한 검증항목을 정의합니다.
응답 Status를 비교하고 그 검증이 통과가 되면 andDo 함수에 정의된 대로 AsciiDocs형식의 스니펫(명세조각)들이 생성됩니다.
10 9번의 스니펫(명세조각)이 저장될 디렉토리명을 설정합니다
11 AsciiDocs형식으로 만들어질 Request 명세정보에 대한 선행처리를 적용추가합니다. (아래에 설명)
12 AsciiDocs형식으로 만들어질 Response 명세정보에 대한 선행처리를 적용합니다. (아래에 설명)
13 AsciiDocs형식으로 만들어질 Header 명세정보를 정의합니다.
14 반환되는 Response 내용을 정의합니다.

 

RestApiDocumentation

명세 문서가 생성되기 직전에 일괄적으로 해당 명세에 대한 일종의 선행 처리과정도 다음과 같이 적용할 수 있습니다.

object RestApiDocumentation {

  fun getDocumentRequest(): OperationRequestPreprocessor {  // 1
    return Preprocessors.preprocessRequest(
            modifyUris()
              .scheme("http")
              .host("salt.dev")
              .port(8085)
              .removePort(),
            Preprocessors.prettyPrint()
    )
  }

  fun getDocumentResponse(): OperationResponsePreprocessor {  // 2
    return Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
  }
}
1 요청하는 API에 대한 기본도메인, 포트를 정의합니다.
처리된 결과를 PrettyPrint() 를 통해 깔끔하게 보여지게 합니다.
2 처리된 결과에 동일하게 PrettyPrint()를 적용하였습니다.

 

자, 이제 작성한 @Test를 실행해봅니다.

@Test 옆 초록색 화살표 마우스클릭 후 Run 'MemberControllerTest...' 실행
Test 빌드화면(1/2) MockHttpServletRequest
Test 빌드화면(2/2) MockHttpServletResponse

 

우리가 설계한 테스트 시나리오대로 정상적으로 실행이 되었는지 비교해봅시다.

 

MockHttpServletRequest

MockHttpServletResponse

Given 단계에서 MemberService.create(memberBody)의 Moking을 하였습니다. 

When 단계에서 RestDocumentationRequestBuilders를 이용해 Given단계에서 Mocking 된 서비스를 호출됩니다. requestBody(=memberBody)값이 Mocking된 서비스의 의도한 대로 처리가 되었다면 WillReturn에 정의한 값으로 응답을 반환합니다.

강등권 팀 뉴캐슬에서 고생만 하다 전성기를 흘려보낸 비운의 골키퍼 No1. 셰이 기븐. given은 테스트코드의 첫 번째 과정입니다.

 

남은 조회, 수정, 삭제의 testCase도 동일하게 작성을 합니다.

 

member-retrieval

    @Test
    fun `member-retrieval`() {

        val memberId = 1L
        val memberBody = MemberBody(memberId, "salt", LocalDate.now())

        // given
        given(memberService.retrieval(memberId))
            .willReturn(Member(memberBody))

        // when
        val resultAction = mockMvc.perform(
            RestDocumentationRequestBuilders.get("/member/{memberId}", memberId)
                .header("api-key", "salt12345aaa")
                .accept(MediaType.APPLICATION_JSON_UTF8)
        ).andDo(MockMvcResultHandlers.print())

        // then
        resultAction
            .andExpect(status().isOk)
            .andDo(
                document(
                    "member-retrieval",
                    getDocumentRequest(),
                    getDocumentResponse(),
                    requestHeaders(
                        headerWithName("api-key")
                            .description("API 키")
                    ),
                    pathParameters(
                        parameterWithName("memberId")
                            .description("회원번호")
                            .maxLength(11)
                            .remarks("회원번호를 찾을 수 없습니다.")
                    ),
                    responseFields(
                        fieldWithPath("code")
                            .type(JsonFieldType.NUMBER)
                            .description("응답 코드"),
                        fieldWithPath("message")
                            .type(JsonFieldType.STRING)
                            .description("응답 메세지"),
                        fieldWithPath("data")
                            .type(JsonFieldType.OBJECT)
                            .description("응답데이터").optional()
                    ).andWithPrefix("data.",
                        fieldWithPath("id")
                            .type(JsonFieldType.NUMBER)
                            .description("회원번호"),
                        fieldWithPath("name")
                            .type(JsonFieldType.STRING)
                            .description("이름"),
                        fieldWithPath("joinDate")
                            .type(JsonFieldType.STRING)
                            .description("가입일"),
                        fieldWithPath("createdAt")
                            .type(JsonFieldType.STRING)
                            .description("생성일"),
                        fieldWithPath("updatedAt")
                            .type(JsonFieldType.STRING)
                            .description("수정일")
                    )
                )
            )
    }

 

member-update

    @Test
    fun `member-update`() {

        val memberId = 1L
        val memberBody = MemberBody(memberId, "sugar", LocalDate.now())

        // given
        given(memberService.update(memberBody))
            .willReturn(memberId)

        // when
        val resultActions = mockMvc.perform(
            RestDocumentationRequestBuilders.put("/member/{memberId}", memberId)
                .header("api-key", "salt12345aaa")
                .accept(MediaType.APPLICATION_JSON_UTF8)
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(objectMapper.writeValueAsString(memberBody))
        ).andDo(MockMvcResultHandlers.print())

        // then
        resultActions
            .andExpect(status().isOk)
            .andDo(
                document(
                    "member-update",
                    getDocumentRequest(),
                    getDocumentResponse(),
                    requestHeaders(
                        headerWithName("api-key")
                            .description("API 키")
                    ),
                    pathParameters(
                        parameterWithName("memberId")
                            .description("회원번호")
                            .maxLength(11)
                            .remarks("회원번호를 찾을 수 없습니다.")
                    ),
                    responseFields(
                        fieldWithPath("code")
                            .type(JsonFieldType.NUMBER)
                            .description("응답 코드"),
                        fieldWithPath("message")
                            .type(JsonFieldType.STRING)
                            .description("응답 메세지"),
                        fieldWithPath("data")
                            .type(JsonFieldType.NUMBER)
                            .description("응답데이터").optional()
                    )
                )
            )
    }


member-delete

    @Test
    fun `member-delete`() {

        val memberId = 1L

        // given
        given(memberService.delete(memberId))
            .willReturn(memberId)

        // when
        val resultActions = mockMvc.perform(
            RestDocumentationRequestBuilders.delete("/member/{memberId}", memberId)
                .header("api-key", "salt12345aaa")
                .accept(MediaType.APPLICATION_JSON_UTF8)
            ).andDo(MockMvcResultHandlers.print())

        // then
        resultActions
            .andExpect(status().isOk)
            .andDo(
                document(
                    "member-delete",
                    getDocumentRequest(),
                    getDocumentResponse(),
                    requestHeaders(
                        headerWithName("api-key")
                            .description("API 키")
                    ),
                    pathParameters(
                        parameterWithName("memberId")
                            .description("회원번호")
                            .maxLength(11)
                            .remarks("회원번호를 찾을 수 없습니다.")
                    ),
                    responseFields(
                        fieldWithPath("code")
                            .type(JsonFieldType.NUMBER)
                            .description("응답 코드"),
                        fieldWithPath("message")
                            .type(JsonFieldType.STRING)
                            .description("응답 메세지"),
                        fieldWithPath("data")
                            .type(JsonFieldType.NUMBER)
                            .description("응답데이터").optional()
                    )
                )
            )
    }

 

Step 4. AsciDoctor 문서 작성하기


Step 3에서 설계한 테스트 케이스를 통해 API 문서를 생성할 수 있습니다. 여기서 한 가지 더 거쳐야 할 중요한 단계가 있습니다. 바로 자동화시켜내는 API 문서를 어떻게 구성할 것인가?이지요.

 

이것은 또 다른 API문서 자동화 도구인 Swagger에 비해 SpringRestDocs이 지닌 강점이라 할 수 있습니다.

It combines hand-written documentation written with  Asciidoctor and auto-generated snippets produced with Spring MVC Test. This approach frees you from the limitations of the documentation produced by tools like Swagger.
- https://spring.io/projects/spring-restdocs

 

단순히 API문서의 자동생성 유무를 넘어 문서의 구성이나 형태를 모두 자유자재로 커스터마이즈가 가능합니다. 마치 html문서를 작성하듯 말이죠. 이쯤이면 SpringRestDocs는 그저 기계적인 틀에 맞춰 찍어내는 단순한 자동화 도구가 아니고, 우리의 필요에 맞게 문서의 템플릿을 구성하고 그 구성에 맞게 테스트 코드를 문서로 자동화시켜 준다는 것이 더 정확한 표현일 것입니다.

 

템플릿의 단위, 스니펫(Snippet)

시작할 때에 스니펫이라는 단어를 자주 사용하였습니다. 스니펫은 SpringRestDocs 문서를 이루고 있는 일종의 템플릿입니다. API 서비스에 필요한 URL, 요청정보, 응답 정보, 헤더 정보 등 그 정보를 구성하는 하나의 조각입니다.

 

SpringRestDocs는 빌드를 수행할 때마다 각 테스트 케이스단위로 기본적으로 6개의 스니펫조각을 생성합니다. 그리고 필요에 따라 스니펫을 추가하거나 수정도 가능합니다.

 

앞서 정의된 build.gradle를 실행해 봅시다.

$ ./gradle build

또는 (IntelliJ IDE인 경우)

화면 좌측 Gradle 탭에서 프로젝트의 Task-build-build 클릭

 

빌드가 끝난 후, build/generated-snippets 경로를 가보면 테스트케이스 함수명 단위로 디렉터리가 생성되면서 각 디렉터리마다 6개의 기본 스니펫이 자동으로 생성됩니다.

(아래의 경우 response-code-field.adoc의 경우 커스텀으로 추가한 스니펫으로 나중에 다시 설명드리겠습니다.)

 

자동으로 생성되는 6개의 기본 스니펫(Snippet)이 어떤 것인지 살펴봅시다.

 

curl-request.adoc

[source,bash]
----
$ curl 'http://localhost:8080/response-code' -i -X GET \
    -H 'Accept: application/json'
----

http-request.adoc

[source,http,options="nowrap"]
----
GET /response-code HTTP/1.1
Accept: application/json
Host: localhost:8080

----

http-response.adoc

[source,http,options="nowrap"]
----
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 39

{"code":200,"message":"OK","data":null}
----

httpie-request.adoc

[source,bash]
----
$ http GET 'http://localhost:8080/response-code' \
    'Accept:application/json'
----

request-body.adoc

[source,options="nowrap"]
----

----

response-body.adoc

[source,options="nowrap"]
----
{"code":200,"message":"OK","data":null}
----

 

SpringRestDocs에서 기본적으로 제공하는 이 6개의 스니펫 조각을 가지고 우리가 관리할 문서에게 차곡차곡 붙여 넣을 수 있습니다. 실제 적용할 API문서를 경로 생성하고 그 문서에 해당 스니펫을 붙여 넣어 보도록 하겠습니다.

 

그렇게 adoc문서로 스니펫 조각들을 포함시킬 수 있습니다. Asciidoc 문서에 대한 사용법은 처음 작성할 때 다소 어색할 수도 있습니다. 기본 문법에 대한 문서는 아래의 링크에 쉽게 설명이 되어 있으니 참고해주세요.

 

AsciiDoc Writer’s Guide
https://asciidoctor.org/docs/asciidoc-writers-guide/

AsciiDoc Writer’s Guide 우리의 친구 한글 번역

https://narusas.github.io/2018/03/21/Asciidoc-basic.html

 

위와 같이 회원 조회(member-retrieval), 회원 수정(member-update), 회원 삭제(member-delete)도 각각 폴더에 생성된 스니펫 조각을 가지고  asciidoc경로(src/docs/asciidoc/)에 API 스펙 문서를 생성합니다. 이렇게 해서 생성된 총 4개의 문서에 대한 색인 페이지(index.adoc)도 만들어 보겠습니다.

 

index.adoc

= Rest Docs API Document
ahndy84.tistory.com
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks: /build/asciidoc/html5/

[[introduction]]
== 소개
API Test 코드를 기반으로 RestDocs 문서를 생성해 봅니다.

[[introduction]]
== 서비스환경
해당 API서비스의 서버 도메인 환경은 다음과 같습니다.

=== Domain
|===
| 환경 | URI

| 개발서버
| `salt.dev`

| 운영서버
| `salt.prod`
|===

ifndef::snippet[]
:snippet: ../../../build/generated-snippets
:root: ./
endif::[]

= 공통 Response Code
include::{snippet}/common/response-code-fields.adoc[]

= 사용자 생성 API
include::{root}/member-create.adoc[]

= 사용자 조회 API
include::{root}/member-retrieval.adoc[]

= 사용자 수정 API
include::{root}/member-update.adoc[]

= 사용자 삭제 API
include::{root}/member-delete.adoc[]

이렇게 AsciiDoctor 문서 형태를 기준으로 SpringRestDocs 문서화까지 모두 완료하였습니다. 정리하면 1. TestCase 코드를 기반으로 Build시점에 생성되는 Snippet이라 불리는 명세 조각을 기반으로 2. 각 API 서비스별 명세 문서(member-create, member-update...)를 만들었습니다. 3. 그렇게 만들어진 각 API 서비스를 색인화시키는 index.adoc도 만들었습니다. 이 구조를 그림으로 표현하면 아래와 같습니다.

 

완성된 AsciiDoctor기반 API 명세문서 구조

 

Step 4. AsciiDoctorTask 처리를 거쳐 html문서 형식을 생성


AsciiDoctor기반의 문서화 자동화를 하기 위한 작업은 이제 완료가 되었습니다. 궁극적으로는 AsciiDocrtor 기반의 문서는 웹을 위한 문서가 아닙니다. API 명세 문서가 웹 기반 언어인 HTML로 변환이 되는 과정까지 온전히 이루어져야만 온전히 우리가 원하는 제품(서비스)의 온전한 형상관리를 할 수 있습니다. 이제 우리가 원하는 과정을 구현해 봅시다.

(앞서 이 과정을 거쳤습니다만 다시 한번 이해를 위해 살펴봅시다.)

ext {
    set('snippetsDir', file("build/generated-snippets"))
}

test {
    useJUnitPlatform()
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    dependsOn test    <----
}

asciidoctor.doFirst {
    println("---------------- delete present asciidoctor.")
    delete file('src/main/resources/static/docs')
}

asciidoctor.doLast {
    println("---------------- asciidoctor is deleted!")
}

task copyHTML(type: Copy) {
    dependsOn asciidoctor    <----
    from file("build/asciidoc/html5")
    into file("src/main/resources/static/docs")  // resources/static/docs 로 복사하여 서버가 돌아가고 있을때 /docs/index.html 로 접속하면 볼수 있음
}

build {
    dependsOn copyHTML
}

bootJar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into "BOOT-INF/classes/static/docs"
    }
}

build.gradle에서 TestCase를 통해 AsciiDoctor 문서가 생성하고 생성한 AsciiDoctor문서를 HTML5 문서로 변환(빌드)하여 애플리케이션 클래스 패스의 특정 경로(/src/main/resources/static/docs)에 저장하는 구조입니다.

 

여기서 유심히 봐야 할 부분이 dependsOn입니다. Gradle은 다음의 작업(Task) 단위로 모델링하여 Build가 가능합니다. 

1. 파일 복사나 또는 소스 컴파일과 같은 작업을 수행
2. 파일 및 디렉터리에 파일저장 또는 수정을 수행
3. 파일 및 디렉토리에 파일 생성을 수행

 

이러한 작업 단위 방식으로 작업 그래프로 모델링하여 자체 빌드 스크립트를 정의할 수 있습니다.

 

출저 : https://docs.gradle.org/current/userguide/what_is_gradle.html

 

이번 SpringRestDocs 적용에서도 마찬가지로 작압(Task) 단위 방식을 쪼개 모델링을 하였습니다.

Task: test 실행에 따라 Task: asciidoctor 실행되는 의존 구조(DependOn)를 가지고 있습니다. 이 뿐 아니라 Task: asciidoctor에서도 전처리기(doFirst)와 후처리기(doLast)가 정의되어 있죠. 이것은 프로젝트가 build 될 때마다 기존에 생성된 API 명세 문서를 일 삭제한 후 다시 빌드시점에 API 명세문서를 생성하여 문서를 자동으로 최신화를 시키기 위한 목적입니다.

 

이제 AsciiDoct으로 작성된 API문서가 어떤 과정을 거쳐 애플리케이션 상의 웹서비스 가능한 HTML 문서로 변환해 낼 수 있는지 알 수 있습니다.

 

Step 5. 생성된 html문서를 기반으로 REST API 서버에 서비스가 가능


마지막으로 이렇게 HTML포맷으로 완성된 API 명세 문서를 실제 애플리케이션이 실행될 때에 서비스가 가능하도록 하겠습니다. HTML포맷의 API명세 문서는 앞서 build.gradle에서 작성한 것과 같이 지정된 경로(src/main/resources/static/docs)에 저장되고 있습니다. WebMvcConfigurer를 상속받는 @Configuration 어노테이션 기반 환경 구성 클래스를 생성합니다. 그리고 그 경로에 있는 리소스 자원(HTML 파일)을 직접 서빙할 수 있도록 addResourceHandler 함수를 오버 라이딩합니다.

 

WebMvcConfig

@Configuration
class WebMvcConfig: WebMvcConfigurer {

    override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
        registry.addResourceHandler("/api-doc/**").addResourceLocations("classpath:/static/docs/")
    }
}

이제 애플리케이션을 직접 실행해 보도록 하겠습니다.

웹 브라우저를 열고 http://localhost:8080/api-doc/index.html을 접속해 봅시다.

 

위와 같이 TestCase를 기반으로 API 명세 문서가 깔끔하게 서비스됩니다. 그리고 왼쪽 상단에는 다음과 같이 색인목록도 함께 표시가 됩니다. 

index 색인목차

이제 우리는 우아하게 비즈니스 코드를 구현하고 그것을 꼼꼼히 검증하는 과정에만 몰두하면 됩니다. 그 이후의 작업은 마치 청진기 대면 딱 답이 나오는 똑똑한 Doctor와 같은 SpringRestDocs를 통해 우리의 서비스 코드에 대한 진단서(명세)를 자동화하여 보여줄 테니깐요.

 

4. 나의 첫 SpringRestDocs 적용기 그리고..


전 직장에서 공공기관 SI 개발업무를 수행해오면서 가장 진절머리가 났던 업무는 바로 직접 수기로 제품의 형상 문서를 관리해오는 일이었습니다.

 

대개 공공기관으로부터 수주해오는 SI사업 진행 말미에는 고객님의 요구사항을 이러이러하게 잘 만들었다는 일종의 증적 자료를 만들어서 CD로 구워 제출하는 것이 그 사업의 방점을 찍는 관례입니다. 프로젝트 말미에는 모두가 그렇게 Excel2000이  띄어진 화면에서 따닥따닥 키보드를 두드려가며 하루 일과를 보내게 됩니다. 사업 준공시점에 다다르면 개발자의 몸에서 사리가 나오기 시작하고 수기로 작성한 문서 파일의 양이 클래스 파일의 양을 방불케 합니다. 

 

이거 너무한거 아니냐고.

 

이번 협업 프로젝트에서 Spring RestDocs를 본격적으로 활용하였습니다. 과거 현업에서 만들어왔던 문서와 지금의 그것은 존재의 이유와 역할은 분명하게 다를 수밖에 없지요. 문서의 품질을 떠나 그러한 기존의 방식으로 변화무쌍한 우리의 서비스의 지속해서 형상 관리해 가기란 매우 귀찮을 뿐 아니라 비효율적이며 그게 가능하다 해도 해선 안될 만큼 다양한 잠재위험(다시 말하지만 인간의 실수는 끝이 없..)이 도사리고 있기 때문입니다.

 

물론 다 그러하듯 도구에 대한 숙련도가 무지한 첫 도입기라 여러 우여곡절도 있었습니다.  간략히 정리해보자면 


장점

- 비즈니스 로직에 집중하고 그것을 설명하는 과정을 생략할 수 있었습니다.

- 테스트 결과 기반으로 문서가 생성되기 때문에 서비스 본연의 (최소한의) 기능을 담보할 수 있었습니다.

- TDD 개발 습관을 자연스럽게 체득할 수 있었습니다.

 

아쉬운 점

- 빌드할 때에  시간이 더 소요되었습니다.

- 중복적이고 반복적인 코드가 많아집니다.

- 문서에 대한 편집이 기대했던 것보단 수동적이고 제한적이었습니다.


 

아쉬운 점에 나열된 부분은 그것이 가진 한계라기보단 제가 아직 그것을 온전치 못한 활용으로 생긴 부가적인 이슈입니다.  다음 시간에는 그 아쉬운 점을 대해 우리가 해결한 방법에 대해 공유해보고자 합니다.

 


나의 첫 Spring Rest Docs 적용기 part.2에서는

  - 중복적이고 반복적인 SpringRestDocs를 위한 TestCase 코드를 구조화하는 과정

  - 스니펫(Snippet)을 정의하는 필드 함수 정리

  - 더 다양한 커스텀 스니펫을 통한 API 명세 개선하기


읽어주셔서 감사합니다.

 

 

 

4. 참고한 곳


1. Carrey's 기술 블로그

 

공부하고, 경험하고, 삽질해서 얻은 지식들을 기록합니다. | Carrey`s 기술블로그

Spring Batch에서 Chunk 작업이 길어지는 경우 주의할 점 2020-08-08|Springboot-Hikari CP-Spring Batch 들어가며 Spring Batch 실행 중 아래와 같은 에러 메세지를 확인 하는 경우 이 글에서 설명하는 case일 수 있습��

jaehun2841.github.io

2. 우아한 형제들 기술 블로그 [Spring REST Docs에 날개를...]

 

Spring REST Docs에 날개를... (feat: Popup) - 우아한형제들 기술 블로그

안녕하세요? 우아한형제들에서 정산시스템을 개발하고 있는 이호진입니다.

woowabros.github.io

3. Gradle 공식 사이트

 

What is Gradle?

Gradle is an open-source build automation tool that is designed to be flexible enough to build almost any type of software. The following is a high-level overview of some of its most important features: High performance Gradle avoids unnecessary work by on

docs.gradle.org

4. narusas's blog

 

Asciidoc 기본 사용법

Asciidoc의 기본 문법을 설명한다

narusas.github.io

 

※ 모든 코드는 저의 Github 에서 확인하실 수 있습니다.

'아는 것'보다 '알아야할 것'이 더 많은 늦깎이 주니어개발자입니다.

알고 있는 지식을 전한다는 목적 보단 막 알게된 지식을  스스로 정리하는 차원에서 포스팅하고 있으니 잘못된 내용이나 부족한 부분이 있더라도 겸허한 이해 부탁드립니다. 댓글이나 쪽지를 통해 첨삭의견주시면 감사히 수렴하고 보완하겠습니다.

Comments