제네릭을 사용한 추상화 및 중복제거

카일·2020년 9월 14일
4

우아한 테크 마켓

목록 보기
4/6
post-thumbnail

문제 상황

안녕하세요. 👨‍💻 이번 포스팅에서는 반복되는 테스트를 어떻게 추상화할까? 라는 고민에 대한 글입니다. 기존에 작성했던 글처럼 저는 하나의 도메인에 대해서 아래의 테스트를 진행합니다. 진행중인 마켓 프로젝트는 실무에 비하면 너무나 적은 도메인 수(6개)를 가지고 있습니다. 그럼에도 아래의 테스트를 모두 작성하는 과정에서 중복 코드가 발생하고 테스트 코드가 유지보수 하기 어려워졌습니다.

  • 인수테스트
  • 컨트롤러 테스트
  • 서비스 테스트 & 도메인 테스트
  • 리파지토리 테스트(커스텀 메소드가 추가되는 경우)

그래서 테스트를 Generic을 사용해서 일반화 해보면 어떨까? 라고 생각하게 되었고 이를 코드로 옮긴 부분을 공유하고자 합니다. 더 좋은 방법이 있을 것 같은데 괜찮은 방법이 있으시면 공유해주시면 감사하겠습니다!!

문제의 코드

대표적으로 중복이 발생하는 부분은 도메인별 CRUD(Create, Read, Update, Delete)메소드였습니다. 인수 테스트와 컨트롤러 테스트에서 계속적으로 비슷한 요청을 보내고 비슷한 응답에 대해서 처리하는 것을 발견하였습니다.

아래의 코드는 리팩토링 하기 전의 코드입니다.아래와 같은 코드뿐 아니라 모든 도메인의 Get, Put, Patch, Delete 요청에 대한 부분이 중복되었습니다.

  • Controller - POST
@DisplayName("회원을 정상적으로 생성한다.")  // Controller Test
    @Test
    void create() throws Exception {
        when(bearerInterceptor.preHandle(any(), any(), any())).thenReturn(true);
        when(memberService.createMember(any())).thenReturn(MemberFixture.createResponse());

        return mockMvc.perform(post("/api/members")
            .header(HttpHeaders.AUTHORIZATION, LoginFixture.getUserTokenHeader())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsBytes(MemberFixture.createMemberRequest()))
        )
            .andExpect(status().isCreated())
            .andExpect(header().string(HttpHeaders.LOCATION, "/api/members/1");
    }
  • Acceptance - POST
protected Long createMember(MemberCreateRequest memberRequest) {
        String response = given()
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .body(memberRequest)
            .when()
            .post("/api/members")
            .then()
            .log().all()
            .statusCode(HttpStatus.CREATED.value())
            .extract().header(HttpHeaders.LOCATION);

        return Long.parseLong(response.substring("/api/members/1".length() + 1));
}

해결 - 제네릭 사용

위의 코드에서 다른 도메인을 테스트 하더라도, 달라지는 부분은 Request, Path, Header 정도를 제외하고는 거의 존재하지 않습니다. 이에 제네릭을 사용해서 다양한 타입을 커버할 수 있도록 작성하면 어떨까라는 생각을 하게 되었고 아래의 코드로 리팩토링 하게 되었습니다.

  • Controller - POST, PUT (Get과 Delete도 동일한 방식입니다. 길이가 길어 두개만 작성하였습니다)
protected <T> ResultActions doPost(String path, T request) throws Exception {
        return mockMvc.perform(post(path)
            .header(HttpHeaders.AUTHORIZATION, LoginFixture.getUserTokenHeader())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsBytes(request))
        )
            .andExpect(status().isCreated())
            .andExpect(header().string(HttpHeaders.LOCATION, path + "/1"));
}

protected <T> ResultActions doPut(String path, T request) throws Exception {
        return mockMvc.perform(put(path)
            .header(HttpHeaders.AUTHORIZATION, LoginFixture.getUserTokenHeader())
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsBytes(request))
        )
            .andExpect(status().isNoContent());
}
  • Acceptance - GET, DELETE
protected <T> T doGet(String path, Header header, Class<T> response) {
        return given()
            .header(header)
            .accept(MediaType.APPLICATION_JSON_VALUE)
            .when()
            .get(path)
            .then()
            .log().all()
            .statusCode(HttpStatus.OK.value())
            .extract().as(response);
}
protected <T> void doDelete(String path, Header header) {
        given()
            .header(header)
            .when()
            .delete(path)
            .then()
            .log().all()
            .statusCode(HttpStatus.NO_CONTENT.value());
}

사용하기 편한 코드

위와 같이 제네릭을 통해 CRUD를 일반화했기 때문에 각 도메인별 Controller와 AcceptanceTest는 많은 중복이 제거 되었습니다. 아래가 변경된 테스트입니다. 모든 도메인의 CRUD는 아래의 메소드에 적절한 타입만 넣어준다면 테스트가 가능합니다!

  • doPost(String path, T request, Header header)
  • doGet(String path, Header header Class< T > response)
  • doPut(String path, Header header, T request)
  • doPatch(String path, Header header, T request)
  • doDelete(String path, Header header)
@DisplayName("회원을 정상적으로 생성한다.")
    @Test
    void create() throws Exception {
        when(bearerInterceptor.preHandle(any(), any(), any())).thenReturn(true);
        when(memberService.createMember(any())).thenReturn(MemberFixture.createResponse());

        doPost(BASE_PATH, MemberFixture.createMemberRequest());
    }

결론

API에 따라서 그리고 비즈니스 로직에 따라서 변경되는 로직까지는 추상화하기 힘들 것 같습니다. 하지만 기본적인 CRUD는 대부분의 도메인에서 제공하고 있기 때문에 이를 추상화하는 것은 범용적으로 사용할 수 있지 않을까 라고 생각하여 위와 같이 리팩토링을 해보았습니다. 위와 같은 추상화를 통해 제가 얻을 수 있는 장점은 이런 것들이 있는 것 같습니다.

  • 중복의 제거 - 6개의 도메인인데도 행복해졌어요...
  • API 스펙 및 테스트 일관성 - 모든 CRUD는 동일한 StatusCode를 반환하도록 권장할 수 있습니다.
  • 사용 편의성 - 도메인이 50개라면 동일한 CRUD를 모두 테스트하는 것이 매우 힘든 작업일 것 같습니다. 위와 같이 추상화한다면 조금 더 편리하게 작업할 수 있을 것 같아요.

너무 부족한 글입니다. 고칠 점이나 다른 방법에 대해서 알려주시면 언제나 너무 감사할 것 같네요! 좋은 하루 보내세요😊

참고

  • 실제 Production 코드는 단순히 타입을 받아서 그 자체로 사용하는 형태가 아니기 때문에 제네릭을 적용하기 힘들었습니다. (예를 들어 MemberCreateRequest를 받아서, toEntity()라는 메소드를 호출해야 하는데 위와 같은 T 타입을 사용하면 이것이 불가능합니다.)
  • 사실 위의 리팩토링에 대한 효용은 지나봐야 알 것 같습니다. 대부분의 코드가 제가 리팩토링한 제네릭 범위를 벗어난다면, 오해를 부르기 좋은 코드라고 생각하여 제거할 것 같습니다.
  • Service 계층은 중요한 비즈니스 로직이 존재하지 않으면 코드가 짧기 때문에(DB에서 가져와서 변환만 해주는 정도) 추상화하지 않아도 충분히 귀찮지 않은 작업인 것 같습니다.
  • Mocking과 관련된 부분은 추상화 혹은 중복제거를 할 수도 있지만, 각 테스트에서 어떤 것을 Mocking했는지 명확하게 보여주는 것이 좋을 것 같아서 도메인 테스트에 그대로 두었습니다.
  • 문서화를 하는 부분은 구현 이후에 추가 작성하도록 하겠습니다.😊

0개의 댓글