Rest ful 예외 처리

이한수·2022년 7월 4일
0
post-thumbnail

개인 토이 프로젝트를 만들면서 발생한 상황들을 정리하고자 한다.

상황 : 회원 가입에 대한 유효성 검증 처리 과정.

@RequestBody를 이용하여 데이터를 DTO에 바인딩할 계획이었습니다.

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();
    }



}

Controller

//경로 -> /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를 재정의하여 사용하였습니다.

반환 데이터

  • 발생 시간(timestamp)
  • 상태 코드(status)
  • 요청 경로(path)
  • 에러 발생 필드(fieldName)
  • 에러 메세지(message)
  • 거절된 값(rejectedValue)

발생한 문제

1) ObjectError까지 한번에 처리할 수가 없었습니다.

Controller에서 BindingResult를 파라미터로 사용하면 , handleMethodArgumentNotValid가 호출되지 않습니다.

2) 예외 메세지로 인한 데이터의 중복이 발생됩니다.

예를 들어, 아래의 데이터를 넘긴다고 가정해보겠습니다.

{"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를 가지고 있기 때문에 데이터의 중복이 발생합니다.

3)예외 메세지가 이쁘지 않습니다.

이것은 문제라기 보다는 , 미처 생각하지 못하고 아직 적용하지 못한 상황입니다.
하지만 , 진행과정 동안 겪었던 것을 정리중이었고 이것도 해결해야할 문제라고 생각이 들어 문제항목에 넣게 되었습니다.


이제 하나하나씩 해결해보려고 합니다.

먼저 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()를 반환하도록 처리하였습니다.

ObjectError 처리 과정

  • 해당 예외를 담아주기 위해서는 Controller에서 BindingResult를 사용할 수 밖에 없었습니다.

그리하여 CustomException을 만들어서 처리하기로 하였습니다.

내부에는 BindingResult를 예외처리하는 곳에서 사용하기 위해 , 생성자 주입으로 받아 사용하였습니다.

//Custom Exception

public class ValidationNotFieldMatchedException extends RuntimeException{

   private BindingResult bindingResult;

   public ValidationNotFieldMatchedException(BindingResult bindingResult){
       this.bindingResult = bindingResult;
   }

   public BindingResult getBindingResult() {
       return bindingResult;
   }
}
  • Controller는 Object에러를 담아줄 로직을 구현하였습니다.
  • bindingResult가 에러를 담고 있을 경우 , throw로 직접 던져 예외처리 클래스로 넘어가게 하였습니다.
@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());
    }
  • ObjectError대신 FieldError로 담아주었습니다. (//주의)
//변경된 예외처리 메소드

@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);
           
           }
  • 내부로직은 똑같습니다.
  • 메소드 선언부와 status코드를 얻어오는 부분만 살짝 다릅니다.

지금 까지 해결한 내용으로의 출력 예시

{
    "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": "비밀번호가 일치하지 않습니다"
        }

데이터의 중복 문제 처리 과정

  • 먼저 , Json데이터에 키값을 추가하기 위해서 , Map

을 추가적으로 사용할까 하였지만 너무 가독성이 떨어지는듯

하여 예외정보를 담아줄 클래스를 추가하였습니다.

//예외정보 담아줄 클래스.
@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 으로 구현체를 변경했습니다.

  • 주목이라고 적어놓은 부분을 보시면 , filedErrorsInfo라는 Map이 발생한 예외의 필드이름으로 된 Key값을 이미 가지고 있다면 , 해당 Value값을 가져와 메세지를 추가해주고, 없으면 새로운 객체를 등록하여 처리하였습니다.
//전송 데이터
{
    "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": "이"
        }
    }
}
profile
성실하게

0개의 댓글