에러는 여러 가지 형태로 만나게 될 수 있는데 저는 이 에러를 JUnit 테스트를 하면서 알게 되었습니다. 문자열로 날짜(LocalDate)를 입력받아 시스템에서는 날짜, 시간(LocalDateTime)으로 처리하는 코드를 작업하면서 테스트 결과를 비교하는 과정에서 변환 결과에 차이가 있어서 오류가 났습니다. 조금 검색을 해보니 Spring Boot에서 LocalDateTime을 JSON 입출력 과정에서 필요한 코드(어노테이션)가 있고, 이를 제대로 이해하고 사용하면 문제를 해결할 수 있다는 것을 알게 되었습니다.
1. 오류 발생 및 확인
// Example Class
public class User {
...
private LocalDateTime birth;
...
public User(LocalDateTime birth) {
this.birth = birth;
}
}
JUnit에서 LocalDateTime을 Jackson으로 JSON 직렬화 하려면 ObjectMapper에 JavaTimeModuel을 추가해야 합니다. 아래 @BeforeEach 섹션에 사용법이 있습니다.
// JUnit Test Example
@BeforeEach
public void setUp() {
// LocalDateTime Jackson Bind
// - SpringBoot 2.9.4 old version까지 jsr310이 의존성에 포함됨
// - 이상 버전에서는 jackson-datatype-jsr310 추가 필요
// - resiterModule 메소드로 JavaTimeModule() 추가
ObjectMapper objMapper = new ObjectMapper();
objMapper.registerModule(new JavaTimeModule());
JacksonTester.initFields(this, objMapper);
}
@Test
void getUser() {
...
LocalDateTime birth = LocalDateTime.of(2022, 12, 1, 0, 0, 0);
User user = new User(birth);
...
MockHttpServletResponse response = mvc.perform(
...
); // 실행결과로 User를 반환
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentAsString()).isEqualTo(
jsonResult.write(user).getJson());
아래 화면은 다른 class에서 발생한 것입니다만 코드 형태는 위와 같습니다.
class에서 LocalDateTime을 선언하였고, JUnit에서는 LocalDateTime.of() 메서드를 사용하여 날짜를 생성하고 클래스 변수 초기화에 사용하였습니다. 그런데 JSON 변환에서는 서로 다른 형태로 변환되어 비교되는 것을 확인할 수 있었습니다.
2. 오류의 원인 및 해결방법
Request와 Response에서 LocalDateTime을 주고받을 때 Spring Boot에서 별도의 어노테이션 없이는 직렬화에 실패하게 됩니다. 그리고 JSON 변환에서도 마찬가지입니다. 이를 지원하기 위해서 Spring에서는 @DateTimeFormat 어노테이션을, JSON에서는 @JsonFormat 어노테이션이 있습니다.
- Get요청 시에는@DateTimeFormat
- Post 요청, ResponseBody에서는 @JsonFormat
- Post 요청 시에도@DateTimeFormat이 적용될 수 있으나, @JsonFormat이 지정되어 있지 않을 때만 가능하다.
- Spring Boot 2.0에서는 JSR 310이 기본 의존성에 포함되어있다.
각 케이스에 대한 자세한 설명은 아래 블로그에서 확인할 수 있었습니다. 저는 이 포스팅을 보고 위의 문제를 해결할 수 있었습니다.
SpringBoot에서 날짜 타입 JSON 변환에 대한 오해 풀기
3. 코딩 방법 요약
3.1 Controller : RequestParam
Before
@RestController
@RequestMapping("/userinfo")
public class UserInfoController {
...
@PostMapping
ResponseEntity<UserInfo> getUserInfo(
@RequestParam String birthday,
...) {
LocalDateTime birthdt
= LocalDate.parse(birthday, DateTimeFormatter.BASIC_ISO_DATE).atStartOfDay();
...
}
}
저는 그동안에 RequestParam에서 날짜를 처리할 때 문자열로 받아 변환 처리하곤 했습니다. 그런데 @DateTimeFormat 어노테이션을 추가하여 이를 자동으로 변환하면 좀 더 깔끔하게 처리할 수 있습니다.
한 가지 예로 생일을 문자열로 입력받아서 LocalDateTime 데이터 타입으로 변환하여 DB에 저장하려고 위와 같이 코딩했다면, @DateTimeFormat 어노테이션을 사용하여 바로 LocalDateTime 타입으로 입력받을 수 있습니다. 저는 한 단계 더 거치면서 LocalDate로 입력받고, 코드에서 atStartOfDay() 메서드로 LocalDateTime 데이터 타입으로 변환해 봤습니다.
After
@RestController
@RequestMapping("/userinfo")
public class UserInfoController {
...
@PostMapping
ResponseEntity<UserInfo> getUserInfo(
@DateTimeFormat(pattern="yyyyMMdd")
@RequestParam LocalDate birthday,
...) {
LocalDateTime birthdt = birthday.atStartOfDay();
...
}
}
3.2 Domain Class
Before
public class User {
...
private LocalDateTime birthday;
...
}
위와 같이 처리할 경우 JSON 직렬화 과정에서 Format문제가 발생할 수 있습니다. 제 경우는 JUnit 테스트에서 임의로 생성한 데이터와 요청을 처리한 데이터를 JSON 변환을 통해 비교하는 경우 발생했습니다.
Spring은 JSON을 처리할 때 Jackson을 기본 컨버터로 사용합니다. 직렬화 시 @JsonFormat 어노테이션을 통해서 데이터를 일관성 있게 변환하도록 지정할 수 있습니다. 만약 직렬화가 사용되지 않는 일반적인 호출 과정에서는 @JsonFormat이 동작하지 않으므로 Spring의 어노테이션인 @DateTimeFormat을 RequestParam이나 ModelAttribute에 명시해야 합니다.
좀 더 상세한 설명은 위에 추가한 포스팅 링크에 있으니 이해가 부족하다면 위 포스팅을 읽어보시길 바랍니다.
After
public class User {
...
@JsonFormat(shape = JsonFormat.Shape.STRING,
pattern = "yyyyMMddHHmmss",
timezone = "Asia/Seoul")
private LocalDateTime birthday;
...
}
4. 어노테이션 처리 후 JUnit 실행 결과
위의 어노테이션을 Class와 Controller에 각각 추가하고 동일한 JUnit을 실행한 결과입니다. Request의 데이터 형태와, Response의 데이터 형태가 같은 것을 확인할 수 있었습니다.
참고
SpringBoot에서 날짜 타입 JSON 변환에 대한 오해 풀기
'Dev. Cookbook > Spring, Spring Boot' 카테고리의 다른 글
[JUnit] MockitoAnnotations.initMocks is Deprecated (0) | 2022.12.13 |
---|---|
[Thymeleaf] 단순 텍스트 출력 - text, utext (0) | 2022.11.15 |
[Windows] Windows 10에서 Symbolic Link 만들기 (0) | 2022.10.10 |
댓글