클라이언트가 RequestBody에 Request DTO에 값을 넣어보낼때 이 값을 @Valid 어노테이션을 사용해서 검사한다.
들어와야하는 값이 들어오는지에 따라 다음 3가지 어노테이션을 주로 써서 검사를 한다.
- @NotNull : 요청된 값이 null이면 거절, “” 또는 “ “는 허용
- @NotEmpty : 요청된 값이 null 또는 “”이면 거절, “ “는 허용
- @NotBlank : 요청된 값이 null, “”, “ “이면 거절
이 외에도 @Min , @Max, @Email 등을 통해 길이나 형식 등을 DTO를 검증할 수 있다.
현재 서버의 API에서 발생하는 에러는 DTO Validation에서 발생하는 에러와, Controller 이후에서 발생하는 에러, Filter에서 발생하는(JWT 검증 및 역할) 에러 3가지로 구분된다.
이때 DTO 검증 시 발생하는 에러에선 위에서 언급한, 길이, 공백정도로 간단한 검증만을 한다.
이 때 요청의 어떤 필드가 잘못된 입력인지, 그 이유가 무엇인지 알려줘야 적절한 요청을 다시 보내 요청에 성공할 수 있다.
요청 DTO 검증에러 (MethodArgumenetNotValidException)
유저의 잘못된 요청은 우선, 프론트엔드 개발자와 싱크를 맞추면 어느정도 프론트엔드 개발자가 처리를 해준다.
가령, 이메일 형식이 아니면 요청 자체를 안보내거나, 제출할 주관식 정답이 공백이라면 제출을 막는다거나 처리를 해주지만, 서버 개발자 입장에선 클라이언트에서 어떤 요청을 보내더라도 검증을 한 번 더 해서 나쁠 것이 없다.
회원가입을 예로 설명해보자.
public record JoinRequest(
@Email
@NotNull
String email,
@Size(min = 8, max = 16, message = "비밀번호는 8~16자리여야합니다.")
@NotNull
String password,
@NotNull
String name,
@NotNull
@Size(min = 11, max = 11, message = "전화번호는 11자리여야합니다.")
String phoneNumber
) {
}
이 요청이 잘못 들어왔을때 서버는 클라이언트에게 어떤 필드가 왜 잘못되었는지 이유를 알려줘야한다.
가령 사용자가 회원가입을 하는데 이메일 형식이 아니라면, 이메일 형식으로 입력하라고 알려줘야하고, 비밀번호가 길이를 넘으면 ‘비밀번호’ 필드에 ‘길이 문제’ 라는 것을 알려줘야한다.
이런 오류에 대해서 ‘어떤 필드’에서 ‘왜’ 에러가 발생했는지 알려줘야한다. 필드는 여러가지일 수 있지만, 기존 에러 처리하는 방식은 필드별로 이유를 알려주지 않고 있다.
기존엔 임시 방편으로 message 필드에 에러 필드를 리스트로 알려주고 있었지만 ErrorResponse로 전달할 필드 에서 작성한 message필드와 의미가 맞지 않기도 하고 필드별로 잘못된 이유를 알려주고 있지 않아 명확하게 수정할 필요성을 느꼈다.
기존 GlobalExceptionHandler
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status,
WebRequest request) {
ServletWebRequest servletWebRequest = (ServletWebRequest) request;
HttpServletRequest httpServletRequest = servletWebRequest.getRequest();
String requestURI = httpServletRequest.getRequestURI();
BindingResult bindingResult = ex.getBindingResult();
List<String> errorFields = bindingResult.getFieldErrors().stream()
.map(FieldError::getField)
.toList();
log.error("[Method Argument Not Valid Execption 발생]: {}", errorFields);
log.error("에러가 발생한 지점 {}, {}", httpServletRequest.getMethod(), requestURI);
ErrorResponse errorResponse = ErrorResponse.of(httpServletRequest, errorFields);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
기존 ErrorResponse
public record ErrorResponse(
ErrorCode errorCode,
String message,
String method,
String requestURI
) {
public static ErrorResponse of(ErrorCode errorCode, HttpServletRequest request) {
return new ErrorResponse(
errorCode,
errorCode.getMessage(),
request.getMethod(),
request.getRequestURI()
);
}
//MethodArgumentNotValidException에 사용되는 경우
public static ErrorResponse of(HttpServletRequest request, final List<String> errorFields) {
return new ErrorResponse(
ErrorCode.INVALID_INPUT,
errorFields.toString(),
request.getMethod(),
request.getRequestURI()
);
}
...
}
수정 방향
기존에 사용하는 ErrorResponse는 비즈니스 로직 내부에서(컨트롤러 이후에서) 발생하는 에러들(AppException, EntityNotFoundException 등)에 초점을 맞췄기에 ErrorResponse를 사용하는 것 외의 별도의 DTO Request Valid 확인용 ErrorResponse 클래스를 활용하기로 했다.
기존 ErrorResponse에 List<FieldErrorResponse> errors 를 추가해 에러가 발생한 필드들과 잘못된 이유를 응답으로 알려줬다.
새로 작성한 MethodArgumentErrorResponse 클래스
public record MethodArgumentErrorResponse(
ErrorCode errorCode,
String message,
String method,
String requestURI,
List<FieldErrorResponse> errors
) {
public static MethodArgumentErrorResponse of(ErrorCode errorCode, HttpServletRequest request,
List<FieldErrorResponse> errors) {
return new MethodArgumentErrorResponse(
errorCode,
errorCode.getMessage(),
request.getMethod(),
request.getRequestURI(),
errors
);
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class FieldErrorResponse {
private String field;
private String reason;
public static FieldErrorResponse of(FieldError fieldError) {
return new FieldErrorResponse(
fieldError.getField(),
fieldError.getDefaultMessage()
);
}
}
}
수정된 MethodArgumentException 핸들러
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status,
WebRequest request) {
ServletWebRequest servletWebRequest = (ServletWebRequest) request;
HttpServletRequest httpServletRequest = servletWebRequest.getRequest();
String requestURI = httpServletRequest.getRequestURI();
List<FieldErrorResponse> fieldErrorResponses = ex.getBindingResult().getFieldErrors().stream()
.map(FieldErrorResponse::of)
.toList();
//로그 확인용
List<String> errorFields = fieldErrorResponses.stream()
.map(FieldErrorResponse::getField)
.toList();
log.error("[Method Argument Not Valid Execption 발생]: {}", errorFields);
log.error("에러가 발생한 지점 {}, {}", httpServletRequest.getMethod(), requestURI);
MethodArgumentErrorResponse errorResponse = MethodArgumentErrorResponse.of(
ErrorCode.INVALID_INPUT, httpServletRequest, fieldErrorResponses);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
리팩토링한 PR 링크
https://github.com/IT-Cotato/CS-Quiz-BE/pull/149
추가
https://cheese10yun.github.io/spring-jpa-best-02/#google_vignette
해당 글을 참고해서 리팩토링했다.
이 글에선 Spring에서 제공하는 FieldError 클래스의 value 값을 Response에 같이 보여주고 있다.
하지만, value를 넣을 경우 아래와 같이 응답이 나오는데
ErrorResponse에 원문 그대로의 잘못 입력된 비밀번호 노출 우려가 있다고 판단해 value는 삭제했다.
'프로젝트 > COTATO.KR' 카테고리의 다른 글
멱등성을 통한 정답 제출 API 중복 요청 방지 (이론) (0) | 2024.06.01 |
---|---|
퀴즈 객체 양방향 매핑 없애기 (0) | 2024.04.24 |
여러 객체를 삭제할 때 쿼리 줄이기 (0) | 2024.04.24 |
Controller 리팩토링 (0) | 2024.04.15 |
ErrorResponse로 전달할 필드에 대한 고민 (0) | 2024.04.02 |