2회차 미션은 크게 유효성 검사와 예외 처리 부분과 API 문서를 만드는 2개의 요구사항이 주어졌습니다.
2회차 미션을 진행한 전체 코드는 다음 PR에서 확인할 수 있습니다.
https://github.com/JSCODE-EDU/project-class-HiiWee/pull/4
🤓 유효성 검사 및 예외 처리 요구사항 분석
이전 미션에서 임의로 게시글과 제목에 대해 정했던 유효성 검사에서 약간의 변동이 있었습니다.
- 게시글 작성 기능
- 제목은 1글자 이상 15글자 이하여야 한다. (기존에는 200글자까지 허용)
- 내용은 1글자 이상 1000글자 이하여야 한다. (기존에는 5000자까지 허용)
- 제목은 공백으로만 이루어질 수는 없다.
- 게시글 검색 기능
- 검색 키워드는 공백을 제외한 1글자 이상이어야 한다.
기존에 작성한 내용들이 있었기에 옵션과 테스트 코드를 변경해주면 간단하게 요구사항을 적용할 수 있었습니다.
@NotBlank, @NotEmpty, @NotNull의 차이점
1번 게시글 작성 기능 요구사항을 만들면서, 게시글 내용에 대해서는 공백을 허용하고자 했습니다.
따라서 RequestDto에서 request body 값에 대한 검증이 필요했고, 이때 @NotBlank -> @NotEmpty로의 변경이 있었습니다.
@NotBlank, @NotEmpty, @NotNull는 Bean Validation에서 제공하는 애노테이션으로 사용자의 입력값 검증을 쉽게 할 수 있게 도와주는 애노테이션 입니다. 정말 많이 사용되는 애노테이션입니다. 이름이 상당히 유사하지만, 제공하는 기능은 꽤 차이가 있으므로 확실하게 알고 사용해야 합니다!
@NotBlank
@NotBlank 애노테이션에 대한 설명은 다음과 같다.
@NotBlank 애노테이션이 달린 요소는 null이 아니어야 하며 공백이 아닌 문자를 하나 이상 포함해야 합니다.
즉, @NotBlank는 공백과 null 모두 허용하지 않고 있습니다.
해당 애노테이션이 붙은 입력값에 대해 null
, ""
, " "
3가지 모두 허용하고 있지 않습니다.
@NotNull
@NotNull 애노테이션에 대한 설명은 다음과 같다.
@NotNull 애노테이션이 달린 요소는 null이 아니어야 하며 이는 모든 type에 허용됩니다.
즉 해당 애노테이션이 달려있는 것이 타입이라면 해당 요소는 null이면 안됩니다.
따라서 "null"
로 들어오는 입력값은 허용하지 않습니다.
@NotEmpty
@NotEmpty에 대한 설명은 다음과 같습니다.
@NotEmpty 애노테이션이 달린 요소는 null이거나 비어 있으면 안됩니다.
따라서 null
, ""
을 허용하지 않습니다. 공백으로 이루어진 " "
과 같은 값은 허용됩니다.
제약의 강도는 @NotBlank > @NotEmtpy > @NotNull의 순서로 정해집니다.
현재 구현하고자 하는 기능은 제목은 공백, null이 모두 허용되면 안되므로 @NotBlank를 사용하고, 내용의 경우 공백의 입력까지는 허용되지만 null값이나 빈 값은 허용하지 않으므로 @NotEmpty를 달아주는것이 적절 했습니다.
@Getter
public class PostSaveRequest {
@NotBlank(message = "제목을 반드시 입력해야 합니다.")
private String title;
@NotEmpty(message = "내용을 반드시 입력해야 합니다.")
private String content;
private PostSaveRequest() {
}
@Builder
private PostSaveRequest(final String title, final String content) {
this.title = title;
this.content = content;
}
}
📃 게시글 API 문서화 하기
게시글 API 문서 작성 요구사항은 다음과 같습니다. 사실상 지금까지 만든 모든 API를 문서화하면 됩니다.
또한 API에 반드시 포함되어야 할 내용들은 아래와 같습니다.
제가 선택한 문서화 도구는 Spring Rest Docs으로 API 문서를 자동화 할 수 있다는 장점이 있었고, Swagger와 달리 애플리케이션 코드에 어떠한 변화도 없으며 테스트 코드를 반드시 작성해야만 사용할 수 있었습니다.
이번 스터디를 진행하면서 테스트 코드에 많은 집중을 했기에 자연스럽게 관심을 가지게 되어 적용하게 됐습니다.
Spring Rest Docs 의존성 설정하기
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.11'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id 'org.asciidoctor.jvm.convert' version "3.3.2"
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
asciidoctorExtensions
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('snippetsDir', file("build/generated-snippets")) // snippet이 생성되는 위치
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
annotationProcessor 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'io.rest-assured:rest-assured:4.4.0'
testImplementation 'io.rest-assured:spring-mock-mvc:4.4.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
tasks.named('test') {
outputs.dir snippetsDir
useJUnitPlatform()
}
asciidoctor {
configurations 'asciidoctorExtensions'
sources {
include("**/index.adoc")
}
baseDirFollowsSourceDir()
inputs.dir snippetsDir
dependsOn test
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
task createDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static")
}
build {
dependsOn createDocument
}
의존성 설정 및 빌드 설정에 대한 자세한 내용은 다음 글에서 자세히 설명했습니다. [MyLittleBlog] 회원가입 웹 계층 구현하기
MockMvc vs RestAssured
Spring Rest Docs를 생성하는 테스트는 MockMvc 테스트로 결정했습니다.
현재 코드에선 별도의 인수테스트를 진행하고 있기에 기존 Controller 단위 테스트와 병합하기 위해 MockMvc를 선택했습니다.
따라서 기존 테스트에서 Rest Docs를 작성하는 부분만 추가해주면 됩니다.
테스트 작성하기
@WebMvcTest(PostController.class)
@ExtendWith(RestDocumentationExtension.class) // (1)
public class PostControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
PostService postService;
@BeforeEach
void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
// (2)
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
@DisplayName("게시글 작성을 하면 201을 반환한다.")
@Test
void createPost() throws Exception {
// given
PostSaveResponse saveResponse = PostSaveResponse.createPostSuccess(1L);
PostSaveRequest post = PostSaveRequest.builder()
.title("게시글 제목 입니다.")
.content("게시글 내용 입니다.")
.build();
given(postService.createPost(any(PostSaveRequest.class))).willReturn(saveResponse);
// when
ResultActions result = mockMvc.perform(post("/posts")
.content(objectMapper.writeValueAsString(post))
.contentType(MediaType.APPLICATION_JSON_VALUE));
// then
result.andExpectAll(status().isCreated(),
jsonPath("$.savedId").value(1L),
jsonPath("$.message").value("게시글 작성을 완료했습니다.")
// (3)
).andDo(
document("post/create/success", // (4)
getDocumentRequest(), // (5)
getDocumentResponse(), // (6)
requestFields( // (7)
fieldWithPath("title").type(JsonFieldType.STRING).description("게시글 제목")
.attributes(getConstraints("constraints", "제목은 앞뒤 공백 제외 1 ~ 15자 사이여야 합니다.")),
fieldWithPath("content").type(JsonFieldType.STRING).description("게시글 내용")
.attributes(getConstraints("constraints", "내용은 공백 포함 1 ~ 1000자 사이여야 합니다."))
),
responseFields( // (8)
fieldWithPath("savedId").type(JsonFieldType.NUMBER).description("저장된 게시글 id"),
fieldWithPath("message").type(JsonFieldType.STRING).description("게시글 저장 성공 메시지")
)
)
);
}
}
- (1) JUnit5d에서는 Spring Rest Docs를 이용하면 해당 애노테이션을 붙여주어야 합니다.
- (2) 모든 테스트를 시작하기 전에 webApplicationContext와 restDocumentation을 MockMvc에 설정해줍니다. (https://docs.spring.io/spring-restdocs/docs/2.0.4.RELEASE/reference/html5/#getting-started-documentation-snippets-setup)
- (3) : 위의 코드까지는 테스트 코드이고 아래 코드부터 실제 api에 대한 snippet을 생성합니다. (snippet은 일종의 api 문서 조각이라고 생각하면 됩니다.)
- (4) : snippet이 생성되는 위치를 지정합니다. post(게시글)/create(작성)/success(성공)을 의미합니다.
- (5) ~ (6) : 직접 커스텀한 별도의 유틸 메소드로 api 문서에서 호출 URL을 커스텀하고, 전체적인 요청과 응답을 "이쁘게"출력하기 위한 설정을 합니다. 코드
- (7) : 현재 테스트에서 request fields의 이름과 필드에 대한 설명(.description())을 지정합니다.
- (8) : 현재 테스트에서 response fields의 이름과 필드에 대한 설명(.description())을 지정합니다.
asciidoc 문서 작성하기
위와 같이 작성학 테스트를 실행하면 build/generated-snippets/ 폴더에 위의 (4)에서 지정한 디렉토리가 생성되고 내부에 다음과 같은 adoc 파일들이 존재합니다.
주어진 요구사항에는 요청과 응답 형태에서 상당히 많은 정보를 요구하고 있지만
현재 게시글 작성은 body params와 응답 형태만을 만족합니다. 이를 이용해 API 문서를 작성해보겠습니다!
우선 build.gradle 설정에서 index.html로 변경할 index.adoc의 위치를 src/docs/asciidoc/
으로 지정했기때문에 해당 위치에 index.adoc
파일을 생성합니다.
해당 index.adoc 파일에서 다음과 같이 asciidoc을 작성합니다.
operation은 snippet을 조금 더 쉽게 추가할 수 있게 도와주는 기능으로 다음 의존성이 별도로 필요합니다. (org.springframework.restdocs:spring-restdocs-asciidoctor
)
내부 snippet으로 요청과 응답 필드를 지정하고, 실제 request와 response를 지정하게되면 다음과 같이 렌더링 됩니다.
이렇게 하여 간단하게 게시글 작성에 대한 성공 api 문서를 작성했습니다.
위의 렌더링 결과를 보면 제약사항이나 필수값에 대한 표는 현재 글에서 따로 언급하지 않았습니다. 해당 내용은 다음 글에서 더욱 자세히 설명하고 있습니다.
https://techblog.woowahan.com/2597/
또한 request fields, response fields 외에도 여러 속성 (헤더, 파라미터, pathvriable)등 다양한 속성들을 Spring Rest Docs로 지정할 수 있습니다. 그에 대한 내용은 스프링 공식문서에 자세히 설명되어 있습니다.
https://docs.spring.io/spring-restdocs/docs/2.0.4.RELEASE/reference/html5/#documenting-your-api
🤔 마치면서
Spring Rest Docs를 처음 사용해본지 얼마 되지 않아 두 번째로 처음부터 Sprint Rest Docs에 대한 설정을 진행했습니다.
확실히 처음 접했을때보다 수월하게 했던 부분들도 있었고, 레퍼런스를 따라하는것만이 아니라 어느정도 이해를 하면서 코드를 작성할 수 있었습니다!
처음부터 모든것을 이해하면 좋겠지만, Spring Rest Docs만 해도 알아야 할 내용들이 상당히 많았으며 코드양도 만만치 않았습니다.
이렇게 한 사이클씩 여러번 돌리다 보면 점점 더 익숙해지지 않을까 하는 생각이 듭니다 ㅎㅎ