우테코 레벨2 미션 방탈출예약프로그램에서 예약 도메인의 예약 상태는 아래와 같이 Enum으로 표현된다.
public enum ReservationStatus {
BOOKING,
WAITING
}
예약을 생성하는 POST API의 명세는 아래와 같다.
POST /reservations HTTP/1.1
content-type: application/json
cookie: token=eyJhbGciOiJIUzI1Ni
{
"date": "2030-4-18",
"timeId": 1,
"themeId": 1,
"status": "WAITING"
}
이때 status 값은 예약 상태를 의미한다. WAITING
이면 예약 대기, BOOKING
이면 예약 요청이다. request body에서 역직렬화 될 dto는 어떻게 만들어야 할까?
public record ReservationSaveRequest(
@NotNull(message = "예약 날짜는 비어있을 수 없습니다.")
LocalDate date,
@NotNull(message = "예약 시간 Id는 비어있을 수 없습니다.")
Long timeId,
@NotNull(message = "테마 Id는 비어있을 수 없습니다.")
Long themeId,
@NotNull(message = "예약 상태는 비어있을 수 없습니다.")
ReservationStatus status) {
public Reservation toModel(Theme theme, ReservationTime time, Member member) {
return new Reservation(member, date, time, theme, status);
}
}
jackson 라이브러리에서 문자열 "WAITNG"을 ReservationStatus.WAITNG
으로 변환해준다. 예외처리도 간단하게 아래와 같이 할 수 있다.
@Override
public ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException e, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
if (e.getCause() instanceof InvalidFormatException invalidFormatException) {
String errorMessage = getMismatchedInputExceptionMessage(invalidFormatException);
return ResponseEntity.badRequest()
.body(new ErrorResponse(errorMessage));
}
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
}
private String getMismatchedInputExceptionMessage(InvalidFormatException e) {
String type = e.getTargetType().toString();
String fieldNames = e.getPath()
.stream()
.map(Reference::getFieldName)
.collect(Collectors.joining(", "));
return fieldNames + String.format("(type: %s) 필드의 값이 잘못되었습니다.", type);
}
어떤 값이 잘못되었는지 정도는 예외 응답으로 줄 수 있다. 아래는 @MockMvc
로 테스트한 결과이다.
ConstraintValidator
를 도입한 이유결론부터 말하자면 ReservationStatus
클래스 자체를 DTO로 받지 않고 String
으로 받은 후 ConstraintValidator
로 문자열 값을 검증했다. String
이 아닌 다른 타입들(Long
등)은 jackson 역직렬화 과정에서 발생하는 HttpMessageNotReadableException
에 대해 예외 처리를 해주었지만 이번에는 다르게 처리한 이유는 ReservationStatus는 도메인 객체이기 때문이다.
다른 엔티티들(역시 도메인 객체)도 바로 dto에서 역직렬화를 시킬 수 있지만, 그렇게 하지 않은 이유를 생각해보면 위와 비슷할 것이다. 도메인 객체가 뷰에 노출되고, 서로 의존하는 관계를 갖는건 좋지 않다고 판단했다.
ConstraintValidator
로 검증해보자이미 어노테이션 몇 개를 사용하고 있어서 의존성 설정은 마친 상태였다. Jakarta bean validation에 대한 의존성을 설정해주자. 구현체는 하이버네이트다.
# build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
참고로 많이 쓰이는 @NotNull
, @NotBlank
와 같은 어노테이션들도 이 패키지에 속한다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ReservationStatusFormatValidator.class})
public @interface ReservationStatusFormat {
String message() default "유효하지 않은 예약 상태(ReservationStatus) 값입니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Constraint
을 사용한다. 위에 나와 있듯이 세 가지 속성을 반드시 포함해주어야 한다.
String message() default [...];
which should default to an error message key made of the fully-qualified class name of the constraint followed by .message. For example "{com.acme.constraints.NotSafe.message}"
Class<?>[] groups() default {};
for user to customize the targeted groups
Class<? extends Payload>[] payload() default {};
for extensibility purposes
ConstraintValidator
인터페이스를 구현한 validator를 만든다.
public class ReservationStatusFormatValidator implements ConstraintValidator<ReservationStatusFormat, String> {
@Override
public void initialize(ReservationStatusFormat constraintAnnotation) {
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if (value == null || value.isBlank()) { // (1)
return true;
}
List<String> reservationStatusNames = Arrays.stream(ReservationStatus.values())
.map(ReservationStatus::name)
.toList();
return reservationStatusNames.stream()
.anyMatch(reservationStatusName -> isConvertible(reservationStatusName, value));
}
public boolean isConvertible(String reservationStatusName, String value) {
return value.trim().equalsIgnoreCase(reservationStatusName); // (2)
}
}
(1) String
값이 Null이거나 공백이라면 @NotBlank
어노테이션으로 검증할 것이므로 검증 대상에서 제외한다.
(2) 대소문자 구분 없이 String
값이 ReservationStatus
로 변환할 수 있는지 판단한다.
String
필드 위에 어노테이션을 붙인다.
public record ReservationSaveRequest(
@NotNull(message = "예약 날짜는 비어있을 수 없습니다.")
LocalDate date,
@NotNull(message = "예약 시간 Id는 비어있을 수 없습니다.")
Long timeId,
@NotNull(message = "테마 Id는 비어있을 수 없습니다.")
Long themeId,
@NotBlank(message = "예약 상태는 비어있을 수 없습니다.")
@ReservationStatusFormat
String status) {
public Reservation toModel(Theme theme, ReservationTime time, Member member) {
String validStatusValue = status.toUpperCase().trim();
return new Reservation(member, date, time, theme, ReservationStatus.from(validStatusValue));
}
}
Dispatcher Servlet에서 @Valid
로 검증 대상을 확인하면서 argument resolve 과정을 거치기 때문에 @Valid
도 꼭 붙여준다.
// ReservationController.java
@PostMapping
public ResponseEntity<ReservationResponse> createReservation(@RequestBody @Valid ReservationSaveRequest request) {
// 생략
DispatcherServelt - Controller - Validator만 테스트하면 되므로 @MockMvc
와 @WebMVcTest
를 사용해 Web layer에서 테스트했다.
@ParameterizedTest
@ValueSource(strings = {"\"booking\"", "\" booking\"", "\"BOOKING \"", "\"BOOKINg\""})
@DisplayName("예약 POST 요청 시 예약 상태 값을 검증하여 유효한 경우 상태코드 201을 반환한다.")
void crestReservationWithReservationStatusValidation(String statusValue) throws Exception {
// given
String request = """
{
"status": "valid",
"date": "2023-03-08",
"timeId": 1,
"themeId": 1
}
""";
ReservationTime expectedTime = new ReservationTime(1L, MIA_RESERVATION_TIME);
Theme expectedTheme = WOOTECO_THEME(1L);
Reservation expectedReservation = MIA_RESERVATION(expectedTime, expectedTheme, USER_MIA(1L), BOOKING);
BDDMockito.given(bookingManageService.scheduleRecentReservation(any()))
.willReturn(expectedReservation);
BDDMockito.given(reservationTimeService.findById(anyLong()))
.willReturn(expectedTime);
BDDMockito.given(themeService.findById(anyLong()))
.willReturn(expectedTheme);
// when & then
mockMvc.perform(post("/reservations")
.cookie(COOKIE)
.contentType(MediaType.APPLICATION_JSON)
.content(request.replace("\"valid\"", statusValue)))
.andDo(print())
.andExpect(status().isCreated());
}
@Test
@DisplayName("유효하지 않은 예약 상태 값으로 예약 POST 요청 시 상태코드 400을 반환한다.")
void createReservationWithInvalidReservationStatus() throws Exception {
// given
String invalidDateFormatRequest = """
{
"status": "invalid",
"date": "2023-03-08",
"timeId": 1,
"themeId": 1
}
""";
// when & then
mockMvc.perform(post("/reservations")
.contentType(MediaType.APPLICATION_JSON)
.cookie(COOKIE)
.content(invalidDateFormatRequest))
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").exists());
}
논외지만 이렇게 web layer만 떼서 테스트할 필요가 분명히 있는 경우들을 발견할 때마다 기분이 좋아진다. ㅎㅎ 요즘 슬라이싱 테스트에 꽤 공들이고 있기 때문이다.
최근 프로그래밍하면서 매번 확인하는 규칙이 '기술을 써야 할 이유가 있으면 쓰자'이다. 스프링부트에서 이미 다 제공되는 편리한 기능이면 굳이 커스텀하지 않았는데, 이번에는 확실한 나만의 근거가 있어 도입한 기능이었다. 기술 자체를 습득하는 것보다 이런저런 고민을 하고, 사이드이펙트를 고려하는 게 재밌는 것 같다.
[1]
[2]
[3]
Spring 소스 코드