- 사용자가 전달하는 데이터에 대한 검증을 진행해야 하는데, 따로 설정을 해주지 않는다면 NullPointerException 예외가 발생하여 서버는 500에러를 반환해줄 것이다.
- 사용자가 올바르지 않은 데이터를 통하여 요청한 것이기에 400에러를 보내야 하지만,
이를 구현하기 위해 하드 코딩이나 try-catch 구문을 사용하게 되면 유지보수와 가독성이 떨어지는 문제가 발생하였다.
- 이에 스프링 프레임워크에서 JSR-303이나 Validator 구현 클래스를 통하여 데이터를 검증 할 수 있다.
JSR-303을 사용한 데이터 검증
- Hibernate-validator 라이브러리를 사용하여 객체의 속성을 검증한다.
- 여러가지 검증 애너테이션을 사용할 수 있다.
1. @NotNull : not null을 검사한다.
2. @Pattern : 정규 패턴과 매칭되는지 검사한다.
3. @Past : 과거의 날짜인지 검증한다.
4. @Size : 배열, 맵, 컬렉션인 객체의 크기를 검증한다.
5. @Min : 대상값이 min값 보다 크거나 같은지 검증한다.
6. @Max : 대상값이 max값 보다 작거나 같은지 검증한다.
7. @NotEmpty : 대상 값의 크기가 0보다 큰지 검증한다.
@Data
public class HotelRoomUpdateRequest {
@NotNull(message = "roomType can't be null")
private HotelRoomType roomType;
@NotNull(message = "originalPrice can't be null")
@Min(value = 0, message = "originalPrice must be larger than 0")
private BigDecimal originalPrice;
}
- @Valid 애너테이션은 검증할 자바 빈 객체를 마킹하는 용도로 사용한다.
@PutMapping("/hotels/{hotelId}/rooms/{roomNumber}")
public ResponseEntity<HotelRoomIdResponse> updateHotelRoomByroomNumber(@PathVariable Long hotelId, @PathVariable String roomNumber,
@Valid @RequestBody HotelRoomUpdateRequest hotelRoomUpdateRequest,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
FieldError fieldError = bindingResult.getFieldError();
String errorMessage = new StringBuilder("validation error.")
.append(" field : ").append(fieldError.getField())
.append(", code : ").append(fieldError.getCode())
.append(", message : ").append(fieldError.getDefaultMessage())
.toString();
log.warn(errorMessage);
return ResponseEntity.badRequest().build();
}
log.info(hotelRoomUpdateRequest.toString());
System.out.println(hotelRoomUpdateRequest.toString());
HotelRoomIdResponse body = HotelRoomIdResponse.from(1_002_003_004L);
return ResponseEntity.ok(body);
}
- @Valid로 지정한 RequestBody의 HotepRoomUpdateRequest 객체에서 이전에 설정한 검증 애너테이션의 정보를 토대로 검증을 진행한다.
- BindingResult 인수는 @Valid 객체의 검즘 결과를 포함한다.
- bindingReuslt.hasErrors() 메서드를 통해 검증 결과 중 틀린 것이 하나라도 있다면 에러 처리 로직으로 들어가도록 설정하고, 그 이외에는 정상적인 로직을 통해 응답하도록 설정한다.
Validator 인터페이스를 사용한 검증
- JSR-303에서 제공하는 애너테이션은 하나의 속성에 여러가지 애너테이션을 조합하여 검증 할 수 있지만, 여러 속성을 조합하여 검증 할 수는 없다.
- 예를들어, 호텔 예약 일정 검색 시 CheckInDate와 CheckOutDate를 받아온다고 했을 때,
CheckOutDate는 CheckInDate 보다 앞설 수 없다는 조건을 달고 싶지만 기존 애너테이션으로는 검증 할 수 없다. 그렇기에 Validator 인터페이스를 구현하여 이를 검증한다.
public class HotelRoomReserveValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return HotelRoomReserveRequest.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
HotelRoomReserveRequest request = HotelRoomReserveRequest.class.cast(target);
if(Objects.isNull(request.getCheckInDate())) {
errors.rejectValue("checkInDate", "NotNull", "checkInDate is Null");
return;
}
if(Objects.isNull(request.getCheckOutDate())) {
errors.rejectValue("checkOutDate", "NotNull", "checkOutDate is Null");
return;
}
if(request.getCheckInDate().compareTo(request.getCheckOutDate()) >= 0) {
errors.rejectValue("checkOutDate", "Constraint Error", "checkOutDate is earlier than checkInDate");
return;
}
}
}
- Validator 인터페이스를 구현한 HotelRoomReserveValidator 객체를 생성한다.
- supports 메서드는 인자로 받는 객체가 검증할 객체와 같은 타입인지 검증한다.
- cast 메서드를 통해 target 인자를 HotelRoomReserveRequest 객체로 캐스팅한다.
- 그 후, 사용자 정의에 따른 error 조건과 그에 다른 rejectValue들을 설정하여 준다.
@RestController
@Slf4j
public class HotelRoomReserveController {
@InitBinder
void initBinder(WebDataBinder binder) {
binder.addValidators(new HotelRoomReserveValidator());
}
@PostMapping("/hotels/{hotelId}/rooms/{roomNumber}/reserve")
public ResponseEntity<HotelRoomIdResponse> reserveHotelRoomByRoomNumber(@PathVariable Long hotelId, @PathVariable String roomNumber,
@Valid @RequestBody HotelRoomReserveRequest hotelRoomReserveRequest,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
FieldError fieldError = bindingResult.getFieldError();
String errorMessage = new StringBuilder("validation error.")
.append(" field : ").append(fieldError.getField())
.append(", code : ").append(fieldError.getCode())
.append(", message : ").append(fieldError.getDefaultMessage())
.toString();
log.warn(errorMessage);
return ResponseEntity.badRequest().build();
}
// Reserve Service 로직
Long reservationId = IdGenerator.create();
HotelRoomIdResponse body = HotelRoomIdResponse.from(reservationId);
return ResponseEntity.ok(body);
}
}
- @InitBinder의 addValidators 메서드를 통해 내가 구현한 HotelRoomReserveValidator를 등록한다.
- @Valid로 지정한 RequestBody의 HotelRoomReserveRequest 객체에서 등록한 Validator 인터페이스를 통하여 검증한다.
- 그 이후의 로직은 기존 JSR-303 에러처리 로직과 같다.
ExceptionHandler 예외 처리
- java에서 제공하는 예외는 java.lang.Exception 클래스를 상속받는 예외이고, 또 하나는 java.lang.RuntimeException을 상속받는 예외이다.
- Exception 클래스를 상속 받는 예외는, 처리하지 않으면 컴파일 에러가 발생하지만 RuntimeException 클래스를 상속 받는 예외들은 어플리케이션 실행 중 발생하는 예외들이다.
- 스프링에서 상속받는 대부분의 Exception들은 RuntimeException 클래스를 상속받는다.
- 이 책에서 다루는 런타임에러에 관한 처리로직은 다음과 같다.
[Http Client의 요청] -> [Dispatcher Servlet의 컨트롤러 매핑] => [Controller에서 서비스 로직 호출] -> [Service 로직 처리 중 예외 발생] -> [사용자가 정의한 ApiExceptionHandler에서 예외 처리] -> [예외처리 결과를 Dispatcher Servlet에 전달] -> [Dispatcher Servlet은 처리 결과를 Client에게 응답]
@RestControllerAdvice
@Slf4j
public class ApiExceptionHandler {
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorResponse> handlerBadRequestException(BadRequestException ex) {
log.error("Error message : {}", ex.getErrorMessage());
return new ResponseEntity<>(new ErrorResponse(ex.getErrorMessage()), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
log.error("Error message : {}", ex.getMessage());
return new ResponseEntity<>(new ErrorResponse("System Error"), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Data
public class BadRequestException extends RuntimeException{
private String errorMessage;
public BadRequestException(String errorMessage) {
super();
this.errorMessage = errorMessage;
}
}
@Service
@Slf4j
public class ReserveService {
public Long reserveHotlRoom(Long hotelId, String roomNumber, LocalDate checkInDate, LocalDate checkOutDate) {
// hotelRoomRepository.findByHotelIdAndRoomNumber(hotelId, roomNumber)
// .orElseThrow(() -> {
// log.error("Invalid roomNumber. hotelId: {}, roomNumber: {}", hotelId, roomNumber);
// return new BadRequestException("Not existing roomNumber");
// });
return IdGenerator.create();
}
}
- 위와 같이 Service 로직을 처리할 때 생기는 RuntimeException에 대하여 사용자가 생성한 BadRequestException을 통하여 처리하도록 지정하였다.
- BadRequestException은 java.lang의 RuntimeException을 상속한다.
- @RestControllerAdvice 애너테이션을 통하여 스프링 애플리케이션에서 발생하는 전체 예외 처리 메서드를 선언한다.
- @ExceptionHanlder를 통해 사용자가 구현한 Exception 객체를 등록하고, 이에 해당하는 응답객체를 DispatcherServlet에 응답할 수 있도록 설정해 준다.
미디어 컨텐츠 처리하기
- 애플리케이션 서버에서 이미지 등 미디어를 처리하기 위해서는 HttpMessageConverter을 사용하여 메세지를 변환하거나, 서블릿 객체인 javax.servlet.http.HttpServletResponse를 사용하여 직접 OutputStream을 다루는 방법이 있다.
1. HttpMessageConverter 사용
@GetMapping("/hotels/{hotelId}/rooms/{roomNumber}/reservations/{reservationId}")
public ResponseEntity<byte[]> getInvoice(@PathVariable Long hotelId, @PathVariable String roomNumber, @PathVariable Long reservationId) throws IOException {
String filePath = "pdf/hotel_invoice.pdf";
try(InputStream inputStream = new ClassPathResource(filePath).getInputStream()) {
byte[] bytes = StreamUtils.copyToByteArray(inputStream);
return new ResponseEntity<>(bytes, HttpStatus.OK);
} catch (Throwable th) {
th.printStackTrace();
throw new FileDownloadException("file download error");
}
}
- filePath는 resource 폴더에 저장된 pdf 파일의 경로이다.
- pdf 파일을 byte 배열로 변경 후, ResponseEntity에 byte 배열을 담아 리턴한다.
2. 서블릿 객체 HttpServletResponse 사용
@GetMapping(value = "/hotels/{hotelId}/rooms/{roomNumber}/reservations/{reservationId}", produces = "application/pdf")
public void downloadInvoice(@PathVariable Long hotelId, @PathVariable String roomNumber, @PathVariable Long reservationId, HttpServletResponse response) {
String filePath = "pdf/hotel_invoice.pdf";
try(InputStream is = new ClassPathResource(filePath).getInputStream(); OutputStream os = response.getOutputStream();) {
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_PDF_VALUE);
response.setHeader("Content-Disposition", "finename=hotel_invoice.pdf");
StreamUtils.copy(is, os);
} catch (Throwable th) {
th.printStackTrace();
throw new FileDownloadException("file download error");
}
}
- @GetMapping 메서드 인자로 HttpServletResponse 인자를 선언하면, 런타임 과정에서 생성된 HttpServletResponse 객체를 주입한다.
- 이 객체를 이용하여, HttpSatus의 상태코드와 헤더를 설정 할 수 있다.
- response.getOutputStream()을 통해 pdf의 byte 형식으로 쓸 수 있다.
'공부 > Spring Boot' 카테고리의 다른 글
[스프링 부트 공부 일지] 4. 웹 어플리케이션 서버 구축하기 (2) (0) | 2022.12.22 |
---|---|
[스프링 부트 공부 일지] 4. 웹 어플리케이션 서버 구축하기 (1) (0) | 2022.12.21 |
[스프링 부트 공부 일지] 3. 스프링에서 REST API 개발하기 (2) (0) | 2022.12.16 |
[스프링 부트 공부 일지] 3. 스프링에서 REST API 개발하기 (1) (0) | 2022.12.16 |
[스프링 부트 공부 일지] 2. 스프링 웹 MVC (0) | 2022.12.15 |