API 를 개발하면 가장 중요한 부분이 바로 API 사용법 일 것 입니다. 그리고 다른 사람이 이 API 사용법을 빠르게 파악하기 위해서는 API 에 관련된 내용(URL, 요청에 필요한 데이터, 응답 등)을 잘 정리해놓는 것이 중요합니다.
Spring 에서는 이러한 부분을 Spring Rest Docs 로 해결합니다.
본 포스팅은 아래 환경을 기준으로 작성되었습니다.
개발환경 : SpringBoot 3.1.5 / Java 17
1. Spring Rest Docs 설정하기
spring rest docs 를 사용하기 위해선 build.gradle 에 아래와 같은 설정이 필요합니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.5'
id 'io.spring.dependency-management' version '1.1.3'
// 1) asciidoctor 플러그인 추가
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
// == 중략 == //
configurations {
compileOnly {
extendsFrom annotationProcessor
}
// 2) asciidoctor 의존성 설정 선언
asciidoctorExt
}
// == 중략 == //
dependencies {
// == 중략 == //
// 3) Spring Rest Docs 관련 test implement 추가 및 asciidoctor 의존성 추가
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.0.0'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.0'
}
// 4) Spring Rest Docs 관련 gradle 명령어 추가
// Start
ext {
snippetsDir = file('build/generated-snippets')
}
test {
outputs.dir snippetsDir
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
bootJar {
dependsOn asciidoctor
copy {
from asciidoctor.outputDir
into 'src/main/resources/static/docs'
}
}
// End
tasks.named('test') {
useJUnitPlatform()
}
위 설정이 각각 뭘 의미하는지는 아래 공식문서를 참고하면 됩니다.
본 포스팅은 자세한 설명보단 사용방법 위주로 설명합니다.
https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/#getting-started
위와 같이 설정을 완료한 후 gradle 을 reload 했다면, IDE 에서 아래와 같이 bootjar 명령어가 존재하는지 확인해야 합니다.
2. Mvc Test 에 Rest Docs 적용해보기
이제 실제로 테스트 코드에 Rest Docs 설정을 추가해보도록 하겠습니다.
먼저, 테스트 클래스에 annotation 을 추가합니다.
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@ExtendWith(RestDocumentationExtension.class)
public class UserControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
UserService userService;
@Autowired
ObjectMapper objectMapper;
}
RestDocs 사용을 위해 @AutoConfigureRestDocs 와 @ExtendWith(RestDocumentationExtension.class) 를 추가합니다.
그 다음, 테스트 케이스 작성 시에 필요한 MockMvc 와 ObjectMapper 를 주입받고, Mocking 하고자 하는 Service 가 있다면 MockBean 으로 선언해줍니다.
그 다음, 테스트 케이스에는 아래와 같이 API 테스트를 위한 코드를 작성합니다.
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@ExtendWith(RestDocumentationExtension.class)
public class UserControllerTest {
// == 중략 == //
@Test
@DisplayName("사용자가 자신의 회원정보를 조회한다.")
void find_user() throws Exception {
// given
when(userService.findUser(any())).thenReturn(
new UserDto(
100L, "사용자100",
"user100@test.com", "010-1234-2345",
LocalDateTime.of(2022, 12, 22, 11, 34, 19)
)
);
// when & then
mockMvc.perform(RestDocumentationRequestBuilders.get("/users")
.header("X-GATEWAY-AUTH-HEADER", 100L)
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("id", is(100)))
.andExpect(jsonPath("signUpDate", is("2022-12-22")))
.andDo(document("find_user",
responseFields(
fieldWithPath("id").description("사용자 고유 ID"),
fieldWithPath("name").description("사용자 이름"),
fieldWithPath("email").description("사용자 Email"),
fieldWithPath("telNo").description("사용자 연락처"),
fieldWithPath("signUpDate").description("회원가입 일자")
)
))
;
}
}
위 테스트 코드에서 주목해야 하는 부분은 마지막 andDo()의 내용입니다.
먼저, document() 메서드의 "find_user" 는 API 테스트를 통해 Spring Rest Docs 가 만드는 adoc 파일을 포함할 폴더의 이름 입니다.
Spring Rest Docs 는 설정한 후에 테스트를 실행하면, 테스트 성공 시 특정 패키지에 adoc 파일을 생성합니다. 개발자는 해당 adoc 파일들을 적절하게 조합하여 원하는 형태의 API 문서를 html 로 생성할 수 있게되는 원리입니다.
(해당 내용은 좀 더 뒤에서 결과를 보면서 추가로 확인해보겠습니다.)
그리고 responseFields() 메서드를 통해, 응답 받고자 하는 body의 정보들이 각각 어떤 값이고, 어떤 의미인지를 명시해줍니다.
위 코드를 예로 들면, 응답으로 넘어오는 항목들은 아래와 같다는 의미입니다.
requestFields() 의 경우, 여러 request 형태에 따라 다른 메서드들을 제공하는데, 대표적으로 3가지만 다뤄보겠습니다.
1. RequestBody
request body 를 spring rest docs 로 처리하는 예시는 다음과 같습니다.
public class UserControllerTest {
// == 중략 == //
@Test
@DisplayName("사용자가 회원가입에 성공한다.")
void sign_up() throws Exception {
// given
SignUpRequest signUpRequest = new SignUpRequest(
"신규 가입자", "new@test.com", "new1!", "new1!", "010-1234-1234"
);
String content = objectMapper.writeValueAsString(signUpRequest);
when(userService.signUp(any())).thenReturn(
UserDto.builder()
.id(1L)
.name("신규 가입자")
.email("new@test.com")
.telNo("010-1234-1234")
.signUpDate(LocalDateTime.of(2023, 8, 25, 12, 1, 2))
.build()
);
// when & then
mockMvc.perform(post("/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(content))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("name", is("신규 가입자")))
.andDo(document("signUp",
requestFields(
fieldWithPath("name").description("이름"),
fieldWithPath("email").description("가입 이메일"),
fieldWithPath("password").description("비밀번호"),
fieldWithPath("confirmPassword").description("비밀번호 확인"),
fieldWithPath("telNo").description("연락처")
),
responseFields(
fieldWithPath("id").description("사용자 고유 ID"),
fieldWithPath("name").description("이름"),
fieldWithPath("email").description("가입 이메일"),
fieldWithPath("telNo").description("가입 연락처"),
fieldWithPath("signUpDate").description("가입일자")
)
));
}
}
일반적인 회원가입 API 의 성공 사례를 테스트하는 코드입니다. request body 에 포함되는 정보를 Spring Rest Docs 에 표시하기 위해 requestFields() 메서드의 파라미터로 fieldWithPath().description() 메서드를 사용했습니다.
fieldWithPath() 메서드의 파라미터로는 Request Body 에 포함되는 필드명을 명시하고, description() 메서드의 파라미터로는 해당 field 가 어떤 필드인지에 대한 설명을 넣습니다.
위 코드를 예시로 들면, 회원가입을 위해 어떤 필드가 필요로 하고, 각 필드에 대한 설명을 아래와 같이 나타내게 됩니다.
2. RequestParam
RequestParam 을 spring rest docs 로 처리하는 예시는 다음과 같습니다.
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@ExtendWith(RestDocumentationExtension.class)
class UserMicroServiceControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
UserQueryDAO userQueryDAO;
@Test
@DisplayName("특정 회원등급 이상의 사용자를 조회한다.")
void get_user_ids_above_grade() throws Exception {
// given
when(userQueryDAO.getUserIdsAboveGrade(any())).thenReturn(
Arrays.asList(1L, 2L, 3L)
);
// when & then
mockMvc.perform(get("/users/above-grade?targetGrade=REGULAR")
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andDo(document("get_user_above_grade",
queryParameters(
parameterWithName("targetGrade").description("회원 등급")
)
)
)
;
}
}
RequestParam 의 경우, RequestDocumentation.queryParameter() 를 통해 어떤 쿼리 파라미터가 넘어가는지 명시할 수 있습니다. parameterWithName() 으로 파라미터의 key 값을 지정해주고, description() 을 통해 해당 파라미터가 무엇을 의미하는지 명시합니다.
3. PathVariable
PathVariable 을 spring rest docs 로 처리하는 예시는 다음과 같습니다.
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@ExtendWith(RestDocumentationExtension.class)
class UserControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
UserService userService;
@Test
@DisplayName("로그인 사용자가 자신의 등급정보를 조회한다.")
void find_user_grade_info() throws Exception {
// given
when(userService.getUserGradeInfo(any())).thenReturn(
new UserGradeInfoDto(
1000L, "사용자1000",
LocalDateTime.of(2022, 12, 22, 0, 0, 0),
UserGrade.REGULAR, UserGrade.VIP,
50, 10000
)
);
// when & then
mockMvc.perform(RestDocumentationRequestBuilders.get("/users/{id}/grade-info", 1000L)
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("userId", is(1000)))
.andExpect(jsonPath("userName", is("사용자1000")))
.andExpect(jsonPath("signUpDate", is("2022-12-22")))
.andExpect(jsonPath("currentUserGrade", is("단골회원")))
.andExpect(jsonPath("nextUserGrade", is("VIP")))
.andDo(document("find_user_grade_info",
pathParameters(
parameterWithName("id").description("사용자 고유 ID")
),
responseFields(
fieldWithPath("userId").description("사용자 고유 ID"),
fieldWithPath("userName").description("사용자 이름"),
fieldWithPath("signUpDate").description("가입일자"),
fieldWithPath("currentUserGrade").description("현재 회원등급"),
fieldWithPath("gradeDiscountRate").description("등급 할인율"),
fieldWithPath("nextUserGrade").description("다음 회원등급"),
fieldWithPath("remainedOrderCountForNextGrade").description("다음 회원등급 승급까지 남은 주문 수"),
fieldWithPath("remainedAmountsForNextGrade").description("다음 회원등급 승급까지 남은 주문 금액")
)
))
;
}
}
PathVariable 같은 경우, RequestDocumentation.pathParameters() 을 사용해 path 변수를 명시합니다.
parameterWithName() 에는 path 에 사용되는 변수를 명시하고, description() 은 해당 path 에 어떤 값이 들어가는지 명시합니다.
3. API 문서 만들기
spring rest docs 를 포함시켜 테스트 코드를 완성하면 아래와 같은 절차를 통해 API 문서를 생성할 수 있습니다.
- 테스트 코드를 실행한다.
- build.gradle 에서 설정한 경로 하위에 각 테스트 케이스 별로 설정한 이름을 가진 폴더 밑에 여러 adoc 파일이 생성된다.
- 해당 adoc 파일들을 조합하여 실제 API 문서로 변환될 adoc 파일을 만든다.
- build.gradle 에서 설정한 bootjar 명령어를 실행한다.
- resources 폴더 하위에 3번 과정에서 만든 adoc 파일과 동일한 이름을 가지는 html 파일이 생성된다.
- 서비스를 실행 후, 5번에서 만들어진 html 파일을 브라우저로 확인한다.
1. 테스트 코드를 실행한다.
아래 작성된 테스트 코드를 API 문서로 만들기 위해 테스트를 실행합니다.
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@ExtendWith(RestDocumentationExtension.class)
public class UserControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
UserService userService;
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("사용자가 회원가입에 성공한다.")
void sign_up() throws Exception {
// given
SignUpRequest signUpRequest = new SignUpRequest(
"신규 가입자", "new@test.com", "new1!", "new1!", "010-1234-1234"
);
String content = objectMapper.writeValueAsString(signUpRequest);
when(userService.signUp(any())).thenReturn(
UserDto.builder()
.id(1L)
.name("신규 가입자")
.email("new@test.com")
.telNo("010-1234-1234")
.signUpDate(LocalDateTime.of(2023, 8, 25, 12, 1, 2))
.build()
);
// when & then
mockMvc.perform(post("/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(content))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("name", is("신규 가입자")))
.andDo(document("signUp",
requestFields(
fieldWithPath("name").description("이름"),
fieldWithPath("email").description("가입 이메일"),
fieldWithPath("password").description("비밀번호"),
fieldWithPath("confirmPassword").description("비밀번호 확인"),
fieldWithPath("telNo").description("연락처")
),
responseFields(
fieldWithPath("id").description("사용자 고유 ID"),
fieldWithPath("name").description("이름"),
fieldWithPath("email").description("가입 이메일"),
fieldWithPath("telNo").description("가입 연락처"),
fieldWithPath("signUpDate").description("가입일자")
)
));
}
@Test
@DisplayName("로그인 사용자가 자신의 등급정보를 조회한다.")
void find_user_grade_info() throws Exception {
// given
when(userService.getUserGradeInfo(any())).thenReturn(
new UserGradeInfoDto(
1000L, "사용자1000",
LocalDateTime.of(2022, 12, 22, 0, 0, 0),
UserGrade.REGULAR, UserGrade.VIP,
50, 10000
)
);
// when & then
mockMvc.perform(get("/users/{id}/grade-info", 1000L)
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("userId", is(1000)))
.andExpect(jsonPath("userName", is("사용자1000")))
.andExpect(jsonPath("signUpDate", is("2022-12-22")))
.andExpect(jsonPath("currentUserGrade", is("단골회원")))
.andExpect(jsonPath("nextUserGrade", is("VIP")))
.andDo(document("find_user_grade_info",
pathParameters(
parameterWithName("id").description("사용자 고유 ID")
),
responseFields(
fieldWithPath("userId").description("사용자 고유 ID"),
fieldWithPath("userName").description("사용자 이름"),
fieldWithPath("signUpDate").description("가입일자"),
fieldWithPath("currentUserGrade").description("현재 회원등급"),
fieldWithPath("gradeDiscountRate").description("등급 할인율"),
fieldWithPath("nextUserGrade").description("다음 회원등급"),
fieldWithPath("remainedOrderCountForNextGrade").description("다음 회원등급 승급까지 남은 주문 수"),
fieldWithPath("remainedAmountsForNextGrade").description("다음 회원등급 승급까지 남은 주문 금액")
)
))
;
}
@Test
@DisplayName("특정 회원등급 이상의 사용자를 조회한다.")
void get_user_ids_above_grade() throws Exception {
// given
when(userService.getUserIdsAboveGrade(any())).thenReturn(
Arrays.asList(1L, 2L, 3L)
);
// when & then
mockMvc.perform(get("/users/above-grade?targetGrade=REGULAR")
.contentType(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andDo(document("get_user_above_grade",
queryParameters(
parameterWithName("targetGrade").description("회원 등급")
)
)
)
;
}
}
2. adoc 파일 확인
테스트를 실행 후, 모든 테스트가 성공했다면 build.gradle 에 설정해놓은 snippetsDir 경로에 adoc 파일 여부를 확인합니다.
ext {
snippetsDir = file('build/generated-snippets')
}
3. API 문서로 변환될 adoc 파일 생성
2번에서 생성된 adoc 파일들을 활용해 실제로 API 문서로 만들 adoc파일을 생성합니다.
생성 경로는 src/docs/asciidoc 폴더 하위 입니다.
그 후, 해당 adoc 파일 내에 2번에서 생성된 adoc 파일을 Include 하여 API 문서에 담을 내용을 지정합니다.
```user.adoc
= User Micro Service API of Shopping Mall
:toc:
== 회원가입
=== 요청
include::{snippets}/signUp/http-request.adoc[]
include::{snippets}/signUp/request-fields.adoc[]
=== 응답
include::{snippets}/signUp/http-response.adoc[]
include::{snippets}/signUp/response-fields.adoc[]
== 회원등급 정보 조회
=== 요청
include::{snippets}/find_user_grade_info/http-request.adoc[]
include::{snippets}/find_user_grade_info/path-parameters.adoc[]
=== 응답
include::{snippets}/find_user_grade_info/http-response.adoc[]
include::{snippets}/find_user_grade_info/response-fields.adoc[]
== 특정 회원등급 이상의 사용자 조회
=== 요청
include::{snippets}/get_user_above_grade/http-request.adoc[]
include::{snippets}/get_user_above_grade/query-parameters.adoc[]
=== 응답
include::{snippets}/get_user_above_grade/http-response.adoc[]
4. bootjar 명령 실행
3번에서 생성한 adoc 파일을 html 로 변환하기 위해 build.gradle 에서 명시한 bootjar 명령어를 실행해야 합니다.
bootJar {
dependsOn asciidoctor
copy {
from asciidoctor.outputDir
into 'src/main/resources/static/docs'
}
}
해당 명령어를 쉽게 말하자면, 3번에서 생성한 adoc 파일을 html로 변환하여 src/main/resources/static/docs 라는 경로에 생성한다는 의미입니다.
html 생성을 위해 resources/static 하위에 docs 폴더를 생성합니다.
그 후, build.gradle 파일에 bootjar 좌측에 버튼을 클릭하여 gradle 명령을 실행합니다.
성공 시, 아래 콘솔창에 아래와 같은 로그가 발생합니다.
6:45:09 PM: Executing 'bootJar'...
Starting Gradle Daemon...
Gradle Daemon started in 707 ms
> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
// 기타 로그는 길어서 생략
> Task :asciidoctor
2023-12-03T18:45:27.028+09:00 [main] WARN FilenoUtil : Native subprocess control requires open access to the JDK IO subsystem
Pass '--add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED' to enable.
> Task :resolveMainClassName UP-TO-DATE
> Task :bootJar UP-TO-DATE
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
For more on this, please refer to https://docs.gradle.org/8.2.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
BUILD SUCCESSFUL in 18s
7 actionable tasks: 3 executed, 4 up-to-date
6:45:28 PM: Execution finished 'bootJar'.
5. html 파일 생성 확인
4번에서 생성한 resources/static/docs 파일 하위에 user.html 파일의 생성 여부를 확인합니다.
간혹, 한 번에 생성이 되지 않는 경우가 있는데 이 때는 시간을 좀 두었다가 다시 실행하면 정상적으로 생성됩니다.
6. API 문서 확인
이제 서비스를 실행하여 해당 파일을 브라우저에서 호출해보면, 아래와 같이 API 문서가 조회되는 것을 확인할 수 있습니다.
각 기능별로 확인해보면 아래와 같이 조회됩니다.
4. 마치며
spring rest docs 를 통해 API 문서를 만들어봤습니다.
spring rest docs 은 아래와 같은 특징을 가집니다.
- 테스트 코드를 기반으로 문서를 생성하기 때문에, 운영코드에 영향을 주지 않는다.
- 기능 및 스펙 변경에 비교적 잘 대응된다.
spring rest docs 를 사용하는 가장 큰 이유라면 아마 1번의 운영코드에 영향을 주지 않는다. 는 점일 것 입니다.
대표적으로 비교되는 swagger 의 경우, 실제 운영코드에 swagger 관련 설정이 영향을 주게 되어 실제로 서비스에 문제가 발생하면, 점검해야 할 포인트가 될 수 있는 점에 반해, 테스트 코드에서만 처리되는 spring rest docs 은 꽤 매력적으로 다가온다고 생각합니다.
하지만, 어디까지나 테스트 코드를 기반으로 API 문서를 생성하기 때문에, 운영 코드만 수정해놓고 테스트 코드에 반영을 해놓지 않는다면 API 문서가 실제 운영 환경과 다른 정보를 표현하기 때문에 테스트 코드를 작성하는데 개발자가 더 신경을 기울어야 한다는 점은 고려해야할 점이 될 수 있을 것 같습니다.
오히려, 여러 클라이언트에서 사용하는 서비스에서 API 문서를 만든다면, 실제 운영 환경과의 스팩을 그때 그때 바로 API 문서에 반영할 수 있는 swagger 가 협업 시에는 더 유리할 수 도 있을 것 같다는 생각이 듭니다.
이러한 특징들을 비교하여 각자에게 최선의 선택을 하는 것이 좋겠습니다.
참고 및 출처.
https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/#introduction
'Spring' 카테고리의 다른 글
[Spring] Spring AOP - 관심사 분리(부가기능과 핵심기능) (0) | 2024.02.26 |
---|---|
[Design Pattern] 프록시 패턴 & 데코레이터 패턴 (0) | 2024.02.15 |
[Design Pattern] 템플릿/콜백 패턴(Template/Callback Pattern) (0) | 2023.12.08 |
[ArgumentResolver] 로그인 여부를 ArgumentResolver로 처리하기 (0) | 2023.04.25 |
스프링의 객체지향 (1) | 2023.03.06 |
댓글