2024. 4. 1. 15:05ㆍSpring/Spring 웹 MVC
[ HTTP 요청과 검증 처리 ]
HTTP로 요청이 날아오면 컨트롤러에서는 보통 쿼리 파라미터, 바디, 헤더, 쿠키의 4가지 값들을 처리한다. 이게 가능한 이유는 Dispatcher Servlet 때문이며 그 안에는 HTTP 메시지 컨버터와 요청 매핑 핸들러 어댑터로 애노테이션에 따라 적절한 컨버터와 핸들러를 매칭하기 때문이다. 그래서 컨트롤러에서는 딱히 파라미터의 순서, 파라미터 종류에 관해서 웬만한 게 다 있다.
예를 들어, 가게의 이름과 가격을 통해 검색을 하는 상황이 있다고 해보자. 이 때, 쿼리 파라미터 값이 들어오지 않거나, 쿼리 파라미터 값이 int 타입인데 값이 범위를 지나거나 하는 경우, 예외를 처리해주어야 한다. 이 예외처리는 어디에서 해줘야할까? 아마 검증에 관해서 모른다면 다음처럼 예외 검사를 할 수도 있다.
@RestController
public class ValidationController {
@GetMapping("/validation")
public String validation( @RequestParam("shopName") String shopName,
@RequestParam("price") int price){
if(shopName == null){
throw new IllegalArgumentException("가게 이름은 필수입니다");
}
if(price <= 0){
throw new IllegalArgumentException("가격은 양수 값이어야 합니다.");
}
return "Validation is complete";
}
}
위처럼 작성을 해도 사실 간단한 경우에는 문제가 없다. 하지만 여러 상황을 생각해보자. 만약에 컨트롤러 상에서 처리해야 할 비즈니스도 복잡한데, 값까지 겹쳐있으면 컨트롤러 로직이 이해가 안될 수 있다. 그리고 저런 검증 처리는 애초에 파라미터로 들어오기 전에 검사를 하고 아닌 경우 컨트롤러 코드를 아무것도 실행 안하는 게 좋다. 왜냐하면 최적화를 위해 길게 커넥션을 안 물고 있으려고 컨트롤러 계층에서의 JPA 트랜잭션 유지를 꺼버릴 수도 있기 때문이다. 거기서 서비스 리포지토리 코드와 컨트롤러 계층의 코드가 섞여있다고 생각해보자. 뭔가를 저장하거나 변경했는데 이후 로직이 예외랑 섞여서 안돌아가면? 어디서 장애가 일어났는지 알기가 어렵다.
[ 예외 검증 한 곳에 모으기 ]
따라서 API 별로 각각 예외처리를 한 곳에서 모아서 정리를 할 수 있게 도와주는 의존성이 있다. 아래의 의존성을 build.gradle에 추가해주면 된다.
// 검증 관련 의존성
implementation 'org.springframework.boot:spring-boot-starter-validation'
스프링 부트 2 버전 대에서는 그럼 javax.validation이라는 라이브러리가 스프링 부트 3 버전 대에서는 jakarta.validation이라는 라이브러리가 추가되어 있을 것이다. 그게 확인이 되었다면 코드를 작성해보자. 위에서 에러 처리한 부분은 다음처럼 코드를 바꿔 줄 수 있다. 우선 request로 들어오는 값들을 모으기 위해 Data Transform Object를 하나 만들어주자. 내부를 보면 어렵지 않기 때문에 넘어갈 수 있을 것 같다. 추가적으로 얘기를 하면, 애노테이션에는 없는 상황이지만 필요한 검증의 경우 맨 아래 @AssertTrue처럼 작성해주면 문제를 해결할 수 있다.
@Data @Getter
@ToString
public class ValidationRequest {
@NotBlank(message = "가게 이름은 필수입니다.")
String shopName;
@Range(min = 0, max = 20000, message = "가격 최소값은 양수 값이어야 합니다.")
int priceMin;
@Range(min = 0, max = 20000, message = "가격 최대값은 20000원 미만이어야 합니다.")
int priceMax;
@AssertTrue(message = "최소값은 최대값을 넘을 수 없습니다.")
public boolean isValidPrice(){
return priceMin < priceMax;
}
}
이게 잘 동작하는지 확인하기 위해서는 컨트롤러 코드를 아래처럼 작성하면 된다. 포스트맨 등으로 아래의 코드를 실행해보면 알겠지만, 사용자가 int에 string 값을 넣어놓는다던지, 설정한 예외 상황에 걸리면 에러가 발생하고 400번 에러를 내보낸다.
그런데 아래 컨트롤러 동작을 확인하게 놔둔 출력문이 작동하지 않는 것을 알 수 있다. 그냥 단순 조회용이라 모든 요청에서 발생한 에러를 똑같이 400번으로 내보내도 되는 API라면 이렇게 두어도 괜찮을 것이다. 그런데 회원가입, 설문 같은 API에서는 어떤 필드에 어떤 오류가 있는지 내보내 줄 필요가 있다. 예를 들면 비밀번호가 짧다 같은 에러 말이다.
@RestController
public class ValidationController {
@GetMapping("/validation")
public String validation(@Valid @ModelAttribute validationRequest request){
System.out.println("컨트롤러가 동작했습니다.");
return "Validation is complete";
}
}
이럴 때는 BindingResult라는 객체를 사용하면 된다. 컨트롤러의 파라미터에 BindingResult 객체를 놓으면 Validation으로 검증한 모든 에러들이 담긴 채로 컨트롤러까지 넘어오게 된다. 저 문제를 해결하고 싶은 경우라면 문제가 있는 것은 맞으니 400번대 에러를 내보내되, 각각의 비즈니스 요구사항에 따라 따로 오류 응답용 객체를 만들어서 담아서 내보내주면 된다.
아래의 코드는 BindingResult를 이용해서 내부에서 에러를 꺼내와서 내가 설정한 에러인지 아니면 변환과정에서 타입 오류인지를 확인할 수 있게 해준다. 그리고 메세지를 확인하게 해주는데, 이 메세지 값을 활용해서 오류 응답용 객체 값들을 채워주면 될 것 같다.
@RestController
public class ValidationController {
@GetMapping("/validation")
public String validation(@Valid @ModelAttribute validationRequest request, BindingResult bindingResult){
System.out.println("컨트롤러가 동작했습니다.");
List<ObjectError> errors = bindingResult.getAllErrors();
errors.stream().forEach(error -> {
System.out.println("error.getObjectName() = " + error.getObjectName());
System.out.println("error.getDefaultMessage() = " + error.getDefaultMessage());
});
return "Validation is complete";
}
}
[ 검증을 더 잘 쓰는 방법 ]
@RequestBody와 @ModelAttribute의 차이점 알기
둘의 차이는 간단하다. @RequestBody는 바디에 있는 값을 가져오는 방법이고 @ModelAttribute는 쿼리 파라미터 값을 가져오는 방법이다. 그런데 예외에서는 조금 결과가 달라진다. 아래의 코드를 보자. 둘 다 같은데, 아마 요청 값을 제대로 된 값 형식으로 넣어줬으면 아무런 문제가 없었을 것이다.
@GetMapping("/validation")
public String validation(@Valid @ModelAttribute ValidationRequest request, BindingResult bindingResult){
System.out.println("Hello Validation");
return "Validation is complete";
}
@PostMapping("/validation")
public String validationPost(@Valid @RequestBody ValidationRequest request, BindingResult bindingResult){
System.out.println("Hello Validation");
return "validation is complete";
}
그런데 타입이 다르다면? int로 들어가야 하는 priceMin에 "aaa" 같은 값이 들어갔을 때 둘은 어떤 차이를 보일까? 이 때는 둘이 다른 결과를 내보낸다.
@ModelAttribute는 객체로 파라미터 값을 받는 것처럼 보이지만 필드 단위로 적용이 된다. 그래서 특정 필드에 타입에 맞지 않는 오류가 발생해도 예외가 발생하지만 HttpMessageConverter 작동에 문제가 없다. 특정 필드가 바인딩 되지 않아도 나머지 필드들은 정상적으로 바인딩이 돼서 컨트롤러가 호출된다.
하지만 @RequestBody는 전체 객체 단위로 적용이 돼서 한 필드라도 타입이 잘못된 경우에는 HttpMessageConverter 작동이 실패한다. 그래 검증 로직이 작동하기 전에 예외를 발생시키고 컨트롤러 호출이 없이 끝난다. 이 점을 꼭 기억했으면 한다.
다른 계층과 분리해서 사용하기
검증 코드를 위의 방식대로 사용하다보면 컨트롤러 내부에서의 검증 로직은 잘 분리를 했는데, 리포지토리와 검증로직을 결합시켜버리는 경우가 있다. 아래의 코드처럼 작성해버리면 생기는 문제는 스프링은 뷰랑 도메인을 잘 분리 시켜 작성하라는 의도로 컨트롤러와 도메인으로 분리해놨는데, 그걸 도메인이 뷰에 종속적으로 가져다가 붙여버리는 것이다.
@Entity
@NoArgsConstructor @Getter
public class Validation {
@Id @GeneratedValue
private Long id;
@NotBlank(message = "가게 이름은 필수입니다.")
String shopName;
@Range(min = 0, max = 20000, message = "가격 최소값은 양수 값이어야 합니다.")
int priceMin;
@Range(min = 0, max = 20000, message = "가격 최대값은 20000원 미만이어야 합니다.")
int priceMax;
@AssertTrue(message = "최소값은 최대값을 넘을 수 없습니다.")
public boolean isValidPrice(){
return priceMin < priceMax;
}
}
이렇게 작성을 해버리면 해당 객체를 쓰는 모든 요청에 위 검증 로직이 수행되며 API가 많아지고 검증로직이 추가될 때마다 예상하지 않은 결과가 나오고 뭔가 이상함을 느낄 것이다. 또한 위의 방식이 잘못된 건 컨트롤러의 응답 결과가 엔티티에 종속적이게 된다. 누가와서 "필드 하나 늘려주세요. 아 이거 필드 이름이 이상한데 바꾸죠." 이러는 순간 API가 변하게 된다. 그러면 엔티티를 바꿨는데 API 결과를 바뀌어서 갑자기 잘되던 API가 안 돌아가는 문제가 발생한다.
따라서 절대로 엔티티 객체에 검증로직을 가져다가 쓰면 안된다. 반드시 API 별로 DTO 객체를 따로 만들어서 해당 API에 맞게 검증 절차를 수행해주어야 한다. 그래서 처음 검증 관련 로직이 들어간 객체의 이름이 validationRequest였던 것이다.
또한 엔티티에 검증을 결합시켜버리는 것도 문제지만 서비스 계층이랑 API를 결합시켜버리는 문제도 있다. validationRequest를 서비스가 알게 되어버리는 아래의 코드는 이후 다른 API에서 해당 서비스 로직이 필요해 호출할 때 문제가 발생한다. 또한 컨트롤러에 사용된 특정 기술과 서비스가 결합해버리는 문제가 발생할 수 있다.
public class ValidationService {
public void service(ValidationRequest request){
System.out.println("request = " + request);
}
}
따라서 반드시 컨트롤러에서 사용하는 요청 dto 클래스는 서비스에서 알면 안된다. 그럼 어떻게 하라는 거냐는 물음이 있을 수 있다. 그 땐 Builder와 정적 팩토리 메서드를 이용해서 해결해주면 된다.
그래서 ValidationRequest를 다음처럼 수정했다.
@Data @Getter
@ToString
public class ValidationRequest {
@NotBlank(message = "가게 이름은 필수입니다.")
String shopName;
@Range(min = 0, max = 20000, message = "가격 최소값은 양수 값이어야 합니다.")
int priceMin;
@Range(min = 0, max = 20000, message = "가격 최대값은 20000원 미만이어야 합니다.")
int priceMax;
@AssertTrue(message = "최소값은 최대값을 넘을 수 없습니다.")
public boolean isValidPrice(){
return priceMin < priceMax;
}
public ValidationServiceRequest createRequest(){
return ValidationServiceRequest.builder()
.shopName(shopName)
.priceMin(priceMin)
.priceMax(priceMax)
.build();
}
}
내부에서 정적 팩토리 메서드로 만들어지는 녀석은 ValidationServiceRequest로 서비스 계층에서 사용되는 모든 값을 가진 객체이다.
@Data
@ToString @Getter
public class ValidationServiceRequest {
String shopName;
int priceMin;
int priceMax;
@Builder
public ValidationServiceRequest(String shopName, int priceMin, int priceMax) {
this.shopName = shopName;
this.priceMin = priceMin;
this.priceMax = priceMax;
}
}
그리고 서비스를 아래처럼 만든다. 그냥 테스트용이라 컴포넌트 스캔 대상에 넣지 않았다.
public class ValidationService {
public void service(ValidationServiceRequest request){
System.out.println("request = " + request);
}
}
마지막으로 컨트롤러에서 서비스에 값을 넘길 때 createRequest() 메서드를 이용해서 적절한 타입에 맞춰 주면 서비스는 컨트롤러를 모른 채로 사용이 가능하다.
@GetMapping("/validation")
public String validation(@Valid @ModelAttribute ValidationRequest request){
ValidationService validationService = new ValidationService();
validationService.service(request.createRequest());
return "Validation is complete";
}
이번 편에서는 검증을 알아보았다. 검증편에서 해결하고자 하는 문제는 필터를 지난 이후 쿼리 파라미터, 바디, 헤더, 쿠키 등을 검증하는 로직을 컨트롤러 메서드 내부와 분리하는 것이었다. 그렇게 하면 검증을 더 쉽고 강력하게 작성할 수 있었다. 요구사항에 따라 검증 과정에서의 에러를 모아서 내보내고 싶을 때 그냥 @Valid를 사용하면 컨트롤러 코드가 실행이 되지 않는다고 했었다. 이 때는 BindingResult라는 객체에 모든 에러가 들어가 있으니 해당 내용을 사용해서 에러 메시지용 객체를 채워 해결할 수 있다는 이야기를 했다. BindingResult를 쓸 때에 조심해야 할 점에 관해서도 이야기를 하며 @ModelAttribute와 @RequestBody의 차이도 이야기했다. 마지막으로는 컨트롤러 메서드에서 따로 검증을 분리해서 리포지토리나 서비스 계층에 결합시켜버리는 오류를 범하는 문제에 관해 알아보았다. 각각의 상황에서 발생할 수 있는 문제와 해결 방안에 대해서 이야기를 했었는데 이 부분을 잘 기억했으면 좋겠다.
[ 글 정정 내용 ]
테스트 코드 작성을 하다가 발견한 문제점인데, 아래의 priceMin과 priceMax 중 하나가 파라미터로 들어오지 않는 경우에 아래의 isValidPrice에서 오류가 발생할 수 있다.
@Range(min = 0, max = 20000, message = "가격 최소값은 양수 값이어야 합니다.")
int priceMin;
@Range(min = 0, max = 20000, message = "가격 최대값은 20000원 미만이어야 합니다.")
int priceMax;
@AssertTrue(message = "최소값은 최대값을 넘을 수 없습니다.")
public boolean isValidPrice(){
return priceMin < priceMax;
}
값을 반드시 받아야 하고 값이 모두 있는 경우에만 isValidPrice 조건을 확인하고 싶다면 아래처럼 바꾸어야 한다.
@NotNull(message = "최소값 설정은 필수입니다.")
@Range(min = 0, max = 20000, message = "최소값은 0-20000까지 설정이 가능합니다.")
private Integer priceMin;
@NotNull(message = "최대값 설정은 필수입니다.")
@Range(min = 0, max = 20000, message = "최대값은 0-20000까지 설정이 가능합니다.")
private Integer priceMax;
@AssertTrue( message = "최소값은 최대값을 넘을 수 없습니다.")
public boolean isValidPrice(){
if(priceMin != null && priceMax != null)
return priceMin < priceMax;
return false;
}
[ 참고 자료 ]
https://docs.spring.io/spring-framework/docs/4.1.x/spring-framework-reference/html/validation.html
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
'Spring > Spring 웹 MVC' 카테고리의 다른 글
스프링 웹 MVC 4. 컨트롤러 예외 처리 (0) | 2024.04.02 |
---|---|
스프링 웹 MVC 2. 필터 (0) | 2024.03.31 |
스프링 웹 MVC 1. 컨트롤러의 역할 분리를 위해 알아야 하는 것들 (0) | 2024.03.31 |
스프링 웹 MVC 2 편 - 스프링 메시지 (0) | 2024.02.08 |
스프링 웹 MVC 1편 - HTTP 메시지 컨버터, 요청 매핑 핸들러 어댑터 (0) | 2024.02.06 |