문제 상황
NullPointerException 발생
프론트에서 API 테스트 중에 NullPointerException이 발생한다는 상황을 전달받았습니다. 로그를 확인해보니 리펙토링을 진행하면서 추가한 Custom Validator에서 NullPointerException이 발생하고 있었습니다.
@Getter
@AgendaCapacityValid
@AgendaScheduleValid
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AgendaCreateReqDto {
// ...
@NotNull
@Future(message = "마감일은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaDeadLine;
@NotNull
@Future(message = "시작 시간은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaStartTime;
@NotNull
@Future(message = "종료 시간은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaEndTime;
// ...
}
public class AgendaScheduleValidator implements ConstraintValidator<AgendaScheduleValid, AgendaCreateReqDto> {
@Override
public void initialize(AgendaScheduleValid constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(AgendaCreateReqDto value, ConstraintValidatorContext context) {
if (Objects.isNull(value)) {
return true;
}
return mustHaveValidSchedule(value);
}
private boolean mustHaveValidSchedule(AgendaCreateReqDto value) {
return value.getAgendaDeadLine().isBefore(value.getAgendaStartTime())
&& value.getAgendaStartTime().isBefore(value.getAgendaEndTime());
}
}
AgendaCreateReqDto에서 대회 스케쥴을 위해서 deadLine, startTime, endTime을 필드로 선언했습니다. 또한 테스트에서 LocalDateTime으로 요청을 보냈을 때 데이터가 정상적으로 바인딩되었기 때문에 문제가 없다고 생각했고, 또한 @NotNull 어노테이션을 사용했기 때문에 CustomValidator에서는 null 값이 들어올 일이 없다고 생각했습니다. 문제가 되었다면 아마 @NotNull보다 @AgendaScheduleValid가 먼저 실행되었기 때문일 가능성이 높아보였습니다.
public boolean isValid(AgendaCreateReqDto value, ConstraintValidatorContext context) {
if (Objects.isNull(value)) {
return true;
}
if (Objects.isNull(value.getAgendaDeadLine())
|| Objects.isNull(value.getAgendaStartTime())
|| Objects.isNull(value.getAgendaEndTime())) {
return false;
}
return mustHaveValidSchedule(value);
}
우선 문제가 되는 부분에 Null Check를 추가해서 급하게 수정했습니다.
@NotNull
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
@Future(message = "마감일은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaDeadLine;
@NotNull
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
@Future(message = "시작 시간은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaStartTime;
@NotNull
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
@Future(message = "종료 시간은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaEndTime;
또한 @JsonFormat을 추가하여 날짜 형식을 확인하는 부분도 같이 추가했습니다.
LocalDateTime 데이터 바인딩 문제
위 코드를 추가해서 NullPointerException은 발생하지 않았지만, 여전히 데이터 바인딩이 안되고 있었습니다.
@NotNull
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Future(message = "마감일은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaDeadLine;
@NotNull
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Future(message = "시작 시간은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaStartTime;
@NotNull
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Future(message = "종료 시간은 현재 시간 이후여야 합니다.")
private LocalDateTime agendaEndTime;
어떤 이유인지 도무지 종잡을 수가 없어서 한 줄씩 디버깅해서 보니 급하게 추가한 @JsonFormat에서 pattern이 일치하지 않는 문제가 발생했었습니다. 날짜 형식에 관련해서는 프론트와 논의된 내용이 없어서 현재 프론트에서 사용하고 있는 형식으로 pattern을 수정했습니다.
이렇게 우여곡절을 지나면서, 현재 프론트에서는 날짜 형식을 "2024-09-01T04:35:06.872Z"와 같이 UTC 시간으로 보내고 있다는 것에 문제점을 느꼈습니다. 현재 백엔드에서는 LocalDateTime을 사용하고 있기 때문에 Timezone이 UTC인 시간이 들어오게 된다면 변환하는 과정 없이 사용되기 때문입니다. 대회라는 도메인 특성상 스케쥴은 중요한 정보이기 때문에 날짜 형식을 통일해야한다고 생각했습니다.
원인 분석
위 문제 상황을 거치면서 2가지 문제 상황을 분석했습니다.
- @AgendaScheduleValid가 @NotNull보다 먼저 실행된다.
- 프론트에서 Timezone이 포함된 날짜 형식을 사용한다.
이 글에서는 먼저 Custom Validator의 실행 순서에 대해 알아보겠습니다. Timezone에 대한 내용은 다음 글에서 다루겠습니다.
Custom Validation 실행 순서
먼저 CustomValidator가 @NotNull 보다 먼저 실행되는지를 테스트하기 위해서 다음과 같이 세팅했습니다.
@Data
@CustomValid
public class AgendaCreateDto {
@NotNull
private String title;
@NotNull
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm")
@Future(message = "마감일은 현재 시간 이후여야 합니다.")
private LocalDateTime schedule;
}
먼저 테스트할 DTO 입니다. title과 schedule이라는 필드를 선언했습니다.
@Slf4j
public class CustomValidator implements ConstraintValidator<CustomValid, AgendaCreateDto> {
@Override
public void initialize(CustomValid constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(AgendaCreateDto value, ConstraintValidatorContext context) {
if (Objects.isNull(value)) {
return false;
}
log.info("value: {}", value);
if (Objects.isNull(value.getSchedule())) {
throw new NullPointerException();
}
return true;
}
}
다음으로 CustomValidator입니다. 버그 환경을 재현하기 위해 schedule이 null인 경우 NullPointerException을 발생시켰습니다.
테스트 시나리오
테스트 시나리오입니다. 의도적으로 필드를 누락해서 @NotNull이 먼저 실행되면 400 Bad Rquest가 발생하고 @CustomValid가 먼저 실행되면 500 Internal Server Error가 발생할 것입니다.
결과
결과는 위와 같이 NullPointException이 발생했습니다.
정리
결론적으로 Custom Validator를 사용하면 @NotNull보다 먼저 실행된다는 것을 알게되었습니다. 필드에 대한 Validator도 추가로 테스트해 본 결과, Custom Validator가 @NotNull보다 먼저 실행되는 것을 확인했습니다. 또한 Custom Validator 간의 순서는 보장되지 않고 빌드할 때마다 랜덤으로 변경되었습니다.
글이 길어져서 LocalDateTime과 Timezone 관련 내용은 다음 글에서 이어가겠습니다.
'회고록 > Project' 카테고리의 다른 글
[42GG] LocalDateTime과 Timezone (0) | 2024.08.30 |
---|---|
[42GG] 데이터 플로우 다이어그램 작성 (0) | 2024.07.26 |
[42GG] 효율적인 TestFixture 관리 (0) | 2024.07.22 |
[42GG] UpdateDto와 TestFixture (0) | 2024.07.18 |
[42GG] JPA UPDATE 로직 (영속성 컨텍스트, @DynamicUpdate, 벌크 연산) (0) | 2024.07.17 |