back end/java

Java Bean Validation 사용방법 - ControllerAdvice

노루아부지 2022. 10. 3. 00:19
반응형

개요

Validation은 개발 실무에서 가장 중요한 것 중 하나입니다.

클라이언트에서 잘못된 값이 전달되어 장애가 발생하는 것은 흔한 일입니다. Validation을 사전에 하지 않는다면 잘못된 인자 값으로 작업을 수행하다 오류가 발생할 수도 있고 심지어 오류가 발생하지 않고 잘못된 값이 데이터베이스에 저장되기도 합니다.

 

따라서 서버 API에서 parameter를 받자마자 validation을 해야 하지만 이것을 일일히 코딩하기에는 번거롭습니다. Java에서는 Jakarta Bean Validation을 이용할 수 있는데요. Annotation을 이용하여 Validation을 처리할 수 있는 방법입니다.

 

 

Dependency

implementation 'org.hibernate.validator:hibernate-validator'

 

 

Validation Annotation 사용 방법

UserParam이라는 클래스가 있고 Validation Annotation을 아래와 같이 설정할 수 있습니다.

자세한 내용은 Built-in Constraint definitions에서 확인할 수 있습니다.

import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Getter
@Setter
public class UserParam {
  @Size(min = 4, max = 100)
  private String userId;

  @NotNull
  private String userName;

  @Min(0)
  @Max(100)
  private int age;
}

 

이제 위의 예제가 정상적으로 동작하는지 확인할 차례입니다.

다음과 같이 컨트롤러를 생성합니다.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
public class TestController {
  @GetMapping("/test")
  public String test(@Valid UserParam param) {
    return "test";
  }
}

 

Validation이 목적이기 때문에 method 내의 내용은 없습니다.

이 상태에서 http://localhost:8080/test 를 호출하면 결과는 다음과 같습니다.

trace는 너무 길어서 생략했지만 내용을 살펴보면

400 Bad Request 상태가 리턴된 것을 알 수 있고

userName에 @NotNull Annotation을 사용했기 때문에 userName이 Null이어서 오류가 return 된 것을 확인할 수 있습니다.

{
    "timestamp": "2022-10-02T14:30:36.353+00:00",
    "status": 400,
    "error": "Bad Request",
    "trace": "너무 길어서 생략",
    "message": "Validation failed for object='userParam'. Error count: 1",
    "errors": [
        {
            "codes": [
                "NotNull.userParam.userName",
                "NotNull.userName",
                "NotNull.java.lang.String",
                "NotNull"
            ],
            "arguments": [
                {
                    "codes": [
                        "userParam.userName",
                        "userName"
                    ],
                    "arguments": null,
                    "defaultMessage": "userName",
                    "code": "userName"
                }
            ],
            "defaultMessage": "널이어서는 안됩니다",
            "objectName": "userParam",
            "field": "userName",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        }
    ],
    "path": "/test"
}

 

 

이 상황에서 userName에 값을 넣고 다음과 같이 호출합니다.

http://localhost:8080/test?userName=hong

 

userId나 age에도 Annotation을 선언했기 때문에 오류가 발생할 것 같지만 오류가 발생하지 않습니다.

그 이유는 @Max, @Min, @Size와 같은 Annotation은 null check는 하지 않기 때문입니다.

따라서 userId의 @Size가 정상 동작하는지 확인하기 위해서는 다음과 같이 호출하면 size로 인해 오류가 발생하는 것을 확인할 수 있습니다.

http://localhost:8080/test?userName=hong&userId=hon

 

 

 

Message 변경

@NotNull의 경우 아래 이미지와 같이 javax.validation.constraints.NotNull.message 를 default 값으로 사용합니다.

@NotNull message

 

이 메시지를 변경하고 싶다면 다음과 같이 @NotNull에 직접 메시지를 설정할 수 있습니다.

@NotNull(message = "not null test")
private String userName;

 

별도의 설정 파일로 만들어 공통으로 관리하고 싶다면 classpath에 ValidationMessages.properties 파일을 추가하면 됩니다.

만약 다국어를 지원하고 싶다면 파일명에 locale정보를 추가(ex. ValidationMessages_ko.properties)하면 됩니다.

test.notnull.message=plz not null

 

그다음 annotation을 다음과 같이 변경합니다.

@NotNull(message = "{test.notnull.message}")
private String userName;

 

이제 message가 변경된 것을 확인할 수 있습니다.

 

 

 

@ControllerAdvice

위의 에러 메시지에서 너무 길어서 생략했지만 trace에는 엄청나게 긴 에러 메시지가 표시됩니다. 심지어 그냥 에러 메시지가 아니라 코드를 추적 가능한 trace가 표시됩니다. 바로 이렇게 말이죠.

 

 

이런 에러 메시지가 리턴되면 몇 가지 문제가 있습니다.

 

  • 너무 긴 에러 메시지는 traffic의 과부화를 유발할 수 있습니다.
  • trace의 노출은 보안적으로 좋지 않습니다. 실제로 보안 취약점 검사를 하면 이 부분에서 통과하지 못합니다.

 

Controller에서 직접 @ExceptionHandler를 사용해도 되지만 Controller가 여러 개일 경우 @ControllerAdvice로 공통으로 처리할 수 있습니다.

 

 

 

1단계

먼저 공통으로 response를 처리하기 위한 CommonResponse 클래스를 생성합니다.

이 클래스는 생성자에 인자를 받으면 결과가 true이고 인자로 에러 메시지를 보낼 경우 결과는 false입니다.

package com.example.demo;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CommonResponse {
  private boolean result;
  private String message;

  public CommonResponse() {
    this.result = true;
  }

  public CommonResponse(String message) {
    this.result = false;
    this.message = message;
  }
}

 

 

그다음 ControllerAdvisor 클래스를 생성합니다.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;


@ControllerAdvice
public class ControllerAdvisor {
  @ExceptionHandler(BindException.class)
  public ResponseEntity<CommonResponse> bindException(BindException e) {
    return new ResponseEntity<>(new CommonResponse("bind error"), HttpStatus.OK);
  }
}

 

여기까지 진행한 다음 URL을 다시 호출하면 다음과 같은 결과가 리턴됩니다.

{
  "result": false,
  "message": "bind error"
}

 

문제가 되는 trace가 없어진 것을 확인할 수 있습니다.

 

 

 

2단계

실무에서는 에러 메시지를 반환하기보다는 주로 에러코드를 반환합니다.

이를 위해서 먼저 enum을 생성합니다.

여기에서 message는 선언만 하고 사용하지 않습니다.

public enum ResultCode {
  SUCCESS("0000", "success"),
  BIND_EXCEPTION("0400", "bind exception"),
  SYSTEM_ERROR("9999", "system error");

  String code;
  String message;

  ResultCode(final String code, final String message) {
    this.code = code;
    this.message = message;
  }
}

 

그다음 CommonResponse를 변경합니다.

package com.example.demo;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CommonResponse {
  private String code;

  public CommonResponse() {
    this.code = ResultCode.SUCCESS.code;
  }

  public CommonResponse(ResultCode resultCode) {
    this.code = resultCode.code;
  }
}

 

 

 

그 다음 ControllerAdvisor를 변경합니다.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;


@ControllerAdvice
public class ControllerAdvisor {
  @ExceptionHandler(BindException.class)
  public ResponseEntity<CommonResponse> bindException(BindException e) {
    return new ResponseEntity<>(new CommonResponse(ResultCode.BIND_EXCEPTION),
            HttpStatus.BAD_REQUEST);
  }
}

 

여기까지 하고 다시 확인해보면 다음과 같습니다.

{
  "code": "0400",
}

 

코드가 에러 메시지를 포함하고 있기 때문에 실무에서는 메시지를 반환하지 않는 경우가 많습니다.

만약 필요하다면 CommonResponse의 수정을 통해 리턴 메시지에 message도 포함시킬 수 있습니다.

 

전체 소스는 아래에서 확인할 수 있습니다.

https://github.com/youn-jeong-hoon/ControllerAdviceExample.git

728x90
반응형
loading