@DateTimeFormat 으로 날짜 형식 받을 때 바인딩되지 않는 이유(feat. @ModelAttribute)

김유정·2023년 6월 3일
2

글을 쓰게 된 이유

MultiPartFile 형식을 받을 일이 있어서 @ModelAttribute로 DTO 객체를 받으려고 했습니다. 그 중 데이터 타입이 LocalDateTime 인 필드가 있어서 @DateTimeFormat으로 날짜으로 변환을 시도했습니다. 하지만 생성자가 있음에도 불구하고 제대로 바인딩되지 않고 null로 할당됐습니다.

결론부터 말하자면 @DateTimeFormat의 pattern으로 날짜를 받을 때 잘못 작성해서 발생한 에러였습니다. 꽤 오랜 시간 삽질을 했는데, 그 과정 속에서 알게 된 중요한 내용 2가지는 다음과 같습니다.

  1. @ModelAttribute 어노테이션으로 DTO를 바인딩하려고 할 때, DTO에 생성자가 2개 이상 존재하면 기본 생성자만 실행된다.
  2. @DateTimeFormat의 pattern을 통해 요청한 날짜 타입으로 변경이 불가할 경우 400 Bad Request 에러가 발생한다.

문제 상황

쿠폰 등록 API를 개발 중이었고, 등록을 위해서는 클라이언트로부터 쿠폰 유효기간의 시작 날짜와 종료 날짜를 받아야했습니다. 이 때 불필요한 코드를 줄이고 편리하게 받기 위해서 String으로 받고 이후 타입 변환을 하는 대신 DTO에서 바로 바인딩 하는 방법을 선택했습니다. 하지만, 제 예상과 달리 바인딩이 제대로 되지 않았습니다.

DTO

우선 문제된 상황에서 DTO는 다음과 같았습니다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CouponSaveRequestDto {

    ...

    @DateTimeFormat(pattern = "yyyy-mm-dd'T'HH:mm:ss")
    private LocalDateTime expirationStartDate;

    @DateTimeFormat(pattern = "yyyy-mm-dd'T'HH:mm:ss")
    private LocalDateTime expirationEndDate;
    
    ...
    
}

Controller

그리고 해당 클래스에서는 MultiPartFile 타입의 필드가 존재했기 때문에, 아래처럼 @ModelAttribute 어노테이션을 통해 DTO를 받고자 했습니다.

@PostMapping(value = "", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
    public ResponseEntity<List<String>> registerCoupon(@ModelAttribute CouponSaveRequestDto requestDto) {
        return ResponseEntity.status(201).body(couponService.registerCoupon(requestDto));
    }

결과

DTO의 모든 필드가 null로 할당되어 service 계층에서 NullPointerException이 발생하였습니다.

해결

1. 기본 생성자 제거

@ModelAtrribute를 통해 값이 적절하게 바인딩될 수 있도록 아래의 방법 중 하나를 선택하여 수정해야했습니다.

1) 기본 생성자와 Setter를 생성한다.
2) 모든 필드를 파라미터로 받는 생성자만 생성한다.

그래서 고민을 했는데, 아래와 같은 이유로 2번 방법을 선택했습니다.
1) Setter를 사용하는 경우 값을 할당하려는 의도를 알기 어려움.
2) Setter는 모든 곳에서 접근이 가능하여 일관성을 유지하기 어려움
3) 클라이언트로부터 쿠폰 정보를 받기 위해서만 사용할 것이기 때문에, 값을 받은 이후 DTO를 수정할 일이 없음.

2. @DateTimeFormat 의 pattern 올바르게 수정하기

분(minute)을 표현하는 m과 월(month)을 표현하는 M을 헷갈려서 잘못 사용하여 에러가 났기 때문에, 이를 올바르게 수정했습니다.

  • 수정 전
    @DateTimeFormat(pattern = "yyyy-mm-dd'T'HH:mm:ss")
  • 수정 후
    @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")

문제 해결 후 최종 DTO 클래스 코드는 다음과 같습니다.

@Getter
@AllArgsConstructor
public class CouponSaveRequestDto {

	...

    @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    private LocalDateTime expirationStartDate;

    @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    private LocalDateTime expirationEndDate;

    ...

}

문제의 원인은 여러가지가 있지만, 그 중 삽질의 주요 범인의 "@ModelAttribute에 대한 낮은 이해도"라는 생각이 들었습니다. 그래서 삽질 중 깨닫게 된 점을 정리하고자 합니다.
위에서는 테스트 코드를 언급하지 않았지만, 저는 컨트롤러에서 요청을 받았을 때 제대로 바인딩이 되는지를 테스트하기 위해 컨트롤러 유닛 테스트를 생성했습니다.

삽질 중 깨닫게 된 점

1. DTO에 생성자가 2개 이상 존재할 때 처리할 생성자를 선택하는 과정

정확하게 말하면 ModelAttributeMethodProcessor@ModelAttribute를 처리할 때는 기본 생성자가 존재한다면 기본 생성자를 선택하여 처리하게 됩니다. 기본 생성자가 없다면, 아래와 같은 에러가 발생할 것입니다.

java.lang.IllegalStateException: No primary or single unique constructor found for

이유를 자세하게 살펴볼까요? 우선 요청을 처리하는 ModelAttributeMethodProcessor 부터 살펴보겠습니다.

ModelAttributeMethodProcessor는 HandlerMethodArgumentResolver를 구현한 클래스로 supportsParameter()resolveArgument() 메서드를 가집니다

전달된 파라미터가 ModelAttribute 어노테이션을 가진다면, resolveArgument() 메서드가 실행됩니다.

resolveArgument() 실행 과정을 아래처럼 4 단계로 정리해봤습니다. (물론 조건에 따라 실행 흐름이 달라질수 있습니다.)

  1. attribute 객체 생성
  2. 바인딩
  3. 검증
  4. 바인딩 결과를 mavContainer에 추가한 후 결과 반환

여기서 attribute란 바인딩을 하고자하는 DTO 객체를 의미합니다.

ModelAttributeMethodProcessor 는 다음과 같이 정의되어 있습니다.

public class ModelAttributeMethodProcessor implements HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler {

	...

	@Override
	public boolean supportsParameter(MethodParameter parameter) {
		return (parameter.hasParameterAnnotation(ModelAttribute.class) ||
				(this.annotationNotRequired && !BeanUtils.isSimpleProperty(parameter.getParameterType())));
	}

	@Override
	@Nullable
	public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

		...

		if (mavContainer.containsAttribute(name)) {...}
		else {
			// 1. attribute 객체 생성
			try {
				attribute = createAttribute(name, parameter, binderFactory, webRequest);
			}
			catch (BindException ex) {...}
		}

		if (bindingResult == null) {
			WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
			if (binder.getTarget() != null) {
                // 2. 바인딩
				if (!mavContainer.isBindingDisabled(name)) {
					bindRequestParameters(binder, webRequest);
				}
                // 3. 검증
				validateIfApplicable(binder, parameter);
				if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
					throw new BindException(binder.getBindingResult());
				}
			}
			if (!parameter.getParameterType().isInstance(attribute)) {...}
			bindingResult = binder.getBindingResult();
		}

		// 4. 바인딩 결과를 mavContainer에 담아서 결과 반환
		Map<String, Object> bindingResultModel = bindingResult.getModel();
		mavContainer.removeAttributes(bindingResultModel);
		mavContainer.addAllAttributes(bindingResultModel);

		return attribute;
	}
    
    ...
    
}

그렇다면 생성자는 어떤 기준으로 선택되는 것일까요?

생성자는 resolveArgument()에서 attribute 객체를 생성할 때 선택됩니다. 객체 생성을 위해서는 생성자가 필요하기 때문이죠. 이를 수행하는 createAttribute() 메서드를 살펴보면 BeanUtils.getResolvableConstructor(clazz) 를 통해 사용할 생성자를 가져오고 constructAttribute를 통해 attribute 객체를 생성합니다.

  • ModelAttributeMethodProcessor.createAttribute()
protected Object createAttribute(String attributeName, MethodParameter parameter,
			WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {

		MethodParameter nestedParameter = parameter.nestedIfOptional();
		Class<?> clazz = nestedParameter.getNestedParameterType();

        // attribute 객체를 생성할 생성자 조회
		Constructor<?> ctor = BeanUtils.getResolvableConstructor(clazz);
        // 조회한 생성자를 통해 attribute 객체 생성
		Object attribute = constructAttribute(ctor, attributeName, parameter, binderFactory, webRequest);
		if (parameter != nestedParameter) {...}
		return attribute;
	}

그럼 BeanUtils.getResolvableConstructor(clazz) 에서 생성자 선택 기준을 알 수 있을 듯 합니다!

  • BeanUtils.getResolvableConstructor()
public static <T> Constructor<T> getResolvableConstructor(Class<T> clazz) {

		...
        
		// 1. public으로 선언된 생성자 모두 가져오기
		Constructor<?>[] ctors = clazz.getConstructors();
		if (ctors.length == 1) {
			// public으로 선언한 생성자가 1개라면 바로 반환
			return (Constructor<T>) ctors[0];
		}
		else if (ctors.length == 0){
            // 2. 접근 제어자와 상관없이 모든 생성자 가져오기
			ctors = clazz.getDeclaredConstructors();
			if (ctors.length == 1) {
				// public이 아닌 생성자가 1개라면 바로 반환
				return (Constructor<T>) ctors[0];
			}
		}

        // 3. 여러 개의 생성자가 있다면 -> 기본 생성자 선택
		try {
			return clazz.getDeclaredConstructor();
		}
        // 기본 생성자가 없다면, 해당 에러 발생하지만 수행문이 없으므로 아래의 IllegalStateException 예외 발생
		catch (NoSuchMethodException ex) {
			// Giving up...
		}

		throw new IllegalStateException("No primary or single unique constructor found for " + clazz);
	}

결과적으로 생성자가 선택되는 과정을 3가지 경우로 나눠 설명해보겠습니다.
1. 생성자가 1개만 존재한다면, 해당 생성자를 반환합니다.
따라서 모든 필드를 파라미터로 받는 생성자(@AllArgsConstructor)만 존재한다면 setter가 존재하지 않더라도 정상적으로 바인딩될 것입니다.

2. 여러 생성자가 존재한다면, 기본 생성자를 반환합니다.
clazz.getDeclaredConstructor() 를 통해 기본 생성자를 가져옵니다.

3. 여러 생성자가 있지만, 그 중 기본 생성자가 없다면 예외가 발생합니다.
NoSuchMethodException 이 발생합니다. 그런데, 해당 예외를 잡는 catch 문에 실행 코드가 없어서 결국 위에서 언급했던 IllegalStateException 가 발생하게 되는 것입니다.

이제 생성자 선택에 대한 기준을 모두 파헤쳤습니다👏 속이 시원하네요.

따라서 ModelAtrribute를 통해 DTO를 처리할 때 값이 적절하게 바인딩되려면 아래의 방법 중 하나를 선택해야하는 것입니다.
1) 기본 생성자와 Setter를 생성한다.
2) 모든 필드를 파라미터로 받는 생성자만 생성한다.

2. 잘못된 코드임에도 컨트롤러 유닛 테스트가 성공하게 된 이유

저는 바인딩이 잘 되는지 테스트하기 위해 테스트 코드를 다음과 같이 작성했습니다.

@ExtendWith(SpringExtension.class)
@WebMvcTest
public class CouponControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private CouponService couponService;

    @Test
    public void registerCouponTest() throws Exception {
        // given
        given(couponService.registerCoupon(any(CouponSaveRequestDto.class))).willReturn(new ArrayList<>());
        String fileName = "일괄등록_쿠폰_1000개.csv";
        String path = "src/test/resources/" + fileName;
        MockMultipartFile multipartFile = new MockMultipartFile(fileName, new FileInputStream(path));

        // when
        ResultActions resultActions = mockMvc.perform(multipart("/coupon")
                .file(multipartFile)
                .param("discount", "50")
                .param("scope", "ALL")
                .param("description", "전품목 50% 할인 쿠폰")
                .param("expirationStartDate", "2023-05-27T00:00:00")
                .param("expirationEndDate", "2023-06-27T00:00:00")
        );

        // then
        resultActions.andExpect(status().isCreated());
    }

}

잘못된 테스트 결과와 그 이유

잘못된 코드임에도 불구하고 통과해버렸습니다. 분명 Postman으로 테스트할 때는 에러가 나는데 말이죠. 그래서 더 헤맸습니다...

저는 위에서 설명한 경우 중 2번 경우로 기본 생성자를 포함하여 생성자가 2개 이상이었습니다. 그래서 기본 생성자가 선택되어 모든 필드가 null인 DTO 객체가 만들어졌고, 그 과정 속에서 에러가 발생하지 않았기 때문에 컨트롤러 유닛 테스트에서 통과하게 된 것입니다.

검증의 중요성

만약 null이 들어올 수 없도록 검증을 했었다면, ModelAttributeMethodProcessorresolverArgument() 메서드를 처리하는 과정에서 BindingException이 발생했을 것입니다. 그렇다면 위의 예시처럼 잘못된 경우가 테스트를 통과하는 일은 없었겠죠.

결국 요약하자면, @ModelAttribute가 처리되는 과정에 대해 이해가 부족하여 생성자나 setter를 올바른 조합으로 구성하지 못하였고, 검증까지 하지 않아서 잘못된 코드임에도 테스트가 성공해버린 것입니다.

테스트 코드에 대한 고민

결국 검증 로직을 추가하는 건 DTO에 해야하는 일이다. @Valid를 붙이지 않았더라도 실수로 setter를 붙이지 않았거나 잘못된 사용으로 인해 모두 null값으로 바인딩 된다면 실패해야하는 게 맞을 것이다. 하지만, 이를 위해서 어떻게 코드를 짜는 게 좋은건지는 잘 모르겠다.

컨트롤러에서는 대부분 service의 코드를 실행한 결과가 반환된다. 하지만, 컨트롤러 유닛 테스트로 독립적으로 실행시켜보기 위해서는 가짜 Service 객체를 생성하고 임의의 데이터를 반환하게 해야한다. 그렇다면 원하는 값으로 바인딩이 되지 않더라도 바인딩 과정에서 예외가 발생하지 않으면 given().willReturn()로 미리 정의해놓은 값을 반환할 것이고 테스트가 통과되어버린다.

컨트롤러 유닛 테스트 코드에서 내가 원하는대로 바인딩이 잘 되는지 테스트하려면 어떻게 해야할까? 지금으로써는 컨트롤러에서 받은 요청 DTO를 return하고 테스트에서는 mockmvc 수행 결과를 ResultActions의 andReturn()으로 받아서 확인하는 방법밖에 떠오르지 않는다. 더 좋은 방법이 있는지는 좀 더 공부를 해봐야할 것 같다.

참고

0개의 댓글