[프로젝트] Spring REST Docs 적용하기

공부하는 감자·2024년 2월 2일
0

F-Lab 프로젝트

목록 보기
8/11

들어가는 말

프로젝트 구조를 설계하고 ERD를 그렸으니, 이제 API 문서를 작성하기로 했다. 요청값과 응답값은 임의로 하드코딩하여 간단한 API 동작을 구현할 것이다.

API 문서 작성 툴 중에선 Spring REST Docs를 사용하기로 했다.

Spring REST Docs는 테스트 코드가 필요해서 미리 API 규격을 잡아 놓는 데에 좋다고 생각한다. 테스트 코드를 미리 만들어두는 편이 후에 개발하고 의도한 대로 만들어졌는지 확인하는 데 좋을 것이다.

Spring REST Docs 연동

Spring REST Docs란

Spring REST Docs는 테스트 코드 기반으로 RESTful 문서 생성을 도와주는 도구이다.

  • 프로젝트의 API 엔드포인트를 테스트하고 그 결과를 바탕으로 문서를 생성
  • 항상 최신의 API 문서를 유지하면서 API 변경 사항에 대한 즉각적인 업데이트를 제공

장점

보통은 Swagger와 비교하는 듯 하다. 이는 참고 사이트를 보는 걸 추천한다.

  • 제품 코드에 영향이 없다.
  • 테스트가 성공해야 문서 작성이 된다.

단점

  • 설정이 까다롭고 공식 문서 외의 레퍼런스가 많지 않다.
  • 테스트 코드 아래에 이어 붙이는 형식으로 지원하기 때문에 테스트 코드의 양이 많아진다.

1. Build configuration

먼저, 프로젝트의 Build 를 설정해야 한다.

  • 설정은 공식 사이트를 참고했다.
plugins {
	id 'org.asciidoctor.jvm.convert' version '3.3.2' // (1)
}

configurations {
	asciidoctorExt // (2)
}

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

dependencies { // (4)
	asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

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

asciidoctor { // (6)
	inputs.dir snippetsDir
	configurations 'asciidoctorExt'
	dependsOn test
}
  1. Asciidoctor 플러그인 추가
    • adoc 파일을 변환하고 build 디렉토리에 복사하기 위해 사용하는 플러그인이다.
  2. Asciidoctor를 확장하는 dependencies에 대한 구성을 선언
  3. snippets 파일이 저장될 경로를 설정한다.
    • 나는 공식 사이트에 기재된 경로 그대로 적었다.
  4. dependencies에 asciidoctor와 MockMvc추가
    • asciidoctor는 adoc 파일에서 사용할 snippets 속성이 자동으로 build/generated-snippets를 가리키도록 해준다.
    • MockMvc는 Spring Framework에서 제공하는 테스트용 모듈 중 하나로, 이를 사용하여 가상의 요청과 응답을 생성하고 컨트롤러 동작을 테스트할 수 있다.
  5. 테스트가 실행될 때 출력이 저장될 디렉토리를 설정한다.
  6. 테스트가 실행될 때 입력을 snippetsDir에서 읽고, asciidoctorExt를 사용하도록 설정한다. 테스트 문서가 작성되기 전에 테스트가 실행되도록 한다.

2. adoc 문서 작성

  • src/docs/asciidoc 디렉토리에 .adoc 파일로 작성한다.

index.adoc

테스트가 실행되면, 입력(HTTP 요청)과 응답(HTTP 응답)을 어떻게 문서화할 지를 구성하는 문서이다.

프로젝트를 빌드하면 build.gradle 에서 설정했던 snippetsDir 하위에 기본 스니펫들이 생기는데, 해당 스니펫들을 지정했다.

커스텀 스니펫을 생성 후 적용할 수도 있다.

  • http-request : HTTP 요청 메시지
  • http-response : HTTP 응답 메시자
  • request-fields : 요청 필드
  • response-fields : 응답 필드
operation::index[snippets='http-request,http-response,request-fields,response-fields']

미리보기

IntelliJ에서는 adoc 문서를 만들면, 화면을 미리 볼 수 있다.

  • 아래 화면은 테스트 코드를 작성 후 실행하여 문서화가 된 이후에 캡처했다.

3. API 생성

가볍게 테스트 해보기 위해 회원 등록 API를 만들었다.

MemberRestAdapter.java

요청이 들어오면 받아줄 Controller이다.

  • MemberCreateReqeust로 요청 파라미터를 받는다.
  • 도메인 계층의 Service에 회원 등록 요청
    • 회원 등록을 요청할 때, mapper를 사용해 도메인 계층의 Member로 변환하여 전달한다.
  • 회원 등록 후, 결과(Member)를 받아서 응답 DTO로 변환하여 반환한다.
@RestController
@RequiredArgsConstructor
public class MemberRestAdapter {
    private final RegisterMemberUseCase memberService;

    @PostMapping("/member")
    @ResponseBody
    MemberCreateResponse createMember(@RequestBody MemberCreateRequest request) {
        Member member = memberService.registMember(request.toMember());
        return MemberCreateResponse.from(member);
    }
}

MemberService.java

도메인 계층의 Service이다.

  • Port(MemberPort)를 이용하여 영속성 계층에게 회원 등록 요청
@UseCase
public class MemberService implements RegisterMemberUseCase, DeregisterMemberUseCase, LoginUseCase {
    private final MemberPort memberPort;
		...

    @Override
    public Member registMember(Member member) {
        return memberPort.saveMember(member.activate());
    }

    ...
}

MemberPersistenceAdapter.java

영속성 계층의 어댑터이다.

  • 전달받은 도메인 계층의 Member를 영속성 계층에서 사용할 Entity로 변환하여 사용한다.
  • Repository의 save 메소드 호출
  • 저장 후 반환받은 결과(Entity)를 Member로 변환하여 반환
@Component
@RequiredArgsConstructor
public class MemberPersistenceAdapter implements MemberPort {
    private final MemberRepository memberRepository;

    @Override
    public Member saveMember(Member member) {
        MemberEntity memberEntity = MemberEntity.from(member);
        MemberEntity savedMember = memberRepository.save(memberEntity);
        return savedMember.toMember();
    }

    ...
}

MemberRepository

아직 DB 연동을 하지 않았으므로, 임시 레파지토리를 생성했다. 추후 JPA 연동을 한 뒤에 수정될 것이다.

  • Map 에 회원 저장(등록)
  • 임의로 저장한 회워 객체(Member)를 생성하여 반환한다.
public class MemoryMemberRepository implements MemberRepository {

    private static Map<Long, MemberEntity> store = new HashMap<>();
    private static Long id = 1L;

    @Override
    public MemberEntity save(MemberEntity member) {
        MemberEntity savedMember = MemberEntity.builder()
                .id(id)
                .userKey(id.toString())
                .status(member.getStatus())
                .linkType(member.getLinkType())
                .email(member.getEmail())
                .userName(member.getUserName())
                .nickName(member.getNickName())
                .phoneNum(member.getPhoneNum())
                .gender(member.getGender())
                .birthday(member.getBirthday())
                .password(member.getPassword())
                .lastLoginAt(LocalDateTime.now())
                .createdAt(LocalDateTime.now())
                .updatedAt(LocalDateTime.now())
                .build();

        store.put(id++, savedMember);
        return savedMember;
    }

    ...
}

4. 테스트 코드 작성

이제 문서화를 위해 테스트 코드를 작성했다.

ObjectMapper 설정

Entity에서 LocalDateLocalDateTime을 사용했는데, 이 타입들로 선언된 변수들이 Array로 인식되는 문제가 있었다.

따라서, Array가 아닌 String으로 가져오도록 ObjectMapper를 사용했다.

@Configuration
public class SpringConfiguration {

    @Bean
    ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return objectMapper;
    }
}

어노테이션

@ExtendWith(RestDocumentationExtension.class) // (1)
@AutoConfigureMockMvc // (2)
  1. RestDocumentationExtension 테스트 클래스를 적용한다.
  2. @SpringBootTest 를 사용하는 테스트에서 MockMvc를 사용할 경우 추가한다.

테스트 전

  • @BeforeEach 에서 MockMvc를 구성하는 방법을 제공한다.
  • MockMvc와 ObjectMapper를 사용하기 위해 @Autowired 로 자동주입한다.
@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest
@Transactional
@AutoConfigureMockMvc
public class MemberServiceTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(documentationConfiguration(restDocumentation))
                .build();
    }
}

테스트 코드

  • MemberCreateRequest : 저장할 회원 정보
  • mockMvc.perform 로 호출할 API와 예상 결과를 작성한다.
    • post("/member") : POST로 API 호출
    • content(objectMapper.writeValueAsString(request)) : HTTP 요청 값을 설정한다.
    • andExpect(status().isOk()) : 기대하는 응답 값 (200 OK)
    • andDo(document("index", ...) : 테스트 결과를 문서화한다.
    • requestFields : 문서에 표시될 요청 필드들
    • responseFields : 문서에 표시될 응답 필드들
...
public class MemberServiceTest {
    ...

    @Test
    void registerMember() throws Exception {

        // given
        MemberCreateRequest request = MemberCreateRequest.builder()
                .linkType(MemberLinkType.NONE)
                .email("Test@gmail.com")
                .userName("홍길순")
                .nickName("테스터")
                .phoneNum("010-1111-2222")
                .gender(MemberGender.FEMALE)
                .birthday(LocalDate.of(1998,1,30))
                .password("")
                .build();

        // when
        // then
        this.mockMvc.perform(post("/member")
                        .content(objectMapper.writeValueAsString(request))
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(document("index",
                        requestFields(
                                fieldWithPath("linkType").description("회원연동"),
                                fieldWithPath("email").description("이메일"),
                                fieldWithPath("userName").description("이름"),
                                fieldWithPath("nickName").description("닉네임"),
                                fieldWithPath("phoneNum").description("핸드폰 번호"),
                                fieldWithPath("gender").description("성별"),
                                fieldWithPath("birthday").description("생년월일"),
                                fieldWithPath("password").description("비밀번호")
                        ),
                        responseFields(
                                fieldWithPath("userKey").description("회원번호(외부용)"),
                                fieldWithPath("status").description("회원상태")
                        )));
    }
}

전체 코드

@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest
@Transactional
@AutoConfigureMockMvc
public class MemberServiceTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(documentationConfiguration(restDocumentation))
                .build();
    }

    @Test
    void registerMember() throws Exception {

        // given
        MemberCreateRequest request = MemberCreateRequest.builder()
                .linkType(MemberLinkType.NONE)
                .email("Test@gmail.com")
                .userName("홍길순")
                .nickName("테스터")
                .phoneNum("010-1111-2222")
                .gender(MemberGender.FEMALE)
                .birthday(LocalDate.of(1998,1,30))
                .password("")
                .build();

        // when
        // then
        this.mockMvc.perform(post("/member")
                        .content(objectMapper.writeValueAsString(request))
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(document("index",
                        requestFields(
                                fieldWithPath("linkType").description("회원연동"),
                                fieldWithPath("email").description("이메일"),
                                fieldWithPath("userName").description("이름"),
                                fieldWithPath("nickName").description("닉네임"),
                                fieldWithPath("phoneNum").description("핸드폰 번호"),
                                fieldWithPath("gender").description("성별"),
                                fieldWithPath("birthday").description("생년월일"),
                                fieldWithPath("password").description("비밀번호")
                        ),
                        responseFields(
                                fieldWithPath("userKey").description("회원번호(외부용)"),
                                fieldWithPath("status").description("회원상태")
                        )));
    }
}

5. 문서 생성

프로젝트 빌드

프로젝트를 빌드하면 자동으로 테스트 수행 후 API 문서를 생성해준다.

./gradlew asciidoctor

IntelliJ에선 아래에 터미널을 띄워 명령어를 입력하면 된다.

생성된 HTML

생성된 문서는 build/docs/asciidoc/index.html 로 만들어진다.

  • 해당 문서를 크롬에서 열어본 화면이다.

발생했던 오류들

HttpMessageConversionException

참고 사이트:

cannot deserialize from object value 에러!

원인은 다음처럼 나온다.

Cannot construct instance of com.flab.funding.infrastructure.adapters.input.data.request.MemberCreateRequest (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

반환할 때 MessageConverter를 사용했는데, Json을 Object로 변환할 때 빈 생성자(기본 생성자)가 있어야 했다.

나는 @Builder 를 사용해서 빈 생성자가 자동 생성이 안되었던 거 같아서, lombok의 @NoArgsContructor 를 이용해 생성자를 추가하기로 했다.

@Builder와 @NoArgsContructor

참고 사이트:

[오류] Lombok @Builder 생성자 관련 오류

@Builder 사용 시 @NoArgsContructor 를 같이 사용하면 오류가 발생했다.

  • @Builder 를 사용하면 모든 멤버 변수를 파라미터로 받는 기본 생성자를 생성한다.
  • 그런데 만약 생성자가 있을 경우 따로 생성자를 생성하지 않는다.
  • 따라서, @NoArgsContructor 를 사용하면 Builder에 필요한 생성자가 만들어지지 않아서 오류가 발생한다.
  • 이 경우 전체 요소로 생성자를 만들어주는 @AllArgsConstructor 를 사용해야 한다.
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class MemberCreateRequest {
    ...
}

문서에서 LocalDate가 Array로 표기되는 문제

참고 사이트:

ObjectMapper 커스텀

Jackson으로 LocalDate 자동 매핑하기

https://lemontia.tistory.com/918

Jackson은 내부적으로 ObjectMapper를 사용하여 Object와 Json 간의 매핑을 진행한다고 한다.

따라서, ObjectMapper를 Bean으로 등록하여 설정을 일부 변경했다.

  1. LocalDate를 사용하기 매핑해주기 위해 JavaTimeModule 모듈 등록
  2. disable : SerializationFeature.WRITE_DATES_AS_TIMESTAMPS
    • 날짜 관련 타임스탬프 직렬화 disable
    • ISO-8601 형태로 포맷되어서 나온다
@Configuration
public class SpringConfiguration {

    @Bean
    ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule()); // (1)
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // (2)
        return objectMapper;
    }
}

Reference

참고 사이트

Spring REST Docs

cannot deserialize from object value 에러!

[오류] Lombok @Builder 생성자 관련 오류

ObjectMapper 커스텀

Jackson으로 LocalDate 자동 매핑하기

[java] objectMapper로 object->string(json) 변경 시 LocalDate 를 yyyy-MM-dd 포멧하기

profile
책을 읽거나 강의를 들으며 공부한 내용을 정리합니다. 가끔 개발하는데 있었던 이슈도 올립니다.

0개의 댓글

관련 채용 정보