[Spring] 유효성 검사 Bean Validation과 웹 예외 처리

벼랑 끝 코딩·2025년 4월 1일

Spring

목록 보기
11/16

서비스를 고민하며 로직을 개발했다면 사실 이것은 시작에 불과하다.
유효성 검사와 예외 처리가 코드의 9할이라는 소문 때문이다.
Spring에서는 이렇게 중요한 유효성 검사와 예외를 어떻게 처리하는지 살펴보자.

Validation

웹에서는 Client가 Server가 서로 무수히 많은 데이터를 주고 받는다.
하지만 이 과정에서 아주 많은 트러블이 발생한다.
원하는 데이터만 주고 받으면 참 좋겠지만, 기대하지 않은 데이터가 포함되기 때문이다.
독성 물질과 같은 잘못된 데이터가 침투하면 큰 에러가 발생할 수 있다.
잘못된 데이터는 사전에 들어오지 못하도록 처리해야 한다.

이 과정을 유효성 검사(Validation)라고 한다.

Error Message

@PostMapping("/url")
public String method(@ModelAttribute Data data) {
	
    if (data가 내 맘에 안들면) {
    	return "home";
    }
    
    if (한번 봤는데도 data가 다시 맘에 안들면) {
    	return "home";
    }
    
    // 정상 로직
}

위 코드를 보면 유효성 검사가 왜 로직의 9할이라고 하는지 알 수 있을 것이다.

그런데 만약 이렇게 매우 많은 유효성 검사 로직이 있는데,
어떤 부분이 잘못됐는지 안내조차 없이 사용자를 자꾸 돌려보낸다면 어떻게 될까?
아마 사용자는 서비스를 운영하지 않는다고 생각하고 돌아설 것이다.

우리는 유효성 검사를 통과하지 못하면,
사용자가 다음 검사는 통과할 수 있도록 오류 메시지를 함께 전달해야 한다.

Model에 Error Message 저장

@PostMapping("/url")
public String method(@ModelAttribute Data data, Model model) {

	Map<String, String> errorMap = new HashMap<>();
	
    if (dataName이 내 맘에 안들면) {
    	errorMap.put("dataName", "데이터 이름을 수정하세요.");
    }
    
    if (dataNumeber가 내 맘에 안들면) {
    	errorMap.put("dataNumber, "데이터 번호를 수정하세요.");
    }
    
    if (dataName과 dataNumber 조합이 맘에 안들면) {
    	errorMap.put("globalError", "데이터 이름과 데이터 번호 조합 규칙을 지키세요.");
    
    // 정상 로직
    
    model.addAttribute("errorMap", errorMap);
    return "home";
}

유효성 검사를 통과하지 못하면 Map 형태로
어느 부분에서 무엇이 잘못됐는지 메시지를 Model에 담아 저장한다.
사용자를 돌려보낼 때, 에러 메시지가 담긴 errorMap을 더해 돌려보내고
화면에 errorMap의 에러 메시지가 적절한 위치에 표시될 수 있도록 한다.

특정 필드에만 적용되는 유효성 검사가 있는 반면에,
여러 조건의 조합 등으로 유효성 검사를 진행할 수 있는데
이러한 유효성 검사의 에러를 global error라고 부른다.

위 구조는 유효성 검사를 통과하지 못하면 error Message를 저장하지만,
타입 오류가 발생하면 오류 페이지로 전환되어 사용하기 어렵다.

BindingResult

@PostMapping("/url")
public String method(@ModelAttribute Data data, BindingResult bindingResult)

errorMessage를 저장하기 위해 별도의 Map을 생성하기 보다는
error를 관리하는 별도의 객체가 있는 것이 좋을 것 같다.
Spring은 위 과정을 편리하게 수행하기 위해 BindingResult 객체를 지원한다.

BindingResult 객체는 @ModelAttribute 뒤에 선언해야 한다.

위 규칙으로 BindingResult는 @ModelAttribute의 Model 오류를 저장한다.
BindingResult는 타입 오류가 발생해도 정상적으로 error Message를 출력한다.

FieldError

public FieldError(String objectName, String field, String defaultMessage) {}

에러는 필드 오류인 FieldError글로벌 오류인 ObjectError가 있다.
필드 오류인 FieldError에 대해 먼저 알아보자.

@PostMapping("/url")
public String method(@ModelAttribute Data data, BindingResult bindingResult) {
	
    if (dataName이 내 맘에 안들면) {
    	BindingResult.addError(new FieldError("data", "dataName", "error Message"));
    }
    
    // 정상 로직
    
    return "home";
}

더이상 Map을 생성하여 저장하지 않고 BindingResult에 바로 저장할 수 있다.
BindingResult에 error를 저장할 때에는 addError() 메서드를 호출하여 저장한다.
하나의 필드에 여러개의 error를 저장할 수 있다.

// 필드 오류가 1개일 때
<div th:errorclass="error" th:errors="*{field}">

// 필드 오류가 2개 이상일 때
<li th:each="error : ${#fields.errors('fieldName')}" th:text="${error}"></li>
  • th:errorclass

th:errorclass 속성은 error가 있는 경우 class에 해당 속성을 추가한다.

  • th:errors

error Message를 Model에 추가할 필요 없이 Thymeleaf에서 접근할 수 있다.
th:if의 편의 버전으로, 해당 속성의 필드에 error가 있는 경우에만 동작한다.
두개 이상의 오류가 있다면 첫번째 오류 메시지를 출력한다.
모든 오류 메시지를 출력하려면 th:errors가 아닌 th:each 문법을 사용한다.

  • ${#fields}

BindingResult 객체의 오류에 접근할 수 있는 키워드이다.
#fields.errors(), #fields.globalERRORS() 등의 메서드를 사용할 수 있다.

ObjectError

public ObjectError(String objectName, String defaultMessage) {}

global error는 FieldError가 아닌 ObjectError로 생성한다.
특정 필드에서 발생한 에러가 아니므로 field 파라미터는 없다.

@PostMapping("/url")
public String method(@ModelAttribute Data data, BindingResult bindingResult) {
	
    if (dataName과 dataNumber 조합이 내 맘에 안들면) {
    	BindingResult.addError(new ObjectError("data", "error Message"));
    }
    
    // 정상 로직
    
    return "home";
}

FieldError와 파라미터만 다를 뿐 저장하는 방식은 동일하다.
ObjectError에도 여러개의 error를 저장할 수 있다.

<li th:each="error : ${#fields.globalErrors()}" th:text="${error}"></li>

global error은 특정 필드에 저장된게 아니므로 th:errors 속성을 사용할 수 없다.
th:each 문법을 사용해서 global error의 메시지를 하나씩 출력할 수 있다.

reject(), rejectValue()

사용자가 입력한 값이 유효성 검사를 통과하지 못하면,
이탈을 막고 정정을 유도하도록 error Message를 전달했다.

하지만 이런 경우도 있을 것이다.
분명히 잘 입력한 것 같은데 자꾸 다시 입력을 하라고 하는 상황.
잘 입력한 것 같다는 심증만 있고 물증은 없는 상황이다.
이런 상황을 위해 error Message를 전달할 뿐만 아니라
사용자가 잘못 입력한 데이터도 함께 전달하여 편의성을 증대시켜보자.

또한 하드코딩된 errer Message 부분을 메시지 기능을 이용하여 수정해보자.

FieldError

public FieldError(String objectName, String field, @Nullable Object rejectedValue,
		boolean bindingFailure, @Nullable String[] codes,
        @nullable Object[] arguments, @Nullable String defaultMessage)
  • objectName : 오류 발생 객체 이름
  • field : 오류 발생 필드 이름
  • rejectedValue : 유효성 검사를 통과하지 못한 사용자 입력 값
  • bindingFailure : 타입 오류 여부(true : 타입 오류, false : 검증 오류)
  • codes : 메시지 코드
  • arguments : 메시지에 전달할 파라미터
  • defaultMessage : 기본 오류 메시지

FieldError은 또다른 생성자를 제공한다.
FieldError의 rejectedValue에 사용자가 입력한 값을 저장하여 등록하면
유효성 검사를 통과하지 못한 사용자 입력 값을 전달할 수 있다.

또한 codes, arguments 파라미터에
messages.properties에 입력한 key, parameter 값을 전달하면,
하드코딩 하지 않고 메시지 기능을 사용하여 에러 메시지를 출력할 수 있다.

하지만 작성해야할 파라미터의 개수가 너무 많다.
BindingResult는 @ModelAttribute 뒤에 선언되어
구조적으로 이미 @ModelAttribute로 선언된 Model에 대한 정보를 알고 있다.
이미 알고 있는 정보를 제외하면 좀 더 편리하게 사용할 수 있을 것 같다.

rejectValue()

void rejectValue(@Nullable String field, String errorCode, 
		@Nullable Object[] errorAargs, @Nullable String defaultMessage)
  • field : 오류 발생 필드 이름
  • errorCode : 오류 코드
  • errorArgs : 메시지에 전달할 파라미터
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 기본 메시지

rejectValue() 메서드를 사용하면 FieldError 등록을 더 간편하게 사용할 수 있다.
reject() 메서드는 글로벌 오류를 추가할 때 사용한다.

BindingResult는 객체 정보를 이미 알고 있어 objectName 필드가 사라졌고
객체 정보에는 입력된 필드의 값도 있으므로 rejectedValue 파라미터도 없어졌다.

그 외에 변화한 점은 타입 오류인지 나타내는 파라미터와
메시지 기능을 사용하던 파라미터인 codes가 errorCode로 바뀌었다.
이 부분을 좀 더 자세히 알아보자.

MessageCodesResolver

rejectValue() 메서드 호출 → MessageCodeResolver → FieldError 생성 → addError()

rejectValue() 메서드를 호출하면 내부적으로 MessageCodeResolver가 동작한다.
MessageCodeResolver는 errorCode의 문자열을 바탕으로
messages.properties에 등록한 error message key 값을 자동으로 조회한다.

MessageCodesResolver는 어떻게 메시지 key 값을 자동으로 조회할까?

1. errorCode.object.field
2. errorCode.field
3. errorCode.objectType
4. errorCode

MessageCodesResolver는 errorCode 문자열을 바탕으로
위 우선순위 차례대로 메시지 key 값을 자동으로 조회한다.

예를 들어, 필수로 값이 입력되어야 하는 errorCode를 essential이라고 한다면
첫번째로 essential.object.field 메시지 key 값을 조회한다.
이후 차례대로 essential.field, essential.java.lang.String,
마지막으로 essential을 조회한다.

개발자는 messages.properties에 위 우선순위에 맞추어
errorMessage를 key=value 형식으로 등록하기만 하면 된다!

에러 메시지를 간단하게 작성하면 관리하기 편해서 좋을 수 있지만
여러 필드 에러에서 공유하기 어렵기 때문에
위 우선순위에 따라 메시지를 단계별로 구체적으로 작성해서 분류해야 한다.

typeMismatch

rejectValue()의 파라미터를 보면 없어진 것이 하나 더 있다.
바로 타입 오류 여부를 나타내는 bindingFailure이다.
MessageCodesResolver는 타입 오류가 발생하면 errorCode가 아닌
typeMismatch를 바탕으로 메시지 기능을 조회한다.

우선순위는 동일하다.

1. typeMismatch.object.field
2. typeMismatch.field
3. typeMismatch.objectType
4. typeMismatch

개발자는 타입 오류가 발생했을 때 출력할 error message를
위와 같은 우선순위대로 typeMismatch를 messages.properties에 선언하면 된다.

참고로 보통 error message를 메시지에 정의하는 것은
messages.properties가 아닌 error.properties와 같이 별도로 관리하는 것이 좋다.

Vailidator 분리

@PostMapping("/url")
public String method(@ModelAttribute Data data, BindingResult bindingResult) {
	
    if (dataName이 내 맘에 안들면) {
    	BindingResult.rejectValue("dataName", "essential", "default error message");
    }
    
    if (dataNumber가 내 맘에 안들면) {
    	BindingResult.rejectValue("dataNumber", "essential", "default error message");
    }
    
    // 정상 로직
    
    return "home";
}

위 로직은 유효성 검사 로직과 정상 로직이 함께 있어 매우 난잡하게 보인다.
유효성 검사 로직은 별도로 분리하는 것이 좋아보인다.
Spring은 Validator 인터페이스를 지원하여
유효성 검사 로직을 분리할 수 있도록 돕는다.

Validator interface

public interface Validator {
	boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • supports() : 검증기 지원 여부 확인 메서드
  • validate(Object target, Errors errors) : 검증 대상과
    BindingResult를 전달하여 실제 유효성을 검사하는 로직

Validator 인터페이스를 구현하여 validate() 메서드에 유효성 검사 로직을 분리한다.

private final DataValidator dataValidator;

@PostMapping("/url")
public String method(@ModelAttribute Data data, BindingResult bindingResult) {

	dataValidator.validate(data, bindingResult);
    
    if(bindingResult.hasErrors()) {
    	return "/home"
    }
    
    // 정상 로직
}

유효성 검사 로직을 분리하면 깔끔하게 비즈니스 로직을 작성할 수 있다.

@InitBinder, @Validated

@Controller
class Controller {
	
    private final DataValidator dataValidator
	
    @InitBinder
    public void init(WebDataBinder dataBinder) {
    	dataBinder.addValidators(dataValidator);
    }
    
    @PostMapping("/url")
    public String method(@Validated @ModelAttribute Data data, BindingResult bindingResult) {
  		
        if(bindingResult.hasErrors()) {
    	return "/home"
    	}
    
    	// 정상 로직
    }
}

Controller에 @InitBinder 애노테이션을 선언후
WebDataBinder에 구현한 Validator를 등록한다.
그리고 Controller에 @Validated 애노테이션을 검증 대상 앞에 선언하면
Spring이 해당 객체의 유효성 검사를 자동으로 실행한다.

@SpringBootApplication
public class Application implements WebMvcConfigurer {
	
    public static void main(String[] args) {
    	SpringApplication.run(Application.class, args);
    }
    
    @Override
    public Validator getValidator() {
    	return new DataValidator();
    }
}

모든 컨트롤러에 적용하려면 @SpringBooatApplication 애노테이션을 선언하는
Application 클래스에 WebMvcConfigurer의 getValidator() 메서드를 구현한다.
이 경우 컨트롤러에 @InitBinder 애노테이션을 작성하지 않아도 적용된다.

어떤 Validator를 실행해야 할지는 Validator 인터페이스에 구현한
supports() 메서드를 호출하여 검증 대상 객체의 클래스 정보와 비교한다.

@InitBinder와 @Validated 애노테이션을 더하면
비즈니스 로직을 더욱 깔끔하게 작성하고
호출 없이 자동으로 유효성 검사를 실행할 수 있다.

Bean Validation

Validator 인터페이스를 구현하고 @InitBinder 또는 글로벌 설정으로 등록하는
유효성 검사를 위한 과정은 너무나도 멀고 험하다.

우리의 Spring 센세.
필드 값을 검증하기 위한 Validator를 미리 다 만들어 두었다.
우리는 애노테이션을 선언하는 방식으로 유효성 검사를 진행할 수 있다.

build.gradle에 라이브러리를 추가하자.

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
}

Validation Annotation

class Data {
	
    @NotBlank
    private String name;
    
    @NotNull
    @Range(min = 1, max = 10)
    private Integer number;
    
    // 코드
}   

Validation을 위해 사용하는 애노테이션은 자바 표준인 javax와 hibernate가 있는데
실무에서도 hibernate를 주로 사용하므로 자유롭게 사용하면 된다.

이제 Validator 인터페이스를 구현하여 validate() 메서드에
유효성 검사를 위한 로직을 추가하지 않아도
필드 유효성 검사를 하기 위한 대부분의 로직은
Spring이 애노테이션으로 구비해두었다.

@Validated 애노테이션은 동일하게 선언하고
빈 값과 공백을 허용하지 않는 @NotBlank, null을 허용하지 않는 @NotNull,
숫자의 범위를 지정하는 @Range, 최소값과 최댓값을 지정하는 @Min, @Max 등

애노테이션을 사용하여 편리하게 필드 값의 유효성을 검사할 수 있다.

global error

글로벌 오류는 특정 필드에 애노테이션을 선언할 수 있는게 아니기 때문에
ObjectError에 한해서 BindingResult reject() 메서드를 호출하여
직접 추가하는 방식을 권장한다.

@RequestBody

@PostMapping("/url")
public String method(@ReuqestBody @Validated Data data, BindingResult bindingResult)

@ModelAttribute 뿐만 아니라 @RequestBody도 Bean Validator를 적용할 수 있다.

두 애노테이션의 차이점은 @ModelAttribute는 필드 단위로 바인딩하여
어느 한 필드가 바인딩 되지 않더라도 다른 필드는 정상적으로 바인딩한다.
따라서 타입 오류가 발생해도 예외 페이지로 전환하지 않고
타입 오류가 발생한 필드의 FieldError로 처리한다.

@RequestBody는 객체 단위로 적용되어 모든 필드를 객체로 변경하지 못하면
Controller, Validator 모두 호출하지 않고 예외 페이지를 반환한다.

messages.properties

1. Annotation.object.field
2. Annotation.field
3. Annotation.ObjectType
4. Annotation

그렇다면 유효성 검사를 만족하지 못했을 때 에러 메시지는 어떻게 작성하면 될까?
Bean Validator도 마찬가지로 메시지 기능을 사용한다.
사용하는 Annotation을 중심으로 우선순위는 기존 방식과 동일하다.

타입 오류가 발생하는 경우에는 동일하게 typeMistmatch 메시지 기능을 출력하고
FieldError가 추가되며 Bean Validator는 동작하지 않는다.

DTO

Bean Validator 방식에는 한계가 있다.
보통 데이터는 등록, 수정과 같이 경우에 따라 유효성 검사 기준이 달라질 수 있다.
기준 뿐만 아니라 대부분 상황에 따라 객체의 정보를 입력하는 필드가 모두 다르다.

예를 들어, 회원 가입만 하더라도 가입 시에는
주민등록번호, 아이디, 비밀번호, 주소, 개인정보 동의 등 다양한 값을 입력하지만
수정할 때에는 아이디, 비밀번호만 수정이 가능한 경우도 있다.
따라서 클래스 하나와 유효성 검사를 함께 진행하는 것은 불가능에 가깝다.

class Data {
	
    private String name;
    
    private Integer number;
}

class DataModifiedForm {  // ** 데이터 수정 양식(데이터 수정 DTO) **
	
    @NotNull
    @Range(min = 0, max = 999)
    private Integer number;
}

실무에서는 이 문제를 해결하기 위해 DTO라는 개념을 사용한다.
DTO(Data Transfer Object)는 데이터를 전달하기 위한 객체를 의미한다.
상황에 따라 클래스의 다른 형태인 DTO를 설계하여 사용하고,
해당 클래스에 유효성 검사를 적용했다.

위 예시에는 데이터 수정 화면에서 사용자가 데이터를 전송할 때,
데이터 수정 상황에 사용하는 데이터 수정 DTO를 설계하여
다른 필드 구조와 유효성 검사 기준을 적용할 수 있게 만들었다.

이 경우 실제 데이터를 저장하는 클래스와
데이터 수정 시 전달 받는 DTO 클래스를 구분했기 때문에,
실제 클래스에 값을 저장하는 로직이 추가로 필요하다.

@PostMapping("/url")
public String method(@Validated @ModelAttribute("data") DataModifiedForm dataModifiedForm ..)

서버에서 상황에 따라 다른 데이터 구조를 사용할 뿐,
View에서는 동일한 이름으로 데이터를 전송하기 때문에
@ModelAttribute에 객체 이름을 명시해서 사용해야 한다.

결론적으로, 인터페이스를 구현하거나 별도의 복잡한 설정 필요 없이
DTO를 설계 후 해당 클래스에 애노테이션을 선언하는 것 만으로
편리하게 유효성 검사를 실행할 수 있게 됐다!
global error만 reject() 메서드로 별도 로직을 작성해주자.

웹 예외 처리

유효성 검사만큼 중요하고 코드의 많은 부분을 차지하는 것이 예외 처리이다.
유효성 검사는 예상되는 규칙에 따라 값이 적절한지 판단하는 과정이라면,
예외 처리는 예기치 못한 비정상적인 상황을 처리하는 과정이다.

예를 들어 나이를 입력해야 하는 곳에 모두가 숫자를 입력할 것 같지만
문자 데이터가 들어오는 어지러운 상황이 발생하지 않을 수 없다.
입력한 숫자가 적절한 범위에 있는 것을 판단하는 것이 유효성 검사라면
숫자가 입력되지 않아 발생한 오류를 처리하는 것이 바로 예외 처리이다.
둘은 비슷해 보이지만 다른 개념이라는 것을 인지하자.

예외 페이지 등록

1. throw new Exception()
2. response sendError(HTTP 상태 코드, errorMessage)

위와 같이 여러 상황에서 예외가 발생한다.
예외가 발생하면 예외 페이지를 사용자에게 전달해주어야 한다.
예외 페이지를 전달하려면 예외 페이지를 먼저 생성해야겠다.

@Component
public class WebServerCustomizer
		implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
    	
        ErrorPage error404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error404");
        ErrorPage error500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR,
        		"/error500");
         
        factory.addErrorPages(error404, error500);
    }
}

예외 페이지를 생성했다면 예외가 발생한 HTTP 상태 코드에 맞게
Spring이 사용자에게 적절한 예외 페이지를 전달할 수 있도록 페이지를 등록해야 한다.

WebServerFactoryCustomizer<ConfigurableWebServerFactory>의
customize(ConfigurableWebServerFactory factory) 메서드를 구현하고
ErrorPage 객체를 생성한 뒤 addErrorPages() 메서드를 호출하여 등록할 수 있다.

@Controller
public class ErrorController {
	
    @RequestMapping("/error404")
    public String error404() {
    	return "/error404";
    }
    
    ..
    
}

예외 페이지를 등록했다면 등록한 Url에 따라 예외 페이지 요청이 들어왔을 때,
이에 대응할 예외 컨트롤러도 설계해야 한다.

이렇게 하면 특정 HTTP 상태 코드 예외가 발생했을 때, 에러 페이지 Url로 유도하고
컨트롤러가 에러 페이지를 출력하는 것으로 웹 예외를 처리할 수 있다.

Spring 예외 페이지 자동 등록

인터페이스를 구현 후 ErrorPage를 생성해서 등록하고 Controller까지 만드는 작업은
너무나 번거로워서 예외 처리를 안하고 싶을 지경이다.

다행히 인터페이스를 구현하고 개발 페이지를 등록하고 컨트롤러로 호출하는 과정은
Spring의 BasicErrorController가 자동으로 수행해준다!
Spring이 자동으로 수행하는 예외 처리 기능을 변경하고 싶은 경우,
ErrorController를 구현하거나 BasicErrorController를 상속 받아 구현해야 한다.

예외 페이지 우선순위

1. 뷰 템플릿
- resources/templates/error/500.html
- resources/templates/error/5xx.html

2. 정적 리소스
- resources/static/error/400.html
- resources/static/error/4xx.html

3. 적용 대상이 없는 경우
- resources/templates/error.html

예외가 발생하면 Spring은 '/error' Url을 기본으로 요청한다.
위와 같은 우선순위로 /error Url을 조회하는데,
HTTP Status에 따라 구체적으로 상태 코드가 입력된 예외 페이지부터,
범용적으로 처리하는 상태 코드(ex. 5xx)로 우선순위가 설정된다.

개발자는 우선순위에 따라 예외 페이지만 개발하면 된다.

Spring은 오류 정보도 Model에 포함하여 예외 페이지에서 전달할 수 있지만
예외 정보를 외부에 노출하는 것은 좋지 않다.

// application.properties

// 오류 처리 화면을 찾지 못하는 경우 Spring whitelabel 오류 페이지 적용 여부
server.error.whitelabel.enabled=true

// 기본 오류 페이지 경로
server.errror.path=/error

application.properties의 설정을 통해 Spring 오류 옵션을 설정할 수 있다.

예외 페이지 호출 과정

1. Client → WAS(Cleint URl 요청) → Filter → Servlet → Interceptor → Controller(예외 발생)
2. WAS(예외페이지 조회) ← Filter ← Servlet ← Interceptor ← Controller(예외 전달)
3. WAS(예외페이지 Url 재요청) → Filter → Servlet → Interceptor → Controller(예외 페이지 렌더링)

Client 요청 후 Controller가 로직을 처리하다가 예외가 발생하면
WAS Server에 예외를 전달하고, WAS는 등록한 예외 페이지를 조회하여
예외 페이지 Url을 다시 예외 컨트롤러에 요청한다.
예외가 발생함으로써 수많은 객체들을 거쳐야 하는 흐름은 매우 비효율적이다.

DispatcherType

- REQUEST : Client 요청
- ERROR : 오류 요청
- FORWARD
- INCLUDE
- ASYNC

DispatcherType은 위와 같이 분류할 수 있다.
Filter와 Interceptor는 예외 호출에 로직을 적용하지 않도록 설정할 수 있다.

  • Filter

@Configuration
public class WebConfig implements WebMvcConfigurer {
	
    @Bean
    public FilterRegistrationBean filter() {
    	FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new Filter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/url");
        
        // REQUEST DispatcherType만 로직이 적용되도록 설정
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);
        
        return filterRegistrationBean;
    }
}

Filter는 setDispatcherTypes() 메서드에 REQUEST의 DispatcherType를 설정하여
에러 요청의 경우 로직이 적용되지 않도록 설정할 수 있다.
기본값은 DispatcherType.REQUEST이다.

  • Interceptor

@Configuration
public class WebConfig implements WebMvcConfigurer {
	
    @Bean
    public void addInterceptors(InterceptorRegistry registry) {
    	registry.addInterceptor(new HandlerInterceptor())
        		.order(1)
                .addPathPatterns("/url")
                .excludePathPatterns("/error");
    }
}

Interceptor는 excludePathPatterns() 메서드 호출 시 error Url을 설정하여
예외 페이지를 요청할 때는 로직이 적용되지 않도록 설정할 수 있다.

API 예외 처리

예외 발생 시 예외 페이지가 아닌 API 형식으로 예외를 처리하려면 어떻게 해야 할까?
Spring이 자동으로 처리하는 BasicErrorController는
Client의 헤더가 Accept: text/html인 경우 예외 페이지를 반환하도록 동작한다.
이 외에는 내부적으로 ResponseEntity를 반환하는 메서드가 호출되어
messsage Body에 JSON 데이터를 반환하도록 설계되어 있다.

하지만 이 방식은 예외를 처리하지 못해서 밖으로 던져지면
HTTP Status를 500으로 처리하여 예외를 변환하지 못하고,
API 별로 다른 양식을 요구하는 경우 이에 맞게 처리해줄 수 없다.

API 예외를 처리하기 위해서는 무언가 새로운 방법이 필요해보인다.

HandlerExceptionResolver

public interface HandlerExceptionResolver {
	
    ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, 
    		Object handler, Exception ex);
}

HandlerExceptionResolver를 구현한 뒤,
Config 클래스에서 WebMvcConfigurer를 구현하고
extendHandlerExceptionResolvers() 메서드를 오버라이딩하여
HandlerExceptionResolver를 등록하면
예외 처리 흐름을 바꿀 수 있다.

ExceptionResolver는 여러개 등록할 수 있으며,
등록한 순서대로 동작한다.

@Configuration
class WebConfig implements WebMvcConfigurer {
	
    @Override
    public void extendHandlerExceptionResolvers(
    		List<HandlerExceptionResolver> resolvers) {
        
        resolvers.add(new HandlerExceptionResolver());
    }
}

예외 처리 흐름

// 기존
1. WAS → Filter → Servlet → Interceptor → Controller(예외 발생)
2. WAS(예외 조회) ← afterCompletion() ← Servlet ← 
			Interceptor postHandle() 호출 X ← Controller(예외 전달)


// ** HandlerExceptionResolver 추가 **
1. WAS → Filter → Servlet → Interceptor → Controller(예외 발생)
2. WAS(정상 응답 처리) ← afterCompletion() ← View Rendering ← 
	ExceptionResolver 예외 처리 ← Interceptor postHandle() 호출 X ← Controller(예외 전달)

ExceptionResolver를 구현하고 등록하면 예외 발생 시 예외를 처리할 수 있고
WAS에 예외 조회가 아닌, 정상 응답 처리를 전달할 수 있다.
그렇다면 ExceptionResolver에서 예외를 어떻게 처리할 수 있을까?

HandlerExceptionResolver resolveException()

resolveException() 메서드는 ModelAndView를 반환하고
이후에는 해당 View를 렌더링하는 과정을 거친다.
ModelAndView를 반환하는 방식에는 3가지가 있다.

  • 빈 ModelAndView 반환

빈 ModelAndView를 반환했으니 View를 렌더링 하지 않으면서
WAS에는 정상 응답 처리를 전달한다.
보통 response.sendError() 또는 API 응답을 처리한 후
아무런 조치를 취하지 않기 위해 빈 ModelAndView를 반환한다.
응답이 완료된 상태라면 afterCompletion() 메서드도 호출되지 않는다.

  • ModelAndView를 지정해서 반환

지정한 ModelAndView를 렌더링한다.

  • null 반환

다음 ExceptionResolver가 실행된다.
처리할 ExceptionResolver가 없다면 처리되지 않고 예외를 밖으로 던진다.
예외는 WAS에 전달되어 예외 페이지를 요청한다.

Spring ExceptionResolver

HandlerExceptionResolver를 구현하고 등록하여 사용하는 것은 쉬운 일이 아니다.
또 ModelAndView를 반환하면서 response에 직접 API 예외를 작성하는건 쉽지 않다.
Spring은 ExceptionResolver를 기본으로 제공하여 우리의 번거로움을 해결해준다.

ExceptionHandlerExceptionResolver

  • @ExceptionHandler

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(Exception.class)  // ** 애노테이션 예외 설정 생략 가능 **
public Object method(Exception e) {  // ** 파라미터에 처리할 예외 지정 **
}

컨트롤러 내부에 @ExceptionHandler 애노테이션을 선언하고
해당 예외가 발생하면 수행할 로직을 메서드로 작성한다.
메서드의 파라미터로 처리할 예외를 지정할 수 있다.
컨트롤러에서 지정한 예외 발생 시 해당 메서드를 호출한다.
자식 예외 메서드가 없으면 자식 예외 발생 시 부모 예외 메서드를 호출한다.

애노테이션에서 예외를 설정하는 부분은 생략할 수 있는데,
여러개의 예외를 처리하고 싶은 경우 애노테이션에서 배열의 형태로 설정한다.

메서드(또는 클래스)에 @ResponseBody 애노테이션을 선언한 후
JSON 형식으로 반환할 클래스를 설계하여 응답할 수도 있고
ModelAndView를 반환하여 HTML 응답에도 사용할 수 있다.

@ResponseStatus 애노테이션으로 응답코드를 설정할 수 있고
ResponseEntity를 반환하여 동적으로 응답코드를 설정할 수도 있다.


  • @ControllerAdvice, @RestControllerAdvice

// ** @RestControllerAdvice = @ResponseBody + @ControllerAdvice *
@RestControllerAdvice(annotations = Controller.class)
public class ControllerAdvice {
	
    @ExceptionHandler ..
}

@ExceptionHandler 애노테이션 메서드와 컨트롤러가 섞여있으면 복잡하다.
@RestControllerAdvice 또는 @ControllerAdvice 애노테이션을 클래스에 선언하여
@ExceptionHandler 애노테이션이 선언된 메서드를 별도로 관리할 수 있다.

ControllerAdvice에 ExceptionHandler를 적용할 컨트롤러를 지정하여 사용한다.
지정하지 않으면 모든 Controller에 적용된다.
HTML 통신 컨트롤러, API 통신 컨트롤러를 분리하는 것에 사용할 수 있다.

ResponseStatusExceptionResolver

  • @ResponseStatus

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error message")
public class CustomedException extends RuntimeException {
}

@ResponseStatus 애노테이션을 선언하여 상태코드를 지정하면
해당 예외가 밖으로 던져졌을 때 설정한 상태코드를 바탕으로 예외를 처리한다.
reason에 설정한 메시지도 함께 전달되는데
messages.properties의 메시지 기능을 사용할 수도 있다.

  • ResponseStatusException

public String method() {
	throw new ResponseStatusException(HttpStatus.BAD_REQUEST, 
    		"error message", new Exception());
}

@ResponseStatus는 개발자가 변경할 수 없는 애노테이션에는 적용할 수 없다.
또 이미 설정한 예외에 동적으로 상태코드를 변경할 수도 없다.
이 경우에는 애노테이션을 선언하는 것이 아닌,
ResponseStatusException을 throw하여 동적으로 설정할 수 있다.

DefaultHandlerExceptionResolver

Spring이 특정한 오류가 발생하면 적절하게 상태 코드를 변환하여 처리하는 방식이다.
예를 들어 파라미터 바인딩의 경우 보통 사용자의 실수가 많기 때문에
Spring에서 상태코드를 500에서 400으로 변경하여 처리한다.

마무리

Spring의 유효성 검사와 예외 처리에 대해 알아봤다.
두 큰 카테고리는 서로 다르게 동작하며 다른 개념이지만
일반 로직을 작성하면서 동시에 신경써야할 부분이기 때문에 한번에 정리했다.

유효성 검사를 위해 Bean Validator를 사용하고 global error는 별도로 작성한다는 점,
그리고 유효성 검사를 위해 DTO를 설계한다는 점을 명심하자.

웹 예외 처리는 HTML, API 두 가지 예외 처리가 있으며
HTML은 Spring이 자동으로 예외 페이지를 호출하므로 예외 페이지를 개발해야 한다는 점,
API 예외 처리는 @ExceptionHandler를 별도의 @RestControllerAdvice 클래스로 관리하여
적절하게 JSON 응답을 처리해야 한다는 것을 꼭 기억하자.

profile
복습에 대한 비판과 지적을 부탁드립니다

0개의 댓글