개인 토이 프로젝트를 만들면서 발생한 상황들을 정리하고자 한다.
상황 : 회원 가입에 대한 유효성 검증 처리 과정.
@RequestBody를 이용하여 데이터를 DTO에 바인딩할 계획이었습니다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class JoinMemberDto {
private Long id;
@NotBlank
@Size(min = 2 , max = 4)
private String username;
@NotBlank
@Pattern(regexp = "[a-zA-Z0-9]{8,20}")
@Size(min = 8 , max = 20)
private String userId;
@NotBlank
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[~!@#$%^&*()+|=])[A-Za-z\\d~!@#$%^&*()+|=]{8,16}$")
@Size(min = 8,max = 16)
private String password;
private String password2;
@Email
private String email;
private String tel;
private LocalDateTime createdDate;
//JoinMemberDto -> Member
public Member changeEntity(){
return Member.builder()
.username(this.username)
.userId(this.userId)
.password(this.password)
.email(Objects.requireNonNullElse(this.email,"등록 안함"))
.tel(Objects.requireNonNullElse(this.tel,"등록 안함"))
.memberGrade(MemberGrade.NORMAL)
.build();
}
}
//경로 -> /members
@PostMapping
public ResponseEntity<UpdateMemberDto> join(@RequestBody @Valid JoinMemberDto joinMemberDto){
Member joinMember = memberService.join(joinMemberDto);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(joinMember.getId())
.toUri();
return ResponseEntity.created(location).body(
UpdateMemberDto.builder()
.id(joinMember.getId())
.userId(joinMember.getUserId())
.username(joinMember.getUsername())
.email(joinMember.getEmail())
.tel(joinMember.getTel())
.build());
}
해당 Controller에서 BeanValidation으로 인한 예외가 발생할 경우 , MethodArgumentNotValidException 이 발생한다.
//v1
@Slf4j
@RestController
@ControllerAdvice
public class ApiExceptionController extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", occurExceptionTime());
body.put("status", status.value());
body.put("path",request.getDescription(false));
List<Map> fieldErrors = ex.getBindingResult().getFieldErrors()
.stream().map(
fe ->{
HashMap errorInfo = new HashMap();
errorInfo.put("rejectedValue" , fe.getRejectedValue());
errorInfo.put("fieldName" , fe.getField());
errorInfo.put("message" , fe.getDefaultMessage());
return errorInfo;
}
).collect(Collectors.toList());
body.put("fieldErrors", fieldErrors);
return new ResponseEntity<>(body,status);
}
}
//에러 발생한 시간 반환(format)
private String occurExceptionTime() {
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
@ControllerAdvice , @ExceptionHandler 을 이용하여 , MVC 애플리케이션에서 발생하는 전반적인 예외를 처리하고 자 하였습니다.
또한 , ResponseEntityExceptionHandler 상속 받아서 기존의 메소드를 재정의하여 쉽게 사용해보고자 하였습니다.
handleMethodArgumentNotValid를 재정의하여 사용하였습니다.
반환 데이터
Controller에서 BindingResult를 파라미터로 사용하면 , handleMethodArgumentNotValid가 호출되지 않습니다.
예를 들어, 아래의 데이터를 넘긴다고 가정해보겠습니다.
{"userId": "hslee"}
userId필드는 @Size와 @Pattern 어노테이션 2가지에 걸리게 됩니다.
//출력 결과
{
"rejectedValue": "hslee",
"fieldName": "userId",
"message": "크기가 8에서 20 사이여야 합니다"
},
{
"rejectedValue": "hslee",
"fieldName": "userId",
"message": "\"[a-zA-Z0-9]{8,20}\"와 일치해야 합니다"
}
보시다 시피 , 서로 다른 message를 가지고 있기 때문에 데이터의 중복이 발생합니다.
이것은 문제라기 보다는 , 미처 생각하지 못하고 아직 적용하지 못한 상황입니다.
하지만 , 진행과정 동안 겪었던 것을 정리중이었고 이것도 해결해야할 문제라고 생각이 들어 문제항목에 넣게 되었습니다.
이제 하나하나씩 해결해보려고 합니다.
먼저 Message부터 해결해보려고 합니다.
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", occurExceptionTime());
body.put("status", status.value());
body.put("path",request.getDescription(false));
//예외
List<Map> fieldErrors = ex.getBindingResult().getFieldErrors()
.stream().map(
fe ->{
Map errorInfo = new HashMap();
errorInfo.put("rejectedValue" , fe.getRejectedValue());
errorInfo.put("fieldName" , fe.getField());
//변경된 곳(메세지를 얻어오는 과정만 변경)
String message = Arrays.stream(Objects.requireNonNull(fe.getCodes()))
.map(c -> {
try {
Object[] argument = fe.getArguments();
return messageSource.getMessage(c, argument, null);
}catch (NoSuchMessageException e){
return null;
}
}).filter(Objects::nonNull)
.findFirst()
.orElse(fe.getDefaultMessage());
errorInfo.put("message",message);
return errorInfo;
}
).collect(Collectors.toList());
body.put("fieldErrors", fieldErrors);
return new ResponseEntity<>(body,status);
}
message.properties파일을 하나 만들고 code값을 이용하기로 하였습니다.
왜?
앞으로 추가적으로 만들게 될 다른 dto에서도 비슷한 메세지를
재사용성하기 위해서입니다.
FieldError에 담겨 있는 , arguments와 code값을 이용하여 ,messages.properties 파일에 정의한 메세지들을 가져왔습니다.
-코드값으로 된 message가 없을 경우 DefaultMessage()를 반환하도록 처리하였습니다.
그리하여 CustomException을 만들어서 처리하기로 하였습니다.
내부에는 BindingResult를 예외처리하는 곳에서 사용하기 위해 , 생성자 주입으로 받아 사용하였습니다.
//Custom Exception
public class ValidationNotFieldMatchedException extends RuntimeException{
private BindingResult bindingResult;
public ValidationNotFieldMatchedException(BindingResult bindingResult){
this.bindingResult = bindingResult;
}
public BindingResult getBindingResult() {
return bindingResult;
}
}
@PostMapping
public ResponseEntity<UpdateMemberDto> join(@RequestBody @Valid JoinMemberDto joinMemberDto ,BindingResult bindingResult){
//추가된 부분!!
if(!joinMemberDto.getPassword().equals(joinMemberDto.getPassword2())){
bindingResult.rejectValue("password","NotEquals","비밀번호가 일치하지 않습니다");
//주의(아래에서 설명)
}
if(bindingResult.hasErrors()){
throw new ValidationNotFieldMatchedException(bindingResult);
}
//여기까지!!
Member joinMember = memberService.join(joinMemberDto);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(joinMember.getId())
.toUri();
;
return ResponseEntity.created(location).body(
UpdateMemberDto.builder()
.id(joinMember.getId())
.userId(joinMember.getUserId())
.username(joinMember.getUsername())
.email(joinMember.getEmail())
.tel(joinMember.getTel())
.build());
}
//변경된 예외처리 메소드
@ExceptionHandler
public ResponseEntity<Object> handleValidationNotFieldMatchedException(
ValidationNotFieldMatchedException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", occurExceptionTime());
body.put("status", HttpStatus.BAD_REQUEST);
body.put("path",request.getDescription(false));
//예외
List<Map> fieldErrors = ex.getBindingResult().getFieldErrors()
.stream().map(
fe ->{
Map errorInfo = new HashMap();
errorInfo.put("rejectedValue" , fe.getRejectedValue());
errorInfo.put("fieldName" , fe.getField());
//변경된 곳(메세지를 얻어오는 과정만 변경)
String message = Arrays.stream(Objects.requireNonNull(fe.getCodes()))
.map(c -> {
try {
Object[] argument = fe.getArguments();
return messageSource.getMessage(c, argument, null);
}catch (NoSuchMessageException e){
return null;
}
}).filter(Objects::nonNull)
.findFirst()
.orElse(fe.getDefaultMessage());
errorInfo.put("message",message);
return errorInfo;
}
).collect(Collectors.toList());
body.put("fieldErrors", fieldErrors);
return new ResponseEntity<>(body,HttpStatus.BAD_REQUEST);
}
{
"timestamp": "2022-07-05 01:22:57",
"status": 400,
"path": "uri=/members"
"fieldErrors": [
{
"rejectedValue": "hslee",
"fieldName": "userId",
"message": "userId은 8 ~ 20글자 사이로 입력해 주세요."
},
{
"rejectedValue": "hslee",
"fieldName": "userId",
"message": "영어와 숫자로만 구성해주세요."
},
{
"rejectedValue": "1234",
"fieldName": "password",
"message": "비밀번호가 일치하지 않습니다"
}
}
메세지 출력도 잘되었고 , 2개 이상의 필드에서 문제가 발생할
경우 담아줄 ObjectError를 FieldError처리로 담아서
의도한대로 예외클래스에서 처리로 일괄되게 처리하게끔
하기위한것도 잘 반영되었습니다.
하지만, 여기까지 진행하다 보니 , 추가적으로 아쉬운 점이 있었습니다.
fieldErrors라는 데이터 내부를 보시면 , 각각 예외 정
보들이 담겨있습니다. 하지만 , 어느 예외에 관한 데이터
인지 알기 위해서는 , 또 내부의 데이터를 살펴
fieldName이라는 데이터를 확인해야만 합니다.
그래서 아래 중복 처리 과정에서는 키값을 추가로 주어 ,
어떤 예외에 관한 정보인지 내부를 보지 않아도 알 수있
도록 같이 처리해보도록 하겠습니다.
ex)
"fieldErrors": [
이부분-->"userId" :{
"rejectedValue": "hslee",
"fieldName": "userId",
"message": "userId은 8 ~ 20글자 사이로 입력해 주세요."
},
이부분-->"userId" :{
"rejectedValue": "hslee",
"fieldName": "userId",
"message": "영어와 숫자로만 구성해주세요."
},
이부분-->"password": {
"rejectedValue": "1234",
"fieldName": "password",
"message": "비밀번호가 일치하지 않습니다"
}
을 추가적으로 사용할까 하였지만 너무 가독성이 떨어지는듯
하여 예외정보를 담아줄 클래스를 추가하였습니다.
//예외정보 담아줄 클래스.
@Getter
@Builder
public class ValidationErrorResponse {
private List<String> messages;
private String fieldName;
private String rejectedValue;
}
@ExceptionHandler
public ResponseEntity<Object> handleValidationNotFieldMatchedException(
ValidationNotFieldMatchedException ex, WebRequest request) {
Map<String, Object> body = new LinkedMap<>();
body.put("timestamp", occurExceptionTime());
body.put("status",HttpStatus.BAD_REQUEST);
body.put("path",request.getDescription(false));
Map<String ,ValidationErrorResponse> filedErrorsInfo = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.stream().forEach(fe -> {
//주목
if(filedErrorsInfo.containsKey(fe.getField())){
filedErrorsInfo.get(fe.getField()).getMessages().add(getMessageSource(fe));
}else{
ValidationErrorResponse validationErrorResponse = ValidationErrorResponse.builder()
.fieldName(fe.getField())
.rejectedValue(getRejectedValue(fe))
.messages(new ArrayList<>())
.build();
validationErrorResponse.getMessages().add(getMessageSource(fe));
filedErrorsInfo.put(fe.getField() , validationErrorResponse);
}
});
body.put("fieldErrors", filedErrorsInfo);
return new ResponseEntity<>(body,HttpStatus.BAD_REQUEST);
}
//거절된 값을 얻어온다.
private String getRejectedValue(FieldError fe) {
String rejectedValue = null;
if(fe.getRejectedValue() == null){
rejectedValue = "값이 들어오지 않음";
}else{
rejectedValue = fe.getRejectedValue().toString();
}
return rejectedValue;
}
//error 메세지를 얻어온다.
private String getMessageSource(FieldError fe) {
return Arrays.stream(Objects.requireNonNull(fe.getCodes()))
.map(c -> {
try {
Object[] argument = fe.getArguments();
return messageSource.getMessage(c, argument, null);
} catch (NoSuchMessageException e) {
return null;
}
}).filter(Objects::nonNull)
.findFirst()
.orElse(fe.getDefaultMessage());
}
중복되는 코드도 생기고 너무 방대해져서 메세지를 얻어오는 과정등은 따로 메소드를 추출하여 분리하였습니다.
timestamp,status,path등을 담아주는 map은 순서를 보장해주게끔 LinkedHashMap 으로 구현체를 변경했습니다.
//전송 데이터
{
"username": "이",
"userId": "hslee",
"password":"1234",
"password2":"123"
}
//결과
{
"timestamp": "2022-07-05 02:29:13",
"status": "BAD_REQUEST",
"path": "uri=/members",
"fieldErrors": {
"password": { <------키값 추가 구현 완료.
"messages": [
"숫자,영문,특수문자를 1글자 이상씩 구성해주세요.",
"password은 8 ~ 16글자 사이로 입력해 주세요.",
"비밀번호가 일치하지 않습니다"
], <-------message로 인한 데이터 중복 방지 완료
"fieldName": "password",
"rejectedValue": "1234"
},
"userId": {
"messages": [
"userId은 8 ~ 20글자 사이로 입력해 주세요.",
"영어와 숫자로만 구성해주세요."
],
"fieldName": "userId",
"rejectedValue": "hslee"
},
"username": {
"messages": [
"username은 2 ~ 4글자 사이로 입력해 주세요."
],
"fieldName": "username",
"rejectedValue": "이"
}
}
}