클라이언트 요청부터 서버의 응답을 받기까지의 과정을 정리하면 아래와 같습니다.
번호 | 이름 | 설명 |
---|---|---|
1 | 프론트 → 백엔드 호출 | RESTful로 프론트에서 백엔드를 호출합니다. 이때 json으로 data를 넘깁니다. |
2 | JSON → DTO | data를 객체에 옮겨 담으면서 유효성(길이, 형식 등)을 체크합니다. |
3 | 컨트롤러 요청 처리 | 요청에 따라 컨트롤러에서 어떤 동작을 할지 결정합니다. |
4 | 서비스 로직 처리 | 동작에 따른 서버단의 로직을 처리합니다. |
5 | DB 정보 요청 | DB에 접근해야할 경우 Repository를 통해 결과를 받습니다. |
6 | DB 접근 | DB에서 CRUD의 결과값을 넘겨줍니다. |
7 | 요청값 반환 | 리턴한 값을 JSON 형태로 만들어 리턴합니다. |
8 | 예외 리턴 | 사용자의 실수, 서버의 오류등으로 인한 요청이 실패한 경우 에러를 리턴합니다. |
이전 포스팅에서 프론트에서 백엔드로 요청하면서 json data를 넘겨주는 과정을 살펴보았습니다.
요청 본문(body)에 들어있는 json 데이터를 HttpMessageConverter
를 통해 변환한 Java Object로 받아올 수 있었는데요
이 때 @Valid 어노테이션을 사용해서 값을 검증할 수 있습니다.
+ json 데이터 뿐 아니라 파라미터 검증도 가능합니다.
물론 Validator
를 사용하지않고 직접 Exception을 발생하여 유효성을 체크할 수 있습니다.
그러나 이 경우 한 필드에 대해서만 Exception이 발생하기 때문에 사용자 경험이 떨어질 수 있습니다.
예를들어 이름
, 생년월일
, 전화번호
모든 데이터에 대해 유효하지 않은 데이터라고 가정해봅시다.
사용자는 한번에 이름
, 생년월일
, 전화번호
에 대한 유효성 검사 결과를 확인하고 싶지만 이름
에 대한 Exception이 발생 이후 생년월일
에 대한 유효성을 확인할 수 있고, 생년월일
에 대한 Exception이 발생 이후에야 전화번호
에 대한 유효성을 확인할 수 있습니다.
compile group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.4.2'
먼저 build.gradle 파일에 위와 같은 의존성을 추가합니다.
해당 라이브러리는 hibernate-validator:6.1.7.Final 라이브러리를 의존하고 있습니다.
Hibernate Validator는 어노테이션 기반으로 제약사항을 표현하고 입증할 수 있으며 애플리케이션 레이어에 구애받지 않는 검증 처리를 진행할 수 있습니다.
Hibernate Validator 6.x는 Bean Validation 2.0을 구현한다고 설명되어있습니다.1
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
private Long id;
@NotBlank(message = "이메일은 빈 값일 수 없습니다")
@Email(message = "올바른 형식의 이메일 주소어야 합니다")
private String email;
@NotBlank(message = "비밀번호는 빈 값일 수 없습니다")
private String password;
@NotBlank(message = "이름은 빈 값일 수 없습니다")
private String name;
}
Validation 어노테이션을 이용하면 간단히 검증 조건을 추가할 수 있습니다.
먼저 Member 클래스에 제약조건을 추가하였습니다. 📌
javax.validation.constraints
패키지에 더 많은 유효성 검증 애노테이션이 존재하며
https://www.baeldung.com/javax-validation 에서 애노테이션에 대한 간략한 설명을 확인하실 수 있습니다.
헷갈리는 애노테이션에 대해서만 정리하도록 하겠습니다.
@NotNull : null만 허용하지 않으며 "" or " "는 허용한다.
@NotEmpty : null과 ""을 허용하지 않으며 " "는 허용한다.
@NotBlank는 null과 "", " " 모두 허용하지 않는다.
@PostMapping("/members")
public ResponseEntity join(@Valid @RequestBody Member member, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(bindingResult.getAllErrors());
}
memberService.join(member);
return ResponseEntity.ok(member);
}
위와 같이 컨트롤러에서 @Valid 어노테이션과 BindingResult 아규먼트를 사용해 위와 같이 컨트롤러에서 직접 에러를 처리해줄 수 있습니다.
유효성 검증에 실패하는 경우 에러 정보가 BindingResult 변수에 담겨집니다.
{
"email" : "akem",
"password":"",
"name":"mm"
}
여기까지 진행했다면 위와 같은 json 데이터를 넘겨받는 경우 바인딩시 유효성 체크에 걸려
MethodArgumentNotValidException 이 발생하고 400 Bad Request 응답을 받게됩니다.
컨트롤러에서 직접 에러를 처리할 수도 있지만 스프링에서 @ControllerAdvice와 @ExceptionHandler 어노테이션을 이용하여 컨트롤러의 메서드 수행 중 발생하는 예외를 일괄적으로 처리할 수 있습니다.
@ControllerAdvice
public class GlobalExceptionController {
@ExceptionHandler(MethodArgumentNotValidException.class)
public void handleValidationExceptions(BindingResult bindingResult) {
}
}
컨트롤러에서 예외가 발생하는 순간 @ExceptionHandler 어노테이션으로 지정한 메서드가 수행됩니다. 이때, 어떤 예외가 발생했느냐에 따라 다른 메서드가 수행됩니다.
위 코드에서는 MethodArgumentNotValidException
예외 발생시 handleValidationExceptions 메서드가 호출됩니다.
BindingResult 클래스의 getAllErrors() 메서드 호출시 List<ObjectError>
를 반환합니다.
유효성에 걸린 모든 필드에 대한 에러를 갖고 있습니다.
@ControllerAdvice
public class GlobalExceptionController {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(BindingResult bindingResult) {
Map<String, String> errors = new HashMap<>();
bindingResult.getAllErrors().forEach(c -> errors.put(((FieldError)c).getField() , c.getDefaultMessage()));
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
}
각 ObjectError
객체가 갖고있는 field, defaultMessage를 추출하여 Map에 담아 클라이언트에 반환합니다.
{
"password": "비밀번호는 빈 값일 수 없습니다",
"email": "올바른 형식의 이메일 주소어야 합니다"
}
이렇게 클라이언트는 400 상태 코드, Bad Request 상태 문구, json 메시지를 받을 수 있습니다. 👏
handleValidationExceptions 메서드에서 리턴 타입으로 Map<String, String>을 사용하는데
Map 타입의 경우 타입이 명료하지 않아 return 받았을 때 어떤 키에 어떤 값을 갖는지 확인하기 어렵습니다.
즉 한번에 바로 알아볼 수 있는 '명확성'이 필요합니다.
Map 대신 DTO를 사용하여 API 서버 공통 응답 포맷을 만들어보도록 하겠습니다.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(BindingResult bindingResult) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.of(bindingResult));
}
최종적으로 위 코드처럼 Map<String, String> 대신 ErrorResponse
타입을 사용하여 좀 더 명료한 값을 반환할 것 입니다.
@Getter
@NoArgsConstructor
public class ErrorResponse {
private List<ErrorField> errors;
public ErrorResponse(List<ErrorField> errors) {
this.errors = errors;
}
public static ErrorResponse of(BindingResult bindingResult) {
return new ErrorResponse(ErrorField.of(bindingResult));
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
public static class ErrorField {
private String field;
private String value;
private String reason;
public static List<ErrorField> of(BindingResult bindingResult) {
List<ErrorField> errorFields = bindingResult.getAllErrors().stream().map(error ->
new ErrorField(((FieldError) error).getField(), ((FieldError) error).getRejectedValue().toString(),
((FieldError) error).getDefaultMessage())).collect(Collectors.toList());
return errorFields;
}
}
}
ErrorResponse는 email, password, name
와 같은 필드에 대한
에러 정보(필드명, 사용자 입력값, 유효성 오류 원인)를 담는
ErrorField의 List
를 갖습니다.
👀 참고로 내부 클래스를 만들때 웬만하면 static 클래스로 만드는 것이 좋습니다.
static 내부 클래스는 바깥 클래스 인스턴스의 참조를 유지하지 않아
static이 아닌 내부 클래스에 비해 메모리 누수를 예방할 수 있고 더 적은 메모리를 사용한다는 장점이 있습니다.
여기까지 진행했다면 위와 같은 json 데이터를 넘겨받는 경우
클라이언트에게 아래와 같은 정보를 반환할 수 있습니다.
{
"errors": [
{
"field": "name",
"value": "",
"reason": "이름은 빈 값일 수 없습니다"
},
{
"field": "password",
"value": "",
"reason": "비밀번호는 빈 값일 수 없습니다"
},
{
"field": "email",
"value": "aaa",
"reason": "올바른 형식의 이메일 주소어야 합니다"
}
]
}
1: https://hibernate.org/validator/
잘봤습니다^^