예약 시스템 API - 상품 목록 조회

Seyeong·2023년 3월 21일
0

이번엔 상품 목록을 조회하는 API를 만들어봅시다.

API 문서 생성

이전에 만들었던 Controller 테스트 코드에서 새로운 API 문서를 명세해 줍시다.

ReservationControllerTest

@WebMvcTest(ReservationController.class)
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
class ReservationControllerTest {

	...
    

    @Test
    void testApiDisplayinfos() throws Exception {
        // then
        mockMvc.perform(get("/api/displayinfos")) // 요청 보내기
                .andExpect(status().isOk())
                .andDo(document("/api/displayinfos", // Rest Docs 문서 생성
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        requestFields(
                                fieldWithPath("categoryId").description("카테고리 아이디 (0 또는 없을 경우 전체 조회)"),
                                fieldWithPath("start").description("조회 시작 위치")
                        ),
                        responseFields(
                                fieldWithPath("totalCount").description("해당 카테고리의 전시 상품 수"),
                                fieldWithPath("productCount").description("읽어온 전시 상품 수"),
                                fieldWithPath("products[]").description("전시 상품 정보"),
                                fieldWithPath("products[].id").description("전시 상품 ID"),
                                fieldWithPath("products[].categoryId").description("카테고리 ID"),
                                fieldWithPath("products[].displayInfoId").description("전시 상품 ID"),
                                fieldWithPath("products[].name").description("전시 상품명"),
                                fieldWithPath("products[].description").description("전시 상품 설명"),
                                fieldWithPath("products[].content").description("전시 상품 내용"),
                                fieldWithPath("products[].event").description("이벤트"),
                                fieldWithPath("products[].openingHours").description("오픈 시각"),
                                fieldWithPath("products[].placeName").description("장소"),
                                fieldWithPath("products[].placeLot").description("위치"),
                                fieldWithPath("products[].placeStreet").description("도로명"),
                                fieldWithPath("products[].tel").description("연락처"),
                                fieldWithPath("products[].homepage").description("홈페이지"),
                                fieldWithPath("products[].email").description("이메일"),
                                fieldWithPath("products[].createDate").description("생성일"),
                                fieldWithPath("products[].modifyDate").description("수정일"),
                                fieldWithPath("products[].fileId").description("파일 ID")
                        )));
    }
}

/api/displayinfos 경로로 GET 요청에 대한 API 문서입니다.

이전에 Rest Docs 문서 생성에 대한 코드는 상세히 설명했기 때문에 이제부턴 생략하도록 하겠습니다.

이대로 테스트 코드를 실행하면 요청을 받을 컨트롤러는 물론이고, 요청 및 응답으로 필드 값이 API 문서와 매핑되지 않아서 테스트가 실패합니다.

따라서 이를 통과시켜줍시다.

ReservationController

@RestController
@RequiredArgsConstructor
public class ReservationController {
	
    ...

    @GetMapping("api/displayinfos")
    public DisplayInfosResponseDto getDisplayInfos(@RequestBody DisplayInfosRequestDto requestDto) {
        return DisplayInfosResponseDto.builder()
                .totalCount(0)
                .productCount(0)
                .products(List.of(
                        DisplayInfoResponseDto
                                .builder()
                                .build()))
                .build();
    }
}

Request와 Response에 각각 필드들을 담아주기 위해 객체를 생성하였습니다.

DisplayInfosRequestDto

@Builder
@Getter
@RequiredArgsConstructor
@NoArgsConstructor(force = true)
public class DisplayInfosRequestDto {
    private final int categoryId;
    private final int start;
}

요청 파라미터를 받아올 RequestDto 객체입니다.

여기서 눈 여겨 볼 점은 바로 @NoArgsConstructor(force = true) 부분의 코드입니다.

이 부분은 HTTP 요청 Body에 담긴 Json 데이터로부터 자바 Object로 변환하는 과정에서 Object를 생성할 때 기본 생성자로 객체를 생성하기 때문입니다. 그 후에, 객체의 필드들에 getXxx( ) 형식으로 되어 있는 메서드로부터 필드명을 추출합니다.
그리고 추출한 필드명에 setter 메서드를 이용하여 값을 주입합니다.

재밌는 점은 setter 메서드가 실제로 정의되어 있지 않아도 Jackson 라이브러리를 이용해 setter를 사용할 수 있다는 점입니다.

다음은 Response 객체입니다.

DisplayInfosResponseDto

@Builder
@Getter
@RequiredArgsConstructor
public class DisplayInfosResponseDto {
    private final int totalCount;
    private final int productCount;
    private final List<DisplayInfoResponseDto> products;
}

DisplayInfoResponseDto

@Builder
@Getter
@RequiredArgsConstructor
public class DisplayInfoResponseDto {
    private final int id;
    private final int categoryId;
    private final int displayInfoId;
    private final String name;
    private final String description;
    private final String content;
    private final String event;
    private final String openingHours;
    private final String placeName;
    private final String placeLot;
    private final String placeStreet;
    private final String tel;
    private final String homepage;
    private final String email;
    private final LocalDateTime createDate;
    private final LocalDateTime modifyDate;
    private final int fileId;
}

응답 필드가 조금 많아서 복잡해보이지만 구조는 카테고리 조회가 매우 유사한 걸 볼 수 있습니다.

여기서는 따로 설명할 부분이 없으므로 테스트 코드가 동작하도록 수정해줍시다.

ReservationControllerTest

class ReservationControllerTest {
	// 추가해준다.
    private ObjectMapper objectMapper = new ObjectMapper();
    
    ...
    

    @Test
    void testApiDisplayInfos() throws Exception {
        // given
        DisplayInfosRequestDto requestDto = DisplayInfosRequestDto.builder()
                .categoryId(0)
                .start(0)
                .build();

        // then
        mockMvc.perform(get("/api/displayinfos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andExpect(status().isOk())
                
                // Rest Docs 문서 생성
                ...
    }
}
  • ObjectMapper toJson

추가한 부분은 요청 파라미터를 매핑해주기 위해 requestDto를 생성한 부분과
이를 Json 형식의 문자열로 변환하여 넣어주기 위한 부분들입니다.

이제 이대로 테스트를 동작시켜서 스니펫을 생성했다면, 이를 API 문서에 띄우기 위한 형식을 지정해줍시다.

index.adoc


...


== 전시 정보 조회
=== 요청
include::{snippets}/api/displayinfos/http-request.adoc[]
include::{snippets}/api/displayinfos/request-fields.adoc[]

=== 응답
include::{snippets}/api/displayinfos/http-response.adoc[]
include::{snippets}/api/displayinfos/response-fields.adoc[]

이제 코드를 실행시키면 아래와 같은 API 문서가 생성됩니다.

요청

응답

이제 API 문서를 생성하였으니, 실제 비즈니스 코드를 작성해야 합니다.

하지만, 카테고리 조회 API 문서를 만들었던 테스트 코드와 전시 정보 조회 API의 테스트 코드를 보면 아래와 같이 중복 코드가 있는걸 볼 수 있습니다.

class ReservationControllerTest {

	...
    
    
    @Test
    void testApiCategories() throws Exception {
                ...
                .andDo(document("/api/categories",
                        preprocessResponse(prettyPrint()),
                        responseFields(
                                fieldWithPath("size").description("카테고리 개수"),
                                fieldWithPath("items[]").description("카테고리 정보"),
                                fieldWithPath("items[].id").description("카테고리 id"),
                                fieldWithPath("items[].name").description("카테고리 이름"),
                                fieldWithPath("items[].count").description("카테고리에 포함된 전시 상품(display_info)의 수")
                        )));
    }

    @Test
    void testApiDisplayInfos() throws Exception {
                ...
                .andDo(document("/api/displayinfos",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        requestFields(
                                fieldWithPath("categoryId").description("카테고리 아이디 (0 또는 없을 경우 전체 조회)"),
                                fieldWithPath("start").description("조회 시작 위치")
                        ),
                        responseFields(
                                fieldWithPath("totalCount").description("해당 카테고리의 전시 상품 수"),
                                fieldWithPath("productCount").description("읽어온 전시 상품 수"),
                                fieldWithPath("products[]").description("전시 상품 정보"),
                                fieldWithPath("products[].id").description("전시 상품 ID"),
                                fieldWithPath("products[].categoryId").description("카테고리 ID"),
                                fieldWithPath("products[].displayInfoId").description("전시 상품 ID"),
                                fieldWithPath("products[].name").description("전시 상품명"),
                                fieldWithPath("products[].description").description("전시 상품 설명"),
                                fieldWithPath("products[].content").description("전시 상품 내용"),
                                fieldWithPath("products[].event").description("이벤트"),
                                fieldWithPath("products[].openingHours").description("오픈 시각"),
                                fieldWithPath("products[].placeName").description("장소"),
                                fieldWithPath("products[].placeLot").description("위치"),
                                fieldWithPath("products[].placeStreet").description("도로명"),
                                fieldWithPath("products[].tel").description("연락처"),
                                fieldWithPath("products[].homepage").description("홈페이지"),
                                fieldWithPath("products[].email").description("이메일"),
                                fieldWithPath("products[].createDate").description("생성일"),
                                fieldWithPath("products[].modifyDate").description("수정일"),
                                fieldWithPath("products[].fileId").description("파일 ID")
                        )));
    }
}

두 개의 테스트 코드에서 API 문서를 생성하는 부분의 코드가 중복이 발생하며, 이 문제는 앞으로 API가 추가될 때마다 더 심하게 발생할 겁니다.

그렇기 때문에 API 문서를 생성하는 로직을 별도로 분리하여 중복을 줄일 수 있는 방향으로 리팩토링 해보겠습니다.

API 문서 생성 로직 리팩토링

REST Docs 문서의 형식을 보면 아래와 같이 필드명(Path)과 필드 설명(Description) 이 존재하는 것을 볼 수 있습니다.

위의 형식을 만들어주기 위해 객체를 만들어 주겠습니다.

RestDocsDto

@Getter
@Builder
@RequiredArgsConstructor
public class RestDocsDto {
    private final String path;
    private final String description;
}

현재 테스트 코드에서 아래와 같이 필드를 설정하는 부분이 너무 지저분합니다.

requestFields(
	fieldWithPath("categoryId").description("카테고리 아이디 (0 또는 없을 경우 전체 조회)"),
	fieldWithPath("start").description("조회 시작 위치")
)

이 필드들은 어차피 DTO 객체가 가져야 할 필드를 명시하는 것이므로, DTO 객체 내부에서 선언하도록 변경해주겠습니다.

RestDocsTemplate

public interface RestDocsTemplate {
    List<RestDocsDto> generateRestDocsFields();
}

REST Docs에서 명시할 필드들에 대해서 지정하는 인터페이스를 만들어 줍니다.
이 인터페이스는 DTO 객체들이 구현하게 될 것이고, 형식은 필드들에 대해 리스트 형태로 위에서 설정한 Path, Description 을 가지게 될 것입니다.

필드 정보를 제공해야 할 DTO 객체들은 아래와 같습니다.

  • CategoriesResponseDto
  • DisplayInfosRequestDto
  • DisplayInfosResponseDto

그럼 이제 DTO 객체들이 위의 인터페이스를 구현해서 각자의 필드에 대한 정보를 제공하도록 해봅시다.

CategoriesResponseDto

...
public class CategoriesResponseDto implements RestDocsTemplate {
    private final int size;
    private final List<CategoryResponseDto> items;

    @Override
    public List<RestDocsDto> generateRestDocsFields() {
        return List.of(
                RestDocsDto.builder().path("size").description("카테고리 개수").build(),
                RestDocsDto.builder().path("items[]").description("카테고리 정보").build(),
                RestDocsDto.builder().path("items[].id").description("카테고리 id").build(),
                RestDocsDto.builder().path("items[].name").description("카테고리 이름").build(),
                RestDocsDto.builder().path("items[].count").description("카테고리에 포함된 전시 상품(display_info)의 수").build()
        );
    }
}

DTO가 스스로 가지고 있는 필드에 대한 정보를 제공하도록 바꿔주었습니다.

나머지도 이와 같이 변경해봅시다.

DisplayInfosRequestDto

...
public class DisplayInfosRequestDto implements RestDocsTemplate {
    private final int categoryId;
    private final int start;

    @Override
    public List<RestDocsDto> generateRestDocsFields() {
        return List.of(
                RestDocsDto.builder().path("categoryId").description("카테고리 아이디 (0 또는 없을 경우 전체 조회)").build(),
                RestDocsDto.builder().path("start").description("조회 시작 위치").build()
        );
    }
}

DisplayInfosResponseDto

...
public class DisplayInfosResponseDto implements RestDocsTemplate {
    private final int totalCount;
    private final int productCount;
    private final List<DisplayInfoResponseDto> products;

    @Override
    public List<RestDocsDto> generateRestDocsFields() {
        return List.of(
                RestDocsDto.builder().path("totalCount").description("해당 카테고리의 전시 상품 수").build(),
                RestDocsDto.builder().path("productCount").description("읽어온 전시 상품 수").build(),
                RestDocsDto.builder().path("products[]").description("전시 상품 정보").build(),
                RestDocsDto.builder().path("products[].id").description("전시 상품 ID").build(),
                RestDocsDto.builder().path("products[].categoryId").description("카테고리 ID").build(),
                RestDocsDto.builder().path("products[].displayInfoId").description("전시 상품 ID").build(),
                RestDocsDto.builder().path("products[].name").description("전시 상품명").build(),
                RestDocsDto.builder().path("products[].description").description("전시 상품 설명").build(),
                RestDocsDto.builder().path("products[].content").description("전시 상품 내용").build(),
                RestDocsDto.builder().path("products[].event").description("이벤트").build(),
                RestDocsDto.builder().path("products[].openingHours").description("오픈 시각").build(),
                RestDocsDto.builder().path("products[].placeName").description("장소").build(),
                RestDocsDto.builder().path("products[].placeLot").description("위치").build(),
                RestDocsDto.builder().path("products[].placeStreet").description("도로명").build(),
                RestDocsDto.builder().path("products[].tel").description("연락처").build(),
                RestDocsDto.builder().path("products[].homepage").description("홈페이지").build(),
                RestDocsDto.builder().path("products[].email").description("이메일").build(),
                RestDocsDto.builder().path("products[].createDate").description("생성일").build(),
                RestDocsDto.builder().path("products[].modifyDate").description("수정일").build(),
                RestDocsDto.builder().path("products[].fileId").description("파일 ID").build()
        );
    }
}

이렇게 해주었으니 테스트 코드에서 직접적으로 필드에 대해 명시할 필요가 없어졌습니다.

이제 테스트 코드를 수정해야 할텐데, 점진적으로 리팩토링 해봅시다.

먼저 현재 /api/displayinfos API에 대한 요청에 담긴 필드를 명시하는 코드는 아래와 같습니다.

requestFields(
	fieldWithPath("categoryId").description("카테고리 아이디 (0 또는 없을 경우 전체 조회)"),
	fieldWithPath("start").description("조회 시작 위치")
)

PayloadDocumentation 객체의 static 메서드인 requestFields()responseFields() 메서드는 파라미터로 List<FieldDescriptor> 를 받습니다.

그런데 저희가 만들었던 DTO가 반환하는 필드는 List<RestDocsDto> 라는 저희가 임의로 만든 형식입니다.

따라서, 리스트에 담긴 RestDocsDto 형식의 필드 데이터들을 FieldDescriptor 타입으로 변환해주면 깔끔하게 사용할 수 있습니다.

이 변환을 수행하는 코드를 만들어볼까요?

RestDocsFieldsGenerator

public class RestDocsFieldsGenerator {
    public static RequestFieldsSnippet generateRequest(List<RestDocsDto> restDocsDtos) {
        return requestFields(
                generateFields(restDocsDtos)
        );
    }

    public static ResponseFieldsSnippet generateResponse(List<RestDocsDto> restDocsDtos) {
        return responseFields(
                generateFields(restDocsDtos)
        );
    }

    private static List<FieldDescriptor> generateFields(List<RestDocsDto> restDocsDtos) {
        return restDocsDtos.stream()
                .map(dto -> fieldWithPath(dto.getPath()).description(dto.getDescription()))
                .collect(Collectors.toList());
    }
}

코드는 매우 단순합니다. 요청에 대한 파라미터를 만들고 싶을 땐, generateRequest()를, 응답에 대한 파라미터를 만들고 싶을 땐, generateResponse()를 호출해주면 됩니다.

이 메서드들의 파라미터로는 DTO가 정의한 필드 정보를 같이 넘겨주면 알아서 요청 및 응답에 대한 필드 정보를 생성해줄 겁니다.

참고로 위의 RestDocsFieldsGenerator 코드는 main 코드가 아닌 test 코드에 작성해야 합니다. 코드들이 rest docs 라이브러리를 의존하게 되는데 저희가 gradle에서 rest docs에 대한 의존성을 테스트 코드에만 적용하도록 설정해주었기 때문이죠.

이제 테스트 코드에 적용하며 리팩토링을 해볼건데요, 기존의 테스트 코드는 아래와 같았습니다.

ReservationControllerTest

...
class ReservationControllerTest {

	...

    @Test
    void testApiDisplayInfos() throws Exception {
                ...
                .andDo(document("/api/displayinfos",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        requestFields(
                                fieldWithPath("categoryId").description("카테고리 아이디 (0 또는 없을 경우 전체 조회)"),
                                fieldWithPath("start").description("조회 시작 위치")
                        ),
                        responseFields(
                                fieldWithPath("totalCount").description("해당 카테고리의 전시 상품 수"),
                                fieldWithPath("productCount").description("읽어온 전시 상품 수"),
                                fieldWithPath("products[]").description("전시 상품 정보"),
                                fieldWithPath("products[].id").description("전시 상품 ID"),
                                fieldWithPath("products[].categoryId").description("카테고리 ID"),
                                fieldWithPath("products[].displayInfoId").description("전시 상품 ID"),
                                fieldWithPath("products[].name").description("전시 상품명"),
                                fieldWithPath("products[].description").description("전시 상품 설명"),
                                fieldWithPath("products[].content").description("전시 상품 내용"),
                                fieldWithPath("products[].event").description("이벤트"),
                                fieldWithPath("products[].openingHours").description("오픈 시각"),
                                fieldWithPath("products[].placeName").description("장소"),
                                fieldWithPath("products[].placeLot").description("위치"),
                                fieldWithPath("products[].placeStreet").description("도로명"),
                                fieldWithPath("products[].tel").description("연락처"),
                                fieldWithPath("products[].homepage").description("홈페이지"),
                                fieldWithPath("products[].email").description("이메일"),
                                fieldWithPath("products[].createDate").description("생성일"),
                                fieldWithPath("products[].modifyDate").description("수정일"),
                                fieldWithPath("products[].fileId").description("파일 ID")
                        )));
    }
}

이 부분을 리팩토링 해보면,

@Test
void testApiDisplayInfos() throws Exception {
            ...
            .andDo(document("/api/displayinfos",
                    preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    RestDocsFieldsGenerator.generateRequest(new DisplayInfosRequestDto().generateRestDocsFields()),
               		RestDocsFieldsGenerator.generateResponse(new DisplayInfosResponseDto().generateRestDocsFields())));
}

이런식으로 매우 깔끔하게 코드가 변경되는 것을 볼 수 있습니다. REST Docs 필드 생성기에게 요청 및 응답에 대한 필드를 생성하도록 요청하는 것이죠.

필드들에 대한 정보는 DTO 객체가 스스로 자기가 가지고 있는 필드를 제공하고 있습니다.

여기서 한번 더 리팩토링 해봅시다.

아예 문서를 생성하는 로직을 별도의 메서드로 분리해줍시다.

@Test
void testApiDisplayInfos() throws Exception {
            ...
            .andDo(generateDocument("/api/displayinfos", 
            			new DisplayInfosRequestDto(),
                        new DisplayInfosResponseDto()));
}

private static RestDocumentationResultHandler generateDocument(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        return document(identifier,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields()));
}

이런식으로 테스트 코드 내부에서 문서를 직접 생성하는 것을 담당하지 않고,
테스트 코드에서는 생성할 문서의 경로와, 요청 및 응답에 대한 DTO 객체들만 제공해주고 직접적인 생성은 외부 메서드가 하도록 분리하였습니다.

그러나 현재 API 경로인 /api/displayinfos 는 정상적으로 돌아가겠지만, 이 코드를 그대로 /api/categories API에 적용하려고 하면 예외가 발생할 겁니다.

카테고리 조회 API는 요청 파라미터가 없기 때문이죠.
즉, 카테고리 조회 테스트에서 API 문서를 생성할 때 아래와 같이 코드를 작성할 겁니다.

@Test
void testApiCategories() throws Exception {
            ...
            .andDo(generateDocument("/api/categories", 
            			null, // 요청에 대한 DTO 필드 정보가 없음
                        new CategoriesResponseDto())); // 응답 필드만 존재
}

위와 같이 요청 혹은 응답에서 필드에 대한 정보가 없을 때 NullPointerException 이 발생하게 됩니다.

이 문제를 해결해주기 위해서 문서 생성 로직을 담당하는 generateDocument() 메서드를 조금 바꿔주어야 합니다.

...
class ReservationControllerTest {
	
    ...

    private static RestDocumentationResultHandler generateDocument(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        if (request == null && response == null) {
            return generateNoneFieldsDocument(identifier); // 요청, 응답 필드가 모두 없을 때
        }
        if (request == null) {
            return generateResponseFieldsDocument(identifier, response); // 응답 필드만 존재할 때
        }
        if (response == null) {
            return generateRequestFieldsDocument(identifier, request); // 요청 필드만 존재할 때
        }
        return generateAllFieldsDocument(identifier, request, response); // 요청, 응답 필드가 모두 존재할 때
    }
}

이렇게 요청과 응답의 필드 값 유무에 따라 로직을 분리해주었습니다.

이렇게 분리한 로직은 각각 아래와 같습니다.

...
class ReservationControllerTest {
	
    ...

    private static RestDocumentationResultHandler generateNoneFieldsDocument(String identifier) {
        return document(identifier);
    }

    private static RestDocumentationResultHandler generateResponseFieldsDocument(String identifier,
                                                                                 RestDocsTemplate response) {
        return document(identifier,
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields()));
    }

    private static RestDocumentationResultHandler generateRequestFieldsDocument(String identifier,
                                                                                RestDocsTemplate request) {
        return document(identifier,
                preprocessRequest(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields()));
    }

    private static RestDocumentationResultHandler generateAllFieldsDocument(String identifier,
                                                                            RestDocsTemplate request,
                                                                            RestDocsTemplate response) {
        return document(identifier,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields()));
    }
}

여기까지 리팩토링을 해보았습니다.

테스트 메서드에서 문서를 생성하였던 것을 분리하여 메서드로 추출해주었습니다.
그런데 아직도 테스트 클래스 내에서 문서를 생성하는 것이 마음에 들지 않습니다.

그렇기 때문에 아예 문서를 생성하는 로직을 테스트 클래스에서도 덜어내버리겠습니다.

API 문서를 생성하는 클래스를 하나 만들어줍시다.

RestDocsGenerator

public class RestDocsGenerator {
    public static RestDocumentationResultHandler generate(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        if (request == null && response == null) {
            return generateNoneFieldsDocument(identifier);
        }
        if (request == null) {
            return generateResponseFieldsDocument(identifier, response);
        }
        if (response == null) {
            return generateRequestFieldsDocument(identifier, request);
        }
        return generateAllFieldsDocument(identifier, request, response);
    }

    private static RestDocumentationResultHandler generateNoneFieldsDocument(String identifier) {
        return document(identifier);
    }

    private static RestDocumentationResultHandler generateResponseFieldsDocument(String identifier, RestDocsTemplate response) {
        return document(identifier,
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields()));
    }

    private static RestDocumentationResultHandler generateRequestFieldsDocument(String identifier, RestDocsTemplate request) {
        return document(identifier,
                preprocessRequest(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields()));
    }

    private static RestDocumentationResultHandler generateAllFieldsDocument(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        return  document(identifier,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields()));
    }
}

코드가 길어보이지만 새로 추가된 코드는 단 한 줄도 없습니다.

그저 위에서 만들었던, 테스트 클래스 내에 존재하던 API 문서 생성 로직을 그대로 옮겨 담은 것 뿐입니다.

이렇게 하면 테스트 클래스에서는 RestDocsGenerator.generate() 를 호출해주면 될 것입니다. static 메서드이므로 따로 객체를 new로 생성할 필요가 없겠죠.

아래는 최종 리팩토링된 테스트 클래스입니다.

ReservationControllerTest

..
class ReservationControllerTest {
	
    ...

    @Test
    void testApiCategories() throws Exception {
    			...
                .andDo(RestDocsGenerator.generate("/api/categories", null, new CategoriesResponseDto()));
    }

    @Test
    void testApiDisplayInfos() throws Exception {
    			...
                .andDo(RestDocsGenerator.generate("/api/displayinfos", new DisplayInfosRequestDto(), new DisplayInfosResponseDto()));
    }
}

더 이상 테스트 클래스인 ReservationControllerTest 클래스에서는 문서를 생성하는 구체적인 로직이 보이지 않습니다.

이렇게 하니 훨씬 코드가 시원해진 느낌입니다.

단지 마음 속으로 한 가지 아쉬운 점은..

public class RestDocsGenerator {
	
    ...
    
    // 아래의 메서드들에게서 중복이 발생함!
    private static RestDocumentationResultHandler generateNoneFieldsDocument(String identifier) {
        return document(identifier);
    }

    private static RestDocumentationResultHandler generateResponseFieldsDocument(String identifier, RestDocsTemplate response) {
        return document(identifier,
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields()));
    }

    private static RestDocumentationResultHandler generateRequestFieldsDocument(String identifier, RestDocsTemplate request) {
        return document(identifier,
                preprocessRequest(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields()));
    }

    private static RestDocumentationResultHandler generateAllFieldsDocument(String identifier, RestDocsTemplate request, RestDocsTemplate response) {
        return  document(identifier,
                preprocessRequest(prettyPrint()),
                preprocessResponse(prettyPrint()),
                RestDocsFieldsGenerator.generateRequest(request.generateRestDocsFields()),
                RestDocsFieldsGenerator.generateResponse(response.generateRestDocsFields()));
    }
}

위의 4개의 메서드에서도 중복이 발생하는 것 같은데 개발을 하고 있는 현재, 이 부분을 해결하기 위한 더 좋은 방법이 떠오르지 않는다는 것입니다.

더 개발을 해 나가다가 떠오르는게 있으면 추가로 리팩토링을 해야겠습니다.

Repository 테스트 코드 작성

리팩토링 얘기를 길게 한 것 같은데 다시 본론으로 돌아와서,
저희는 전시 정보를 조회하는 API를 만들고 있었습니다.

요청과 응답에 대한 API를 다시 살펴보면.

요청

응답

이러한 API를 만들기 위해서 Repository 부터 테스트 코드를 작성해주겠습니다.

위의 쿼리는 3번 카테고리에 대한 전시 정보를 조회한 것입니다.
API 문서를 보면

  • totalCount: 해당 카테고리의 전시 상품 수
  • productCount: 읽어온 전시 상품 수
  • products: 전시 상품 정보

필드에 대해 표시해야 하는데 여기서 totalCount는 쿼리 결과의 row수를 반환하면 될 것이고, productCount는 모든 결과 row 중에서 나타낼 row 수만 limit 걸면 될 것입니다.

JdbcReservationRepositoryTest

public class JdbcReservationRepositoryTest {

	...

    @Test
    void testApiDisplayInfos() {
        // given
        DisplayInfosRequestDto requestDto = DisplayInfosRequestDto.builder()
                .categoryId(3)
                .start(0)
                .build();

        // when
        DisplayInfosResponseDto actual = repository.getDisplayInfos(requestDto);

        // then
//        assertThat(actual).isEqualTo(displayInfos); 모든 필드를 가지고 있지 않기 때문에 이런 간단한 방식으로는 검증할 수 없음.
        assertThat(actual.getTotalCount()).isEqualTo(16); // totalCount 검증
        assertThat(actual.getProductCount()).isEqualTo(4); // productCount 검증
        assertThat(actual.getProducts().size()).isEqualTo(16); // products 개수 검증
    }
}

API request로 카테고리 아이디가 3이고 조회 시작 위치는 0인 객체를 파라미터로 넘겨주도록 해주었습니다. 참고로 카테고리 아이디가 3인 전시 상품은 현재 총 16개 뿐입니다.

응답으로는 필드가 너무 많기 때문에..
총 응답 row가 16개이고 그 중에 4개만 조회하도록 설정해주었으며 products 값들로는 아이디와 카테고리 아이디, 그리고 파일 아이디만 검증하였습니다.

지금은 repository.getDispalyInfos() 메서드를 만들지 않았기 때문에 컴파일 에러가 발생합니다. Repository를 구현해봅시다.

Repository 구현

products 리스트의 정보들을 포함하기 위해선, 다음과 같이 5개의 테이블이 필요합니다.

  • product

  • display_info

  • category

위의 세 개의 테이블이면 products 리스트에서 fileId 필드를 제외하고는 모두 표현할 수 있습니다.

그러나 fileId 필드를 얻기 위해선 아래의 두 개의 테이블이 더 필요합니다.

  • product_image

  • file_info

상품 id로부터 file_id 필드와 type 필드를 이용하여 파일의 아이디를 가져와야 합니다. type 필드는 파일의 종류를 구분하는 필드입니다. 같은 상품 id라고 하더라도 이에 해당하는 타입이 서로 다른 파일 id가 여러 개 존재할 수 있기 때문에 type으로 파일의 종류를 지정해주는 겁니다.

따라서 이 테이블들을 이용하여 products 리스트를 받아오는 쿼리를 작성하면 아래와 같습니다.

SELECT p.id, p.category_id categoryId, di.id displayInfoId, c.name, p.description, p.content, p.event, di.opening_hours openingHours, di.place_name placeName, di.place_lot placeLot, di.place_street placeStreet, di.tel, di.homepage, di.email, di.create_date createDate, di.modify_date modifyDate,
    (
        SELECT fi.id
        FROM product_image pi, file_info fi
        WHERE pi.file_id = fi.id and pi.product_id = p.id and fi.file_name LIKE CONCAT('%', 'ma' '%')
    ) as fileId
FROM product p, display_info di, category c
WHERE p.id = di.product_id and p.category_id = c.id and p.category_id = 3

위의 쿼리는 카테고리 아이디가 3이고 파일 이미지 타입이 'ma' 인 전시 정보를 조회하는 쿼리입니다.

쿼리가 굉장히 복잡하게 되어있는 것 같은데 사실 제가 잘 작성하지 못한 것 같습니다.

서브쿼리 작성하는 부분도 더 나은 방법이 있을 것 같은데 잘 떠오르지 않습니다.
더 공부를 해보아야 겠습니다.

이제 쿼리를 만들어 주었으니 Repository 코드를 작성해봅시다.

ReservationRepository

public interface ReservationRepository {
	...

    DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto);
}

JdbcReservationRepository (리팩토링 전)

public class JdbcReservationRepository implements ReservationRepository {
    private static final String SELECT_DISPLAY_INFOS_QUERY =
            "SELECT p.id, p.category_id categoryId, di.id displayInfoId, c.name, p.description, p.content, p.event, di.opening_hours openingHours, di.place_name placeName, di.place_lot placeLot, di.place_street placeStreet, di.tel, di.homepage, di.email, di.create_date createDate, di.modify_date modifyDate, "
            + "    ("
            + "        SELECT fi.id "
            + "        FROM product_image pi, file_info fi "
            + "        WHERE pi.file_id = fi.id and pi.product_id = p.id and fi.file_name LIKE CONCAT('%', 'ma' '%') "
            + "    ) as fileId "
            + "FROM product p, display_info di, category c "
            + "WHERE p.id = di.product_id and p.category_id = c.id and p.category_id = ?";
    private static final int SHOW_PRODUCT_COUNT_AMOUNT = 4;

	...
    
    @Override // 실제 로직 부분
    public DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto) {
        List<DisplayInfoResponseDto> results = jdbcTemplate.query(
                SELECT_DISPLAY_INFOS_QUERY,
                displayInfoMapper,
                requestDto.getCategoryId() // 카테고리 아이디를 파라미터로 넘김
        );
        return new DisplayInfosResponseDto(results.size(), SHOW_PRODUCT_COUNT_AMOUNT, getProductCountResult(results, requestDto));
    }
    
    private List<DisplayInfoResponseDto> getProductCountResult(List<DisplayInfoResponseDto> results, DisplayInfosRequestDto requestDto) {
        int start = requestDto.getStart(); // 조회를 시작할 인덱스
        int end = start + SHOW_PRODUCT_COUNT_AMOUNT ; // 조회를 마칠 인덱스

        return IntStream.range(start, end) // start ~ end 범위까지의 전시 정보만 가져옴
                .mapToObj(results::get)
                .collect(Collectors.toList());
    }

    private static final RowMapper<DisplayInfoResponseDto> displayInfoMapper = (rs, rowNum) -> {
        int id = rs.getInt("id");
        int categoryId = rs.getInt("categoryId");
        int displayInfoId = rs.getInt("displayInfoId");
        String name = rs.getString("name");
        String description = rs.getString("description");
        String content = rs.getString("content");
        String event = rs.getString("event");
        String openingHours = rs.getString("openingHours");
        String placeName = rs.getString("placeName");
        String placeLot = rs.getString("placeLot");
        String placeStreet = rs.getString("placeStreet");
        String tel = rs.getString("tel");
        String homepage = rs.getString("homepage");
        String email = rs.getString("email");
        LocalDateTime createDate = rs.getObject("createDate", LocalDateTime.class);
        LocalDateTime modifyDate = rs.getObject("modifyDate", LocalDateTime.class);
        int fileId = rs.getInt("fileId");

        return new DisplayInfoResponseDto(id, categoryId, displayInfoId, name, description,
                content, event, openingHours, placeName, placeLot, placeStreet, tel,
                homepage, email, createDate, modifyDate, fileId);
    };
}

실제 로직 부분이라고 주석 처리해놓은 부분에 비해서 그렇지 않은 코드의 비중이 너무 많아서 지저분해 보입니다.

좀 있다가 차례대로 리팩토링 해봅시다.
지금은 우선 테스트 코드가 통과는 하는데, 테스트 코드가 약간 허술합니다.

저희는 테스트 코드에서 요청 파라미터로 categoryId = 3 과, start = 0 을 주었습니다. 그리고 전시 정보는 전체 정보 중에서 4개만 조회하였죠. 즉, 전체 전시 정보중에서 start가 0이므로 0 ~ 4 인덱스에 해당하는 전시 정보만 가져왔습니다.

그런데 저희가 만약 요청으로 start = 14 정도로 값을 주면 어떻게 될까요?

전체 전시 정보는 16개인데 14 ~ 18 을 조회해야 되는건가요? 뭔가 이상하죠.

이 부분을 테스트 코드로 작성해서 실행해보면 예외가 발생합니다.
따라서 테스트 코드 및 구현 코드를 수정해줍시다.

private List<DisplayInfoResponseDto> getProductCountResult(List<DisplayInfoResponseDto> results, DisplayInfosRequestDto requestDto) {
    int start = requestDto.getStart(); // 조회를 시작할 인덱스
    int end = start + SHOW_PRODUCT_COUNT_AMOUNT ; // 조회를 마칠 인덱스

    return IntStream.range(start, end) // start ~ end 범위까지의 전시 정보만 가져옴
            .mapToObj(results::get)
            .collect(Collectors.toList());
}

문제가 되는 부분의 코드입니다.

이 부분을 아래와 같이 고쳐야 잘못된 요청 파라미터가 들어오더라도, 예외가 발생하지 않습니다.

private List<DisplayInfoResponseDto> getProductCountResult(List<DisplayInfoResponseDto> results, DisplayInfosRequestDto requestDto) {
    int start = requestDto.getStart(); // 조회를 시작할 인덱스
    int end = start + SHOW_PRODUCT_COUNT_AMOUNT ; // 조회를 마칠 인덱스
    if (end >= results.size()) { // 추가된 로직
        end = results.size();
    }
    
    return IntStream.range(start, end)
            .mapToObj(results::get)
            .collect(Collectors.toList());
}

이제 테스트 코드를 다시 실행해봅시다.

JdbcReservationRepositoryTest

public class JdbcReservationRepositoryTest {

	...
    
    @ParameterizedTest
    @MethodSource("generateDisplayInfosRequestDto")
    void testApiDisplayInfos(DisplayInfosRequestDto requestDto, int expectedSize) {
        // when
        DisplayInfosResponseDto actual = repository.getDisplayInfos(requestDto);

        // then
//        assertThat(actual).isEqualTo(displayInfos); 모든 필드를 가지고 있지 않기 때문에 이런 간단한 방식으로는 검증할 수 없음.
        assertThat(actual.getTotalCount()).isEqualTo(16); // totalCount 검증
        assertThat(actual.getProductCount()).isEqualTo(4); // productCount 검증
        assertThat(actual.getProducts().size()).isEqualTo(expectedSize); // products 개수 검증
    }

    private static Stream<Arguments> generateDisplayInfosRequestDto() {
        return Stream.of(
                Arguments.of(DisplayInfosRequestDto.builder().categoryId(3).start(0).build(), 4), // 0 ~ 3으로 '4'개가 조회
                Arguments.of(DisplayInfosRequestDto.builder().categoryId(3).start(14).build(), 2) // 14 ~ 15로 '2'개가 조회
        );
    }
}

테스트 코드를 살짝 수정해보았는데요, 바로 @ParameterizedTest 부분입니다.

위의 어노테이션을 붙이면 일일이 requestDto를 두 개 생성하거나, 혹은 테스트 코드를 따로 두 개 작성할 필요 없이 하나의 테스트 코드에서 변하는 부분인 requestDto만 파라미터로 받아올 수 있습니다.

그리고 그 파라미터는 @MethodSource 에서 넘긴 메서드 명을 실행해서 리턴되는 값들을 파라미터로 넘기게 됩니다.

이렇게 테스트 코드까지 작성해주었으니, 이제 Repository 코드를 리팩토링 해주겠습니다.

Repository 리팩토링

우선, 현재 Repository의 전체 코드입니다.

JdbcReservationRepository

@Repository
@RequiredArgsConstructor
public class JdbcReservationRepository implements ReservationRepository {
    private static final String SELECT_CATEGORIES_QUERY = "SELECT category.id as id, name, count(category_id) as count from category, product, display_info where category.id = product.category_id and product.id = display_info.product_id group by category_id;";
    private static final String SELECT_DISPLAY_INFOS_QUERY =
            "SELECT p.id, p.category_id categoryId, di.id displayInfoId, c.name, p.description, p.content, p.event, di.opening_hours openingHours, di.place_name placeName, di.place_lot placeLot, di.place_street placeStreet, di.tel, di.homepage, di.email, di.create_date createDate, di.modify_date modifyDate, "
            + "    ("
            + "        SELECT fi.id "
            + "        FROM product_image pi, file_info fi "
            + "        WHERE pi.file_id = fi.id and pi.product_id = p.id and fi.file_name LIKE CONCAT('%', 'ma' '%') "
            + "    ) as fileId "
            + "FROM product p, display_info di, category c "
            + "WHERE p.id = di.product_id and p.category_id = c.id and p.category_id = ?";
    private static final int SHOW_PRODUCT_COUNT_AMOUNT = 4;

    private final JdbcTemplate jdbcTemplate;

    public CategoriesResponseDto getCategories() {
        List<CategoryResponseDto> results = jdbcTemplate.query(
                SELECT_CATEGORIES_QUERY,
                categoryMapper);
        return new CategoriesResponseDto(results.size(), results);
    }

    @Override
    public DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto) {
        List<DisplayInfoResponseDto> results = jdbcTemplate.query(
                SELECT_DISPLAY_INFOS_QUERY,
                displayInfoMapper,
                requestDto.getCategoryId()
        );
        return new DisplayInfosResponseDto(results.size(), SHOW_PRODUCT_COUNT_AMOUNT, getProductCountResult(results, requestDto));
    }

    private List<DisplayInfoResponseDto> getProductCountResult(List<DisplayInfoResponseDto> results, DisplayInfosRequestDto requestDto) {
        int start = requestDto.getStart(); // 조회를 시작할 인덱스
        int end = start + SHOW_PRODUCT_COUNT_AMOUNT ; // 조회를 마칠 인덱스
        if (end >= results.size()) { // 조회할 인덱스가 상품 개수보다 많다면
            end = results.size();
        }

        return IntStream.range(start, end)
                .mapToObj(results::get)
                .collect(Collectors.toList());
    }

    private static final RowMapper<CategoryResponseDto> categoryMapper = (rs, rowNum) -> {
        int id = rs.getInt("id");
        String name = rs.getString("name");
        int count = rs.getInt("count");
        return new CategoryResponseDto(id, name, count);
    };

    private static final RowMapper<DisplayInfoResponseDto> displayInfoMapper = (rs, rowNum) -> {
        int id = rs.getInt("id");
        int categoryId = rs.getInt("categoryId");
        int displayInfoId = rs.getInt("displayInfoId");
        String name = rs.getString("name");
        String description = rs.getString("description");
        String content = rs.getString("content");
        String event = rs.getString("event");
        String openingHours = rs.getString("openingHours");
        String placeName = rs.getString("placeName");
        String placeLot = rs.getString("placeLot");
        String placeStreet = rs.getString("placeStreet");
        String tel = rs.getString("tel");
        String homepage = rs.getString("homepage");
        String email = rs.getString("email");
        LocalDateTime createDate = rs.getObject("createDate", LocalDateTime.class);
        LocalDateTime modifyDate = rs.getObject("modifyDate", LocalDateTime.class);
        int fileId = rs.getInt("fileId");

        return new DisplayInfoResponseDto(id, categoryId, displayInfoId, name, description,
                content, event, openingHours, placeName, placeLot, placeStreet, tel,
                homepage, email, createDate, modifyDate, fileId);
    };
}

코드가 굉장히 지저분합니다.

이제 리팩토링을 할 건데, 주요 리팩토링 요소는 아래와 같습니다.

  • SQL 쿼리
  • Mapper

이 두 개를 중심으로 리팩토링을 진행해봅시다.

SQL 쿼리 리팩토링

SQL 쿼리를 외부 유틸리티 클래스에게로 분리해서 관리하도록 만들어줍시다.

SQLMapper

public class SQLMapper {
    public static final String SELECT_CATEGORIES_QUERY = "SELECT category.id as id, name, count(category_id) as count from category, product, display_info where category.id = product.category_id and product.id = display_info.product_id group by category_id;";
    public static final String SELECT_DISPLAY_INFOS_QUERY =
            "SELECT p.id, p.category_id categoryId, di.id displayInfoId, c.name, p.description, p.content, p.event, di.opening_hours openingHours, di.place_name placeName, di.place_lot placeLot, di.place_street placeStreet, di.tel, di.homepage, di.email, di.create_date createDate, di.modify_date modifyDate, "
                    + "    ("
                    + "        SELECT fi.id "
                    + "        FROM product_image pi, file_info fi "
                    + "        WHERE pi.file_id = fi.id and pi.product_id = p.id and fi.file_name LIKE CONCAT('%', 'ma' '%') "
                    + "    ) as fileId "
                    + "FROM product p, display_info di, category c "
                    + "WHERE p.id = di.product_id and p.category_id = c.id and p.category_id = ?";
}

Repository가 조금은 깔끔해졌습니다.

Mapper 리팩토링

이제 각 Mapper들이 각자의 DTO 객체 내부에 존재하도록 리팩토링 해주겠습니다.

CategoryResponseDto

@Builder
@RequiredArgsConstructor
@Getter
@EqualsAndHashCode
public class CategoryResponseDto {
    private final int id;
    private final String name;
    private final int count;

    public static final RowMapper<CategoryResponseDto> categoryMapper = (rs, rowNum) -> {
        int id = rs.getInt("id");
        String name = rs.getString("name");
        int count = rs.getInt("count");
        return new CategoryResponseDto(id, name, count);
    };
}

DisplayInfoResponseDto

@Builder
@Getter
@RequiredArgsConstructor
public class DisplayInfoResponseDto {

	...

    public static final RowMapper<DisplayInfoResponseDto> displayInfoMapper = (rs, rowNum) -> {
        int id = rs.getInt("id");
        int categoryId = rs.getInt("categoryId");
        int displayInfoId = rs.getInt("displayInfoId");
        ...
        

        return new DisplayInfoResponseDto(id, categoryId, displayInfoId, name, description,
                content, event, openingHours, placeName, placeLot, placeStreet, tel,
                homepage, email, createDate, modifyDate, fileId);
    };
}

이렇게 분리된 Repository 코드는 아래와 같습니다.

JdbcReservationRepository

@Repository
@RequiredArgsConstructor
public class JdbcReservationRepository implements ReservationRepository {
    private static final int SHOW_PRODUCT_COUNT_AMOUNT = 4;

    private final JdbcTemplate jdbcTemplate;

    public CategoriesResponseDto getCategories() {
        List<CategoryResponseDto> results = jdbcTemplate.query(
                SQLMapper.SELECT_CATEGORIES_QUERY,
                CategoryResponseDto.categoryMapper);
        return new CategoriesResponseDto(results.size(), results);
    }

    @Override
    public DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto) {
        List<DisplayInfoResponseDto> results = jdbcTemplate.query(
                SQLMapper.SELECT_DISPLAY_INFOS_QUERY,
                DisplayInfoResponseDto.displayInfoMapper,
                requestDto.getCategoryId()
        );
        return new DisplayInfosResponseDto(results.size(), SHOW_PRODUCT_COUNT_AMOUNT, getProductCountResult(results, requestDto));
    }

    private List<DisplayInfoResponseDto> getProductCountResult(List<DisplayInfoResponseDto> results, DisplayInfosRequestDto requestDto) {
        int start = requestDto.getStart(); // 조회를 시작할 인덱스
        int end = start + SHOW_PRODUCT_COUNT_AMOUNT ; // 조회를 마칠 인덱스
        if (end >= results.size()) { // 조회할 인덱스가 상품 개수보다 많다면
            end = results.size();
        }

        return IntStream.range(start, end)
                .mapToObj(results::get)
                .collect(Collectors.toList());
    }
}

필요한 코드들만 남아있게 되었습니다.

이제 Service 코드를 구현해봅시다.

Service 테스트 코드 작성

이번에도 마찬가지로 테스트 코드부터 만들어봅시다.

ReservationServiceTest

class ReservationServiceTest {
	
    ...

    @Test
    void testApiDisplayInfos() {
        // given
        DisplayInfosRequestDto requestDto = DisplayInfosRequestDto.builder()
                .categoryId(3)
                .start(0)
                .build();

        // when 스텁 구현
        when(repository.getDisplayInfos(any(DisplayInfosRequestDto.class))).thenReturn(generateDisplayInfosResponseDto());

        DisplayInfosResponseDto actual = service.getDisplayInfos(requestDto);

        // then
        assertThat(actual.getTotalCount()).isEqualTo(16); // totalCount 검증
        assertThat(actual.getProductCount()).isEqualTo(4); // productCount 검증
        assertThat(actual.getProducts().size()).isEqualTo(4); // products 개수 검증
    }

    private DisplayInfosResponseDto generateDisplayInfosResponseDto() {
        return DisplayInfosResponseDto.builder()
                .totalCount(16)
                .productCount(4)
                .products(
                        List.of(
                                DisplayInfoResponseDto.builder().build(),
                                DisplayInfoResponseDto.builder().build(),
                                DisplayInfoResponseDto.builder().build(),
                                DisplayInfoResponseDto.builder().build()
                        )
                )
                .build();
    }
}

when( ) 메서드를 이용하여repository.getDisplayInfos() 메서드에 인자로 DisplayInfosRequestDto 타입의 파라미터를 넘겨주면 DisplayInfosResponseDto 객체가 반환되도록 스텁을 구현해주었습니다.

이번에도 필드가 응답에 대한 필드가 많기 때문에 응답 개수로 검증해주었습니다.

이제 실제 서비스 코드를 구현해봅시다.

Service 코드 작성

ReservationService

@Service
@RequiredArgsConstructor
public class ReservationService {
    private final ReservationRepository repository;

	...

    public DisplayInfosResponseDto getDisplayInfos(DisplayInfosRequestDto requestDto) {
        return repository.getDisplayInfos(requestDto);
    }
}

이렇게만 해주면 테스트가 통과합니다.

Controller 테스트 코드 작성

이제 마지막으로 컨트롤러 코드를 작성해봅시다.

ReservationControllerTest

class ReservationControllerTest {

	...

    @Test
    void testApiDisplayInfos() throws Exception {
        // given
        DisplayInfosRequestDto requestDto = DisplayInfosRequestDto.builder()
                .categoryId(3)
                .start(0)
                .build();

        // when
        when(service.getDisplayInfos(any(DisplayInfosRequestDto.class))).thenReturn(ReservationServiceTest.generateDisplayInfosResponseDto());

        // then
        mockMvc.perform(get("/api/displayinfos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.totalCount").value(16))
                .andExpect(jsonPath("$.productCount").value(4))
                .andExpect(jsonPath("$.products").isArray())
                .andExpect(jsonPath("$.products", hasSize(4)))
                .andDo(RestDocsGenerator.generate("/api/displayinfos", new DisplayInfosRequestDto(), new DisplayInfosResponseDto()));
    }
}

when( ) 메서드를 통해서 service.getDisplayInfos() 를 실행하면 Service에서 생성한 응답 결과를 반환하도록 스텁을 작성해주었습니다.

참고로 위의 로직을 위해서 ReservationServiceTest 클래스를 public 접근 제한자로 만들어주었고, 응답 결과 메서드를 public static 으로 설정해주었습니다.

또한 응답 검증으로 totalCount = 16, productCount = 4, products.size = 4 이러한 데이터 들을 검증합니다.

이러한 테스트를 통과시켜주기 위해서 컨트롤러 코드를 구현해줍시다.

Controller 코드 작성

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class ReservationController {
    private final ReservationService service;
    	
    ...
    
    @GetMapping("/displayinfos")
    public DisplayInfosResponseDto getDisplayInfos(@RequestBody DisplayInfosRequestDto requestDto) {
        return service.getDisplayInfos(requestDto);
    }
}

이제 테스트는 통과할 것이고 이렇게 생성된 스니펫을 이용하여 API 문서를 확인해주면 아래와 같습니다.

API 문서

요청

응답

Service를 Mocking 했기 때문에 가상의 데이터가 응답으로 반환된 것을 문서에서 확인할 수 있습니다. 그런데 이렇게 products의 값이 없는 상태면 아무래도 API 문서라고 보기는 어렵기 때문에 실제 DB로부터 가져온 값을 API 문서로 띄우도록 만들어 줍시다.

그러기 위해서 지금까지 Controller 단위 테스트로 작성했던 REST Docs를 통합 테스트를 이용하여 실제 DB로부터 데이터를 가져와 보겠습니다.

REST Docs 생성 기능 리팩토링

먼저 Controller 테스트에서 REST Docs를 생성하지 않도록 리팩토링 해줍시다.

ReservationControllerTest

// @WebMvcTest(ReservationController.class)
// @ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
class ReservationControllerTest {
    private ObjectMapper objectMapper = new ObjectMapper();

    @InjectMocks // 컨트롤러 및 서비스를 목을 통해 주입
    private ReservationController controller;

    @Mock
    private ReservationService service;

    private MockMvc mockMvc;

    /*@Autowired
    private WebApplicationContext context;*/

    /*@BeforeEach // REST Docs를 위한 MockMvc 설정을 제거
    void setUp(RestDocumentationContextProvider restDocumentation) {
        MockitoAnnotations.openMocks(this);
        mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(restDocumentation).snippets()
                                .withTemplateFormat(TemplateFormats.asciidoctor())
                        *//*.withTemplateFormat(TemplateFormats.markdown())*//*)
                .build();
    }*/

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
    }

    @Test
    void testApiCategories() throws Exception {
        // when
        when(service.getCategories()).thenReturn(JdbcReservationRepositoryTest.categories);

        // then
        mockMvc.perform(get("/api/categories"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.size").exists())
                .andExpect(jsonPath("$.items").isArray())
                .andExpect(jsonPath("$.items[0].id").value(1))
                .andExpect(jsonPath("$.items[0].name").value("전시"))
                .andExpect(jsonPath("$.items[0].count").value(10))
                .andExpect(jsonPath("$.items[4].id").value(5))
                .andExpect(jsonPath("$.items[4].name").value("연극"))
                .andExpect(jsonPath("$.items[4].count").value(13));
//                .andDo(RestDocsGenerator.generate("/api/categories", null, new CategoriesResponseDto()));
    }

    @Test
    void testApiDisplayInfos() throws Exception {
        // given
        DisplayInfosRequestDto requestDto = DisplayInfosRequestDto.builder()
                .categoryId(3)
                .start(0)
                .build();

        // when
        when(service.getDisplayInfos(any(DisplayInfosRequestDto.class))).thenReturn(ReservationServiceTest.generateDisplayInfosResponseDto());

        // then
        mockMvc.perform(get("/api/displayinfos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.totalCount").value(16))
                .andExpect(jsonPath("$.productCount").value(4))
                .andExpect(jsonPath("$.products").isArray())
                .andExpect(jsonPath("$.products", hasSize(4)));
//                .andDo(RestDocsGenerator.generate("/api/displayinfos", new DisplayInfosRequestDto(), new DisplayInfosResponseDto()));
    }
}

컨트롤러 테스트에서 REST Docs와 관련된 코드들을 제거해주었습니다.

그리고 새로운 테스트 클래스를 만들어 거기서 REST Docs를 만들어 줍시다.

ReservationRestDocsTest

@ExtendWith({RestDocumentationExtension.class})
@SpringBootTest
public class ReservationRestDocsTest {
    private ObjectMapper objectMapper = new ObjectMapper();

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext context;

    @BeforeEach
    void setUp(RestDocumentationContextProvider restDocumentation) {
        MockitoAnnotations.openMocks(this);
        mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(restDocumentation).snippets()
                                .withTemplateFormat(TemplateFormats.asciidoctor()))
                .build();
    }

    @Test
    void generateDocsApiCategories() throws Exception {
        // then
        mockMvc.perform(get("/api/categories"))
                .andExpect(status().isOk())
                .andDo(RestDocsGenerator.generate("/api/categories", null, new CategoriesResponseDto()));
    }

    @Test
    void generateDocsApiDisplayInfos() throws Exception {
        // given
        DisplayInfosRequestDto requestDto = DisplayInfosRequestDto.builder()
                .categoryId(3)
                .start(0)
                .build();

        // then
        mockMvc.perform(get("/api/displayinfos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(requestDto)))
                .andExpect(status().isOk())
                .andDo(RestDocsGenerator.generate("/api/displayinfos", new DisplayInfosRequestDto(), new DisplayInfosResponseDto()));
    }
}

이렇게 통합 테스트로 실제 데이터베이스에서 조회되는 데이터를 이용하여 API 문서를 작성하도록 만들어주었습니다.

아래는 그로부터 만들어진 API 문서입니다.

API 문서

요청

응답

정상적으로 API 문서가 작성된 것을 볼 수 있습니다.

이번에 전시 정보 조회 API를 만들면서 동시에 리팩토링까지 진행하는 바람에 내용이 길어지긴 했지만, 그 과정을 하나하나 기록하는 것도 의미가 있을 것 같아서 조금 주절주절 하게 되었습니다.

다음엔 프로모션을 구하는 API를 만들어 보겠습니다.

0개의 댓글