Spring/Spring Boot

🚀 Spring Request DTO 추상클래스로 받아오기 (까지의 트러블 슈팅..😂)

민돌v 2022. 11. 17. 22:41

 

이번 포스팅은 스프링에서 Client 에서 받는 Json 데이터를, 조건에 따라 다른 유효성 검사를 하기위한 과정에서의 트러블 슈팅 기록입니다..

 

➡️ 상황 

  • 사내에서, 쿠폰 정책에 따라 각각의 쿠폰을 등록할 떄, 필요한 조건이 달라졌기 때문에
  • 기존의 단일 유효성 검사에서, 어떤 쿠폰이냐에 따라서 Custom 한 Validation 을 적용해야하는 상황이였습니다.

 

[문제 직면부터 시도과정]

  1. @RequestBody 로 받아오는 DTO 클래스 생성자에 throw Exception  ➡️ 내가 던진 Exception 이 아닌 예상치 못한
    `Json Parse Error : HttpMessgeException` 이 던져지고, `ClassCastException`이 터짐..
  2. 문제 해결 후, 확장성을 고려하여 DTO 를 추상클래스로 격상 
  3. 추상클래스 적용 후, 잘되던 ControllerTest 에서 예상치 못했던 Exception 이 던져짐

 

 

이런 과정이 있었고.. 정보성 글 보다는 뻘글에 가깝습니다.

별거 아니지만 살짝 억울해서 기록을 남겨봅니다 (하.. 더 분발해야해)


 

📌 1. @RequestBody Json To class 바인딩 시 Throw Exception 던질 떄

그러니까 상황이 @RequestBody로 json 데이터를 java dto class로 바인딩할 때 생성자를 사용하여 바인딩해주는 것으로 알고 있습니다.

Lombok을 사용하여 @Vaild 어노테이션의 @NotNull 과 같은 Validation Anotaion은 생성자로 모두 값이 바인딩 된 후, Lombok 에서 리플렉션을 이용하여 값을 후 체크하는 것으로 알고있었습니다.

즉, @Value 도 json 객체가 dto 로 바인딩이 완료된 후! 할당된 멤버변수를 체크해서 MethodArgumentException 을 떨궈주는 시나리오다!!


그래서❓ 하고싶은건❓

그래서 생성자를 호출할 떄, validation 메소드를 호출하면, 유효성검사 로직을 탈거라는 단순한 생각을 해줬고, Custom Exception 을 던져서 Handling 하고자 했습니다.

 

코드로 보면, Controller 에서는 @RequestBody로 바인딩하고

@PostMapping
public ResponseEntity<Objects> createCoupon(
	@RequestBody @Valid TestDTO request) {
        
	//뭔가의 서비스 로직
    
	return ResponseEntity.status(HttpStatus.CREATED).build();
}

RequestDto 는 아래처럼 생성자에서 validation을 진행합니다!

@Value
@Builder
public class TestDTO {

    @NotNull(message = "쿠폰 명 null 이어서는 안됩니다")
    String name;

    ... 생략

    @NotNull(message = "쿠폰 입력 코드 리스트 null 이어서는 안됩니다")
    List<String> couponCodeList;

    public TestDTO(final Integer publishTypeIdx, ..., final List<String> couponCodeList,) {

        validCodeList(couponCodeList);
        this.name = name;
			...
        this.couponCodeList = couponCodeList;
    }

    void validCodeList(List<String> couponCodeList) {
        if (couponCodeList.size() < 1) {
            throw new IllegalArgumentException("쿠폰 코드를 입력해주세요");
        }
        if (couponCodeList.stream().anyMatch(s -> !StringUtils.hasText(s))) {
            throw new IllegalArgumentException("빈 코드값이 존재합니다.");
        }
    }
}

 

💡 제 시나리오에 의하면,

  • IllegalArgumentException 은 AOP 로 Custom하게 핸들링을 하고있기 때문에, IllegalArgumentException 이 제가 작성한 에러메세지와 함께 아주 예쁘게 Response 가 되어야합니다.

 

하지만 아래처럼 이상한 message 가 날라가고 "ClassCastException" 이 터져버렸죠..

{
    "timestamp": "2022-11-17T12:16:45.741+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "JSON parse error: Cannot construct instance of `com.pood.server.facade.coupon.request.TestDTO`, problem: 쿠폰 코드를 입력해주세요; nested exception is com.fasterxml.jackson.databind.exc.ValueInstantiationException: Cannot construct instance of `com.pood.server.facade.coupon.request.TestDTO`, problem: 쿠폰 코드를 입력해주세요\n at [Source: (PushbackInputStream); line: 11, column: 1]",
    "path": "/api/pood/coupon"
}
java.lang.ClassCastException: class org.springframework.security.web.servletapi.HttpServlet3RequestFactory$Servlet3SecurityContextHolderAwareRequestWrapper cannot be cast to class org.springframework.web.util.ContentCachingRequestWrapper (org.springframework.security.web.servletapi.HttpServlet3RequestFactory$Servlet3SecurityContextHolderAwareRequestWrapper and org.springframework.web.util.ContentCachingRequestWrapper are in unnamed module of loader 'app')

 


 

👏🏻 "ClassCastException" 이 터지는 원인

Json to Dto 바인딩 시, 생성자에서 Throw Exception 일 때 "ClassCastException" 이 터지는 원인

 

디버깅을 해보면 Throw IllgalException 까지는 로직이 시나리오데로 잘 탑니다.

 

하지만 Json 에서 Dto 로 아직 값이 바인딩 되지 않았기 때문에 문제가 생깁니다.

 

💡 @RequestBody Json to dto 바인딩 과정 (,,,짧게!)

우선, Spring은 Http Message Body를 읽기 위해 HttpMessageConverter를 사용합니다.

 

🔥 Spring 에서 Json 객체를 읽는데는 아래와 같은 흐름을 가집니다.

  1. 클라이언트로부터 값을 받으면 여러 Converter 중에서 해당 값을 읽을 수 있는 Converter를 찾는다.
  2. 읽을 수 있는 컨버터를 찾으면 read() 메서드를 통해 값을 읽고 원하는 Object로 변환한다.
  3. 참고로 Spring에서 JSON의 형변환은 Jackson2HttpMessageConverter가 담당한다.
  4. Jackson2HttpMessageConverter의 read() 메서드를 살펴보면 ObjectMapper를 통해 값을 변환시킨다.

대충 이런 흐름..!

 

🔥 하지만 바인딩 시 Exception 이 터지면 아래와 같은 흐름을 가집니다.

1. 익션션이 터진 후, 디버깅은 ValueInstantiator 추상클래스와 java.io.Serializable 인터페이스를 상속받고 구현하고 있는 StdValueInstantiator 클래스로 가게됩니다.

2. 그 후, Json 개체에서 Java Class 에 바인딩하기 위한 createFromObjectWith 메소드를 호출하는 것을 확인했습니다!

추상 클래스 ValueInstantiator 에 정의된 createFromObjectWith 메소드

3. 이 때 StdValueInstantiator 클래스에서 재구현 메소드에서 바인딩 시에 Exception 이 터지면 Catch 잡아서 별도의 핸들링 클래스인 handlerInstantiationProblem 메소드를 반환합니다.

 

역직렬화 실패시 호출되는 메소드

4. 디버깅을 쭉쭉쭉 타게되면 위에서 말했던 AbstractJackson2HttpMessageConverter 의 readJavaType() 메소드를 호출하게 되는데 여기서 JsonException 을 잡아버려서 최종적으로는 HttpMessageNotReableException을 떨궈버립니다.

 

이래서 Spring 에서는 Class 에 매핑이 되지도 않았기 때문에 ClassCastException이 로그에 찍힌게 아닐까... 하는 추측이 들었습니다.

(지극히 개인적인 생각이입니다. 하.. 정답 알려주실분?? ㅋㅋㅋㅋㅋㅋ)

 

 

📌 2. 문제해결

문제해결 방법은 간단했습니다. Client로 들어오는 Json 객체를 java dto Class에 먼저 바인딩시킨 후

별도의 validation 메소드를 호출하면 되는 것이져!!

public ResponseEntity<Objects> createCoupon(
        @RequestBody @Valid TestDTO request) {
        //이렇게 ㅎㅎ
        request.validate();

        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

 

 

📌 3. @Request 추상클래스로 받기

문제해결 후 확장성을 고려하여 DTO 를 추상클래스로 격상하였습니다.

이제 조금 더 자바스럽게 문제를 해결하기 위해, 추상클래스를 이용해봅시다

 

@RequestBody 로 불러오는 Json 객체를 추상클래스를 이용해서 다양한 구현 클래스로 받는 방법은 제가 생각했을 때 2가지가 있었습니다.

  • 1번) 단일 POJO java class로 값을 바인딩 한 후, 별도의 추상클래스를 두어서, 팩토리패턴으로 서비스로직에 구현체를 넘겨준다.
  • 2번) @RequestBody로 받아올 떄, 추상클래스로 두고, Json 객체 바인딩시 구현체를 선택하게 한다.

 

저는 2번 방법을 선택했습니다~

 


👏🏻 Json 추상클래스 구현체로 데이터 바인딩하기

그 방법은, @JsonSubTypes 입니다..!

매우 간단하니까 코드로 보겠습니다.

@Getter
@NoArgsConstructor
@JsonTypeInfo(use = Id.NAME, visible = true, property = "typeIdx", defaultImpl = CouponCreateRequestImpl.class)
@JsonSubTypes({
    @JsonSubTypes.Type(value = CouponCreateRequestBrandImpl.class, name = "18"),
})
public abstract class CouponCreateRequest {

    Integer typeIdx;
    String name;

	...
    
    List<String> list;


    public CouponCreateRequest(...) {

        //생략
    }

    public void validate() {
        validCodeList();
        brandIdxValidate();
    }

   abstract void brandIdxValidate();

    void validCodeList() {
        if (this.list.size() < 1) {
            throw new IllegalArgumentException("쿠폰 코드를 입력해주세요");
        }

        if (this.list.stream().anyMatch(s -> !StringUtils.hasText(s))) {
            throw new IllegalArgumentException("빈 코드값이 존재합니다.");
        }
    }
}

 

별거 없습니다. 어떤 값으로 비교를 해서, 어떤 객체를 바인딩해줄건지를 적어준 어노테이션 입니다. 짧게 보면

  • use : 어떤 속성을 이용해서 비교할거냐..!! Name 은 말그대로 입니다. property가 지정되지않으면 Class 이름으로 비교하는걸로 알고있습니다.
  • visible : false가 default 입니다. property 속성을 한번 쓰고 값을 버릴지 말지를 의미합니다. false면 값이 null 이 됩니다.
  • property : 어떤 변수를 이용할거냐, 해당 이름의 key 값으로 비교합니다.
  • defaultImpl 속성을 통해서 기본 구현체를 지정할 수 있습니다.
  • 문제 해결 후, 확장성을 고려하여 DTO 를 추상클래스로 격상 

 

(좀 피곤해서.. https://kobumddaring.tistory.com/51 이분의 글에 상당히 자세히 나와있습니다 ㅠㅠ 빨리 쓰고 퇴근할래;;)

 

 

📌 4. 추상클래스 적용 후 ControllerTest 에서 예상치 못했던 Exception 이 던져짐

 

기존의 잘되던 ControllerTest Code

  • 대충 보면 해당 상황일 때, 400에러가 터지고, 내가 위에서 던져줌 IllegalArgumentException이 터지는 확인하는 Test 코드입니다.
  • 원래는 잘 통과하던 테스트였는데 objectMapper에서 HttpMessageException이 터지는걸 확인했습니다..
@ParameterizedTest
@ValueSource(ints = {1,2,3})
@DisplayName("해당 타입의 쿠폰의 경우 쿠폰 코드가 존재하지 않음 (실패)")
void validCouponCode_fail(Integer publishTypeIdx) throws Exception {
    mockMvc.perform(
            post("/api/pood/coupon")
                .content(objectMapper.writeValueAsString(
                    CouponRequestTestFixture.Code(publishTypeIdx, List.of(" "))))
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isBadRequest())
        .andExpect(result -> assertThat(
            Objects.requireNonNull(result.getResolvedException()).getClass().getCanonicalName())
            .isEqualTo(IllegalArgumentException.class.getCanonicalName()));
}

원인은 content() 에 파라미터로 넘겨주는 객체에 있었는데

원래라면 objectMapper 를 이용해서 java To Json 으로 야무지게 직렬화하고 잘 넘겼지만

 

추상클래스를 바인딩해주니까 property 가 지맘대로 생성되서 넘어갑니다

 

 

 

이렇게..!!!

이게 @JsonSubType use = Id.NAME 인거 같다는 생각이 들었고

 

원래는 TestFisture를 사용해서 아래와 같이 객체를 넘겨줬는데 아래처럼 Map으로 Json 형식을 맞춰주어서 해결했습니다아... 끄으읕

public class CouponRequestTestFixture {

    public static CouponCreateRequest Code(final Integer typeIdx, final List<String> codes) {
        return new CouponCreateRequestImpl(typeIdx, "", "", 1, 1, AVAILABLE_TIME, 1, codes, null);
    }
    
    //이렇게 객체를 넘기는게 아닌, Json형식을 맞춰서 반환시켜줌
    public static Map<Object, Object> Code(final Integer typeIdx, final List<String> codes) {
        Map<Object, Object> json = new HashMap<>();
        json.put("publishTypeIdx",typeIdx);
        json.put("name","");
        ... 생략
        return json;
    }
}

 

 

 

 

아 너무나 삽질이다..

혹시 여기까지 보신분이 계시다면 제 삽질을 지적해주세요 ㅠ 감사합니다ㅠㅠ