점프투스프링부트 개선 | 예외처리

Park JeaHyun·2023년 2월 23일

목표

댓글 서비스를 구현하면서 @ControllerAdvice를 통해 예외처리를 해줬다. 그때 보다 예외처리 구성을 더 개선해보고 싶어졌다. 또한 예외처리 로직이 제대로 동작하는지 테스트 코드를 통해 확인해보고자 한다.

  • 요구 사항
    • 예외가 발생했을 때 서버에서 생성하는 응답(http)의 body는 모두 JSON 형태로 만들 것이다.
    • JSON의 형식은 모든 예외가 동일한 형식을 가지도록 할 것이다.

예외처리

ErrorCode

예외(Exception) 클래스가 가지는 속성값이다. 커스텀으로 만드는 모든 예외들이 ErrorCode를 가짐으로서 예외 메세지들이 통일성을 가질 수 있게 해준다.

@Getter
@RequiredArgsConstructor
public class DataNotFoundException extends RuntimeException {
    private static final long serialVersionUID = 1L;
    private final ErrorCode errorCode;

    @Override
    public String getMessage() {
        return errorCode.getMessage();
    }
}
@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter included"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not exists"),
    INACTIVE_USER(HttpStatus.FORBIDDEN, "User is inactive"),
    USER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "해당 이메일의 유저가 없습니다"),
    USER_NOT_FOUND_BY_USERNAME(HttpStatus.NOT_FOUND, "해당 ID의 유저가 없습니다"),
    CREATE_MAIL_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 생성 에러"),
    SEND_MAIL_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송 에러"),
    SIGN_UP_FAIL(HttpStatus.BAD_REQUEST, "이미 등록된 사용자입니다")
    ;

    private final HttpStatus httpStatus;
    private final String message;
}

예시

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final TempPasswordMail tempPasswordMail;
    private final CommonUtil commonUtil;

	...(생략)...

    @Transactional
    public void modifyPassword(String email) throws EmailException {
        String tempPassword = commonUtil.createTempPassword();
        SiteUser user = userRepository.findByEmail(email)
            .orElseThrow(() -> new DataNotFoundException(ErrorCode.USER_NOT_FOUND_BY_EMAIL));
        user.setPassword(passwordEncoder.encode(tempPassword));
        userRepository.save(user);
        tempPasswordMail.sendSimpleMessage(email, tempPassword);
    }
}

ErrorResponse

예외 발생 시 최종적으로 http body(JSON)에 들어가게 되는 데이터. 예외 객체를 바탕으로 만들어지게 된다.

List<ValidationError> errors; 필드는 @Valid 검증이 실패한 필드들이 담기게 된다. 예를들어 클라이언트에서 컨트롤러로 데이터가 넘어올 때 @Valid 검증이 실패할 경우 BindException이 발생하게 되고 이 예외는 뒤에 나올 예외 핸들러에서 다른 예외들과 달리 errors 필드를 추가로 채워준다.

@Getter
@Builder
@RequiredArgsConstructor
public class ErrorResponse {
	private final String code;
    private final String message;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final List<ValidationError> errors;

    @Getter
    @Builder
    @RequiredArgsConstructor
    public static class ValidationError {
    
        private final String field;
        private final String message;

        public static ValidationError of(final FieldError fieldError) {
            return ValidationError.builder()
                    .field(fieldError.getField())
                    .message(fieldError.getDefaultMessage())
                    .build();
        }
    }
}

GlobalExceptionHandler

컨트롤러에서 발생한 모든 예외들을 관리하는 클래스. @RestControllerAdvice 어노테이션을 붙힘으로써 JSON 형태의 응답 데이터를 보낼 수 있다.

BindException의 경우 특별히 예외 객체를 직접 처리하면서 어떤 값이 잘못 들어왔는지 클라이언트에게 알려줄 수 있도록 한다.

BindException이 발생했을 때 응답 예시

@ExceptionHandler({Exception.class}) 어노테이션은 핸들러가 없는 모든 예외들이 처리될 수 있도록 한다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
	@ExceptionHandler(DataNotFoundException.class)
    public ResponseEntity<Object> handleCustomException(DataNotFoundException e) {
        ErrorCode errorCode = e.getErrorCode();
        return handleExceptionInternal(errorCode, e.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException e) {
        log.warn("handleIllegalArgument", e);
        ErrorCode errorCode = ErrorCode.INVALID_PARAMETER;
        return handleExceptionInternal(errorCode, e.getMessage());
    }

    @ExceptionHandler(BindException.class)
    public ResponseEntity<Object> handleMethodArgumentNotValid(BindException e) {
        log.warn("MethodArgumentNotValidException", e);
        ErrorCode errorCode = ErrorCode.INVALID_PARAMETER;
        return handleExceptionInternal(e, errorCode);
    }

	@ExceptionHandler({Exception.class})
    public ResponseEntity<Object> handleAllException(Exception ex) {
        log.warn("handleAllException", ex);
        ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
        return handleExceptionInternal(errorCode);
    }

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode) {
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(makeErrorResponse(errorCode));
    }

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode, String message) {
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(makeErrorResponse(errorCode, message));
    }

    private ResponseEntity<Object> handleExceptionInternal(BindException e, ErrorCode errorCode) {
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(makeErrorResponse(e, errorCode));
    }

	private ErrorResponse makeErrorResponse(ErrorCode errorCode) {
        return ErrorResponse.builder()
                .code(errorCode.name())
                .message(errorCode.getMessage())
                .build();
    }

	private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) {
        return ErrorResponse.builder()
                .code(errorCode.name())
                .message(message)
                .build();
    }

    private ErrorResponse makeErrorResponse(BindException e, ErrorCode errorCode) {
        List<ErrorResponse.ValidationError> validationErrorList = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(ErrorResponse.ValidationError::of)
                .collect(Collectors.toList());

        return ErrorResponse.builder()
                .code(errorCode.name())
                .message(errorCode.getMessage())
                .errors(validationErrorList)
                .build();
    }
}

기존 코드 수정

기존 코드에서는 서비스 계층에서 발생한 예외를 컨트롤러에서 try-catch로 처리하는 로직이 있었다. 해당 로직은 프론트 화면에 어떤 예외가 발생했는지 보여주기 위함이었다. 이제는 그냥 굳이 예쁘게 보여줄 필요가 있나 싶어서 JSON 형태로 응답 메세지를 보여주도록 했다.

아래 코드처럼 컨트롤러에서 예외를 처리해서 예외 메세지를 뷰 화면으로 렌더링 하는 작업은 모두 없앴다.

PostMapping("/signup")
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
      if (bindingResult.hasErrors()) {
          return SIGNUP_FORM;
	  }

      if (!userCreateForm.getPassword1()
          .equals(userCreateForm.getPassword2())) {
          bindingResult.rejectValue("password2", "passwordInCorrect",
              "2개의 패스워드가 일치하지 않습니다.");
          return SIGNUP_FORM;
      }

      userService.create(userCreateForm.getUsername(),
               userCreateForm.getEmail(), userCreateForm.getPassword1());

      // try {
      //     userService.create(userCreateForm.getUsername(),
      //         userCreateForm.getEmail(), userCreateForm.getPassword1());
      // } catch (DataIntegrityViolationException e) {
      //     e.printStackTrace();
      //     bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
      //     return SIGNUP_FORM;
      // } catch (Exception e) {
      //     e.printStackTrace();
      //     bindingResult.reject("signupFailed", e.getMessage());
      //     return SIGNUP_FORM;
      // }

      return "redirect:/";
}

테스트

MockMvc를 이용해서 컨트롤러 테스트를 진행했다. 근데 자꾸 http status 302(redirect: 카카오 로그인 페이지로 이동한다)가 발생해서 고생을 했다. 결론부터 말하면 정확한 원인은 찾지 못했다ㅠㅠ

MockFilterChain.doFilter(ServletRequest request, ServletResponse response)
--> iterator --> index 3: DelegatingFilterProxyRegistrationBean
--> springSecurityFilterChain

여기서 302 리다이렉트가 발생하던데 정확한 원인은 찾아봐야 할 것 같다. 공부할게 참많네.

일단은 임시로 @WithMockUser 어노테이션을 붙였다. 이렇게 함으로써 스프링 시큐리티가 인증된 유저로 인식하게 해서 카카오 로그인 페이지로 넘어가지 않도록 했다. 403 error가 발생해서 csrf 토큰도 추가했다.

@WebMvcTest(UserController.class)
public class UserControllerTests {
    @Autowired
    MockMvc mvc;

    @MockBean
    private UserService userService;
    @MockBean
    private QuestionService questionService;
    @MockBean
    private AnswerService answerService;
    @MockBean
    private CommentService commentService;
    @MockBean
    private BindingResult bindingResult;

    @Test
    @DisplayName("유저 생성")
    @WithMockUser
    void createUser() throws Exception {
        //given

        //when
        when(userService.create(anyString(), anyString(), anyString()))
            .thenThrow(new UserDataIntegrityViolationException(ErrorCode.SIGN_UP_FAIL));
        when(bindingResult.hasErrors()).thenReturn(false);

        mvc.perform(post("/user/signup")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .param("email", "jea5158@naver.com")
            .param("username", "jea5158")
            .param("password1", "1234")
            .param("password2", "1234")
            .with(csrf()))
        //then
            .andDo(print())
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("SIGN_UP_FAIL"))
            .andExpect(jsonPath("$.message").value("이미 등록된 사용자입니다"));
    }
}

원하는 요청과 응답이 날라온 모습

배운점

  • 예외처리 방법
  • MockMvc 사용 방법

개선점

  • MockFilterChain 학습 필요

0개의 댓글