🚀 Spring Request DTO 추상클래스로 받아오기 (까지의 트러블 슈팅..😂)
이번 포스팅은 스프링에서 Client 에서 받는 Json 데이터를, 조건에 따라 다른 유효성 검사를 하기위한 과정에서의 트러블 슈팅 기록입니다..
➡️ 상황
- 사내에서, 쿠폰 정책에 따라 각각의 쿠폰을 등록할 떄, 필요한 조건이 달라졌기 때문에
- 기존의 단일 유효성 검사에서, 어떤 쿠폰이냐에 따라서 Custom 한 Validation 을 적용해야하는 상황이였습니다.
[문제 직면부터 시도과정]
- @RequestBody 로 받아오는 DTO 클래스 생성자에 throw Exception ➡️ 내가 던진 Exception 이 아닌 예상치 못한
`Json Parse Error : HttpMessgeException` 이 던져지고, `ClassCastException`이 터짐.. - 문제 해결 후, 확장성을 고려하여 DTO 를 추상클래스로 격상
- 추상클래스 적용 후, 잘되던 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 객체를 읽는데는 아래와 같은 흐름을 가집니다.
- 클라이언트로부터 값을 받으면 여러 Converter 중에서 해당 값을 읽을 수 있는 Converter를 찾는다.
- 읽을 수 있는 컨버터를 찾으면 read() 메서드를 통해 값을 읽고 원하는 Object로 변환한다.
- 참고로 Spring에서 JSON의 형변환은 Jackson2HttpMessageConverter가 담당한다.
- Jackson2HttpMessageConverter의 read() 메서드를 살펴보면 ObjectMapper를 통해 값을 변환시킨다.
🔥 하지만 바인딩 시 Exception 이 터지면 아래와 같은 흐름을 가집니다.
1. 익션션이 터진 후, 디버깅은 ValueInstantiator 추상클래스와 java.io.Serializable 인터페이스를 상속받고 구현하고 있는 StdValueInstantiator 클래스로 가게됩니다.
2. 그 후, Json 개체에서 Java Class 에 바인딩하기 위한 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;
}
}
아 너무나 삽질이다..
혹시 여기까지 보신분이 계시다면 제 삽질을 지적해주세요 ㅠ 감사합니다ㅠㅠ