본문 바로가기
빌드노트 검색

빌드노트

Spring LocalDate 날짜 파라미터가 컨트롤러 진입 전에 400이 될 때

Spring MVC의 LocalDate 날짜 파라미터가 컨트롤러 진입 전에 400이 되는 조건을 비교한다. ISO 명시 여부와 두 Locale의 실제 결과를 바탕으로 요청 형식, 존재하지 않는 날짜, 누락을 구별한다.

GET 요청의 date=2026/10/20을 LocalDate 인자로 받다가 400과 MethodArgumentTypeMismatchException이 나오면, 실제 입력 문자열과 날짜 형식을 먼저 맞춘다. API가 2026-10-20 같은 ISO 날짜를 받기로 했다면 클라이언트가 그 형식으로 보내고, 서버 인자에도 @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)를 명시한다. 애너테이션을 붙여도 클라이언트가 보내는 슬래시가 하이픈으로 바뀌지는 않는다.

이 오류는 컨트롤러 인자를 준비하는 동안 생길 수 있다. 메서드 본문의 첫 줄에 도달하지 않으므로, 본문 안의 try/catch로 처리하려고 해도 실행되지 않는다. Spring MVC 타입 변환 문서 (새 탭에서 열림)는 문자열 요청 값을 설정된 변환기에 따라 인자 타입으로 변환한다고 설명한다.

ISO 날짜를 받는 인자 선언

다음은 @RestController 클래스 안에 넣는 메서드다. LocalDate는 java.time.LocalDate, DateTimeFormat은 org.springframework.format.annotation.DateTimeFormat을 사용한다. GetMapping과 RequestParam은 org.springframework.web.bind.annotation의 애너테이션이다.

@GetMapping("/reports")
public String reports(
        @RequestParam("date")
        @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
    return date.toString();
}

이 선언으로 별도 재현한 결과, date=2026-10-20은 LocalDate로 전달되고 응답 본문은 2026-10-20이었다. date=2026/10/20은 400이었고 메서드 호출은 0회였다. 기존 요청 계약이 슬래시 형식이라면 인자의 애너테이션을 @DateTimeFormat(pattern = "uuuu/MM/dd")로 바꾸는 방법도 있다. 같은 실험에서 이 패턴은 2026/10/20을 받았고, 존재하지 않는 2026/02/29는 거절했다.

DateTimeFormat 6.2.12 API (새 탭에서 열림)는 ISO와 사용자 지정 패턴을 제공하며, 스타일 기반 파싱은 Locale과 Java 런타임에 영향을 받을 수 있다고 설명한다. iso와 pattern 중 요청 계약에 맞는 하나를 선택한다. 화면의 표시 형식을 그대로 API의 입력 형식으로 간주하지 않는다.

애너테이션이 없는데 ISO 입력이 성공할 수도 있다

Windows의 OpenJDK 17.0.2와 Spring Framework 6.2.12에서 standalone MockMvc로 비교했다. 변환 서비스는 DefaultFormattingConversionService를 명시했고, FixedLocaleResolver를 사용해 미국과 한국 Locale을 각각 고정했다. JVM 기본 Locale이나 시스템 시간은 바꾸지 않았다.

날짜 인자에 형식 애너테이션이 없는 /plain과 ISO를 명시한 /iso에 같은 값을 보냈다. 다음은 실제 기록에서 미국 Locale의 상태·호출 횟수·반환값을 추린 것이다. calls는 컨트롤러 메서드가 실행된 횟수다.

en-US /plain date=2026-10-20  status=200 calls=1 body=2026-10-20
en-US /iso   date=2026-10-20  status=200 calls=1 body=2026-10-20
en-US /plain date=10/20/26    status=200 calls=1 body=2026-10-20
en-US /iso   date=10/20/26    status=400 calls=0 body=(empty)

한국 Locale에서도 두 선언 모두 2026-10-20을 받았다. 하지만 10/20/26은 두 선언 모두 400이었고, 한국식 짧은 날짜인 26. 10. 20.은 /plain에서만 성공했다. ISO 명시는 이 실험에서 이미 성공한 ISO 입력을 고치는 역할보다, 지역 형식에 기대지 않는 요청 형식을 드러내는 역할을 했다.

ISO 입력이 애너테이션 없이 성공한 이유도 확인했다. 6.2.12의 날짜 포매터 등록 코드 (새 탭에서 열림)는 기본 날짜에 지역별 짧은 스타일을 사용한다. 이어 같은 버전의 TemporalAccessorParser (새 탭에서 열림)는 기본 파싱이 실패했을 때, 별도 fallback 패턴이 없으면 LocalDate.parse(text)도 시도한다. 따라서 이 버전에서 “애너테이션이 없으니 ISO 날짜는 반드시 실패한다”는 설명은 관찰과 맞지 않는다.

같은 400이라도 누락과 잘못된 날짜를 구별한다

두 Locale에서 ISO 선언에 보낸 입력의 결과는 같았다.

  • 2024-02-29: 200, 호출 1회, 전달된 값은 윤년의 2월 29일이었다.
  • 2026-02-29: 400, 호출 0회, MethodArgumentTypeMismatchException이었다. 원인 체인의 마지막 메시지는 Invalid date 'February 29' as '2026' is not a leap year였다. 구분자가 맞아도 달력에 없는 날짜는 통과하지 않았다.
  • 2026/10/20: 400, 호출 0회, 같은 타입 변환 예외였지만 날짜 문자열의 형식이 맞지 않았다.
  • date 자체 누락: 400, 호출 0회, MissingServletRequestParameterException이었다. isMissingAfterConversion()은 false였다.
  • date=: 400, 호출 0회, 누락과 같은 예외였지만 isMissingAfterConversion()은 true였다. 이 구성에서 빈 문자열은 변환 후 null이 됐다.

누락을 허용할 API라면 @RequestParam(name = "date", required = false)로 선언하고 메서드에서 null의 의미를 정한다. 실험에서는 누락 시 메서드가 null을 받아 실행됐지만, 2026-02-29는 여전히 변환 단계에서 400이었다. required = false는 잘못된 날짜를 받아들이는 옵션이 아니다. 변환 뒤 누락 여부를 나타내는 플래그는 MissingServletRequestParameterException API (새 탭에서 열림)에서도 확인할 수 있다.

내 컨트롤러에서 확인할 부분

기존 테스트에 Spring의 spring-test·spring-webmvc와 Jakarta Servlet API가 준비돼 있다면, 위 메서드를 넣은 컨트롤러로 다음처럼 한 요청을 확인할 수 있다. ReportsController는 자신의 컨트롤러 이름으로 바꾼다. 아래 코드는 테스트 메서드 안에서 실행하며, MockMvcBuilders, MockMvcRequestBuilders, DefaultFormattingConversionService, FixedLocaleResolver, java.util.Locale을 사용한다.

var mvc = MockMvcBuilders.standaloneSetup(new ReportsController())
        .setConversionService(new DefaultFormattingConversionService())
        .setLocaleResolver(new FixedLocaleResolver(Locale.US))
        .build();
var result = mvc.perform(MockMvcRequestBuilders.get("/reports")
        .param("date", "2026/10/20")).andReturn();
System.out.println(result.getResponse().getStatus());
System.out.println(result.getResolvedException().getClass().getSimpleName());

위 ISO 선언을 사용한 별도 실행의 출력은 400과 MethodArgumentTypeMismatchException이었다. 값을 2026-10-20으로 바꾸면 200이었고 예외는 없었다. 성공한 경우 getResolvedException()은 null이므로 마지막 출력문은 제거하거나 null 검사를 추가한다. 이 비교에서 ISO 입력도 실패한다면 실제 요청의 파라미터 이름·값과 애플리케이션의 커스텀 Converter, Formatter, @InitBinder 설정을 확인한다. 컨트롤러를 직접 호출하는 단위 검사만으로는 이 변환 경로를 검사할 수 없다. MockMvc 설명 (새 탭에서 열림)

이번 결과는 서버를 띄우지 않은 standalone MockMvc의 관찰이다. Spring Boot 자동 설정, 실제 Servlet 컨테이너, 커스텀 예외 처리와 다른 JDK·Spring 버전까지 확인한 결과는 아니다. 또 ISO 애너테이션이 모든 허용 문자열을 정규식처럼 제한한다고 검증한 것도 아니다. 우선 API가 주고받을 날짜 형식을 정하고, 정상 날짜·형식 불일치·존재하지 않는 날짜·누락을 각각 보내 상태와 메서드 진입 여부를 확인한다.

← 전체 글 보기