[회고] 코드숨 스프링 12기 5주차

개발자 춘식이·2022년 12월 12일
0

CodeSoom

목록 보기
5/8
post-thumbnail

5주 차 회고록

이번 주는 회사 일이 너무 바빴다. 과제 할 여력이 없어 Pull Request를 평소보다 늦게 했고, 분명히 잘 돌아가야 하는 코드여야 하는데 에러가 나서 그거 찾느라 시간도 많이 허비했다..(결국 스프링 버전 문제였음) 따라서 코드 리뷰도 많이 받지 못했던 게 너무 아쉽다. 🥲

이번 주는 과제 주제가 유효성 검사였는데, 나는 유효성 검사보다 DTO의 사용 이유를 이번에 깨달았기 때문에 그 부분이 더 와닿았다. 여태까지는 DTO를 목적 별로 분리하지 않았지만, 이번 주에는 영상에서 UserRegistrationData, UserModificationData, UserResultData 목적 별로 3개의 dto가 나누는 것을 배웠는데 처음에 이해하지 못했지만, 코드의 전체적인 흐름을 파악하고 나니 유레카였다!

  • UserRegistrationData는 회원가입용 DTO로 DB에 들어갈 ID가 생성되기 전이기 때문에 이름, 이메일, 비밀번호만을 입력받고 유효성 검사를 진행한다. -> 서비스에서 User 객체와 맵핑하고, user 객체를 리포지토리에 저장한다. -> 컨트롤러에서 다시 UserResultData DTO를 리턴한다.
  • UserModificationData는 회원 정보 수정용 DTO로 이메일이 Unqiue Key이므로 이름과 비밀번호만을 사용자로부터 받아 그 정보로 수정하는 용도의 DTO이다. 이 역시 서비스에서 비즈니스 로직 처리 후 UserResultData DTO를 리턴한다.

이런 식으로 DTO를 용도별로 나누게 되면 불필요한 필드가 계층 간 이동을 할 필요가 없다!


5주 차 Keyword

  • Java Validation : spring-boot-starter-validation을 사용하면 객체의 유효성을 애노테이션으로 확인할 수 있다. null이 아니거나, 최소 글자나, 정규식, 이메일 등의 조건을 걸 수 있다.
  • Object Mapper : 객체와 객체끼리 맵핑해주는 라이브러리 중 하나인 Dozer를 배웠으나, 과제에서는 빌더패턴을 사용하여 맵핑해주었기 때문에 사용하지 않았다.

코드 리뷰 코멘트

📌 MethodArgumentNotValidException를 핸들링하는 복잡한 코드를 간단하게

		BindingResult result  = ex.getBindingResult();
        StringBuilder builder = new StringBuilder();
        List<FieldError> fieldErrors = result.getFieldErrors();

        builder.append("[");
        for(FieldError error : fieldErrors) {
            builder.append(error.getDefaultMessage());
            builder.append(",");
        }
        builder.deleteCharAt(builder.lastIndexOf(",")); //쉼표 제거
        builder.append("] 은(는) 필수 입력 사항입니다.");

        return new ErrorResponse(builder.toString());

위의 코드를 아래와 같이 더 간단하게 코딩할 수 있다.

        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map((e) -> e.getDefaultMessage())
                .collect(Collectors.joining(","));

        return new ErrorResponse("[" + message + "] 은(는) 필수 입력 사항입니다.");

📌 어떤 메서드가 주어진 값이 null인지를 고려하는 함수는 이례적인 일이다. 코드를 읽는 다른 사람에게 추가적인 설명이 필요하다. 또한 null인지 체크하는 코드는 코드의 내결함성을 높여서 null이 오더라도 정상적으로 처리할 수 있어 보기에는 좋아 보이지만, 그 부분이 더 위험할 수도 있다. 무엇인가 잘못되어도 빠르게 파악할 수 없기 때문이다. 아예 NPE를 던지는 게 더 나을 수도 있다.

📌 테스트 명을 좀 더 명백하게 설명해주자.

📌 Context는 주어진 상황이나 환경이 다른 경우에 다르게 동작할 경우를 명시하는 것이 좋다.

📌 그리고 문제의 BDD 스타일에서의 Mockito 사용 시 발생하는 버그이다.

1. 원인

  • 'org.springframework.boot' version '2.3.5.RELEASE'
  • ProductControllerTest 클래스에서 상단에 아래와 같이 @BeforeEach 메서드 작성
 @BeforeEach
 void setUp() {
        given(productService.getProduct(1000L))
                .willThrow(new ProductNotFoundException(1000L));

        given(productService.deleteProduct(1000L))
                .willThrow(new ProductNotFoundException(1000L));
}
  • 테스트 일괄적으로 돌릴 시 아래와 같은 에러 무더기로 발생
  • 그러나 하나씩 테스트 돌리면 정상 작동됨.

2. 답변
일단 왜 안 되는지를 아는 것보다 이런 경우 어떻게 알아내는가?가 더 중요하다고 생각해서 그 과정을 남겨봅니다. 이건 제 생각이기 때문에 참고로만 보시는 게 좋습니다.

저는 코드를 복구하여 상황을 재현했습니다. 각 테스트에 있는 @BeforeEach를 제거하고, 가장 상단에 있는 @BeforeEach만 남겨놓고 테스트를 실행했더니, 말씀하신 대로 상황이 재현되었습니다.

코드가 어떻게 실행되는지 멘탈 시뮬레이션을 했습니다. 그래서 테스트 코드가 실행되는 순서를 살펴보았습니다.

테스트 코드가 너무 많아서 문제를 재현할 수 있는 가장 최소 단위로 줄여보았습니다. 그래서 GET 요청 테스트를 제외한 코드들은 모드 주석처리 하였고 문제가 재현되는 것을 확인했습니다.

하나의 테스트 코드를 실행시키면 어떻게 되는지 확인했습니다. 테스트 하나만 실행할 때는 문제가 발생하지 않는 것을 확인했습니다. 딱 2개 이상부터 문제가 재현됩니다.

위 정보를 조합하여 가설을 세웁니다. ProductControllerTest 테스트에서

given(productService.getProduct(1000L))
                .willThrow(new ProductNotFoundException(1000L));

가 2번 이상 실행되면 테스트가 실패한다.

위를 검증하기 위해 하나의 테스트에서 가장 상단에 @BeforeEach도 있고, 각 테스트 안에도 @BeforeEach를 넣어서 2번 실행하도록 합니다.

// ... 생략

@BeforeEach
void setUp() {
    given(productService.getProduct(1000L))
                .willThrow(new ProductNotFoundException(1000L));
}

@Nested
@DisplayName("GET 요청은")
class Describe_get {
    @Nested
    @DisplayName("id가 없으면")
    class Context_without_segment {
        @BeforeEach()
        void setUp() {
            given(productService.getProduct(1000L))
                    .willThrow(new ProductNotFoundException(1000L));
        }

        @Test
        @DisplayName("모든 Product 리스트를 리턴한다")
        void it_return_products() throws Exception {
            mockMvc.perform(get("/products")
                            .accept(APPLICATION_JSON))
                    .andExpect(status().isOk());

            verify(productService).getProducts();
        }
    }
}

그랬더니 실패합니다. 즉 2번 실행되면 안 된다 라는 것을 검증했습니다. 그러면 다음 스텝은 왜 안되는가? 그러면 어떻게 해야 하는가?를 고민할 시간입니다.

왜 안되는가?를 알아내기 위해 일단 문서를 살펴볼 것 같습니다. 일단 인텔리j에서 함수 Javadocs를 살펴보고 아래 문서를 살펴볼 것 같습니다
https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html

디버깅하면서 확인해 보았는데, Mockito가 어떻게 동작하는지 정확히 몰라서 많이 헤맸네요.

일단 디버깅 하면서 어떻게 동작하는지 파악하려고 했습니다.

디버그 모드로 실행하여, 실행되기 전의 productService.getProduct(1000L)을 실행하면 null을 반환하는 것을 확인할 수 있습니다.

아마도 productService가 Mock으로 만든 객체이기 때문이겠죠.

그다음 2번째로 실행될 때는 어떻게 다르길래 안되는지 디버거로 실행해보니 다음과 같이 결과가 나오는 것을 확인할 수 있었습니다.

when(productService.getProduct(1000L))
                .thenThrow(new ProductNotFoundException(1000L));

위에 코드를 실행하려면 when함수 안에 productService.getProduct(1000L)을 평가한 것을 넣어야 하는데, 평가할 때 이미 예외를 던지니 여기서 예외가 던져져서 테스트가 실패하고 있었습니다.

그렇다면 처음에는 왜 null이고 그게 왜 기억되고 있느냐? 가 궁금하긴 한데, 그건 아직 발견하진 못했고, mock 된 객체를 초기화하면 될 것 같아서

다음과 같이 BeforeEach맨 위에 초기화 함수를 넣었더니 잘 되는 것을 확인했습니다.

reset(productService);

See also

3. 해결 방법

  • 각 내부 클래스 안에 given~willThrow 사용해주기
  • 가장 상단의 @BeforeEach에서 reset(productService)로 mock 초기화해주기 (비권장)
  • given~willThrow 대신에 willThrow~given~메소드 해주기 (void 메서드에서 활용하는 방법인데 왜 이때는 작동하는지는 모르겠음)
  • 스프링 버전 업그레이드해주기!

4. 결론

  • 왜 에러 나는지보다 에러 나는 경우를 어떻게 알아내는가?가 더 중요하다.
  • 공식문서를 살펴보자. 영어 공부를 하자.

마무리

바빴음에도 불구하고 끝까지 과제를 종주한 나 자신에게 박수를~!!! 👏🏻👏🏻👏🏻

profile
춘식이를 너무 좋아하는 주니어 백엔드 개발자입니다.

0개의 댓글