๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
BE/๐Ÿƒ Spring

[Spring Boot] Custom Validation annotation

by ํ‹ด๋”” 2024. 11. 10.
๋ฐ˜์‘ํ˜•

Intro

  • ์‚ฌ์šฉ์ž ํ˜น์€ ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ์š”์ฒญํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ฆํ•˜๋Š” ๊ฒƒ์€ ์ค‘์š”
  • Spring Boot์™€ ๊ฐ™์€ ํ”„๋ ˆ์ž„์›Œํฌ์—์„œ๋Š” @Valid, @Validated, validation ๊ด€๋ จ ์• ๋„ˆํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•ด์„œ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์ด ๊ฐ€๋Šฅํ•จ
  • ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด๋‚˜ ํŠน์ • ์š”๊ตฌ์‚ฌํ•ญ์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์ œ๊ณตํ•˜๋Š” ๊ฒ€์ฆ ์• ๋„ˆํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•˜๊ธฐ ์–ด๋ ค์šธ ์ˆ˜ ์žˆ์Œ
  • Spring Boot์—์„œ๋Š” ์‚ฌ์šฉ์ž ์ •์˜์˜ validation์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Œ

์˜ˆ์ œ์ฝ”๋“œ

@Getter
@ToString
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class RegistUserRequest {
    @Pattern(regexp = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.")
    private String email;

    @Pattern(regexp = "^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$", message = "์œ ํšจํ•œ ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.")
    private String phoneNumber;

    @Min(value = 19, message = "19์„ธ ์ด์ƒ ๊ฐ€์ž… ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.")
    private int age;

    @NotBlank
    private String name;

    @Pattern(regexp = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.")
    private String recommendedEmail;
}
  • ์œ„์™€ ๊ฐ™์€ ์š”์ฒญ ๋ฐ์ดํ„ฐ๋ฅผ ํ•„์š”๋กœ ํ•  ๋•Œ ๋งŒ์•ฝ recommendedEmail์ด ์„ ํƒ ๊ฐ€๋Šฅํ•œ ์˜ต์…˜ ๊ฐ’์ด๋ผ๊ณ  ํ•˜์ž
{
    "email" : "test@naver.com",
    "phone_number" : "010-2034-1122",
    "age" : 19,
    "name" : "test",
    "recommended_email" : ""
}

 

  • ์š”์ฒญ ๋ฐ์ดํ„ฐ์— recommended_email์„ ๋ณด๋‚ด์ง€ ์•Š๋Š”๋‹ค๋ฉด validation ์• ๋„ˆํ…Œ์ด์…˜์€ ๋ฐ์ดํ„ฐ์˜ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์„ ํ•˜์ง€ ์•Š์ง€๋งŒ, ํ•„๋“œ์™€ ๊ฐ’์„ ๋ณด๋‚ด๋Š” ๊ฒฝ์šฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์„ ์‹ค์‹œํ•œ๋‹ค
{
    "code": 40001,
    "msg": "๋ฐ์ดํ„ฐ ๊ฒ€์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.",
    "field_errors": [
        {
            "field": "recommendedEmail",
            "rejectedValue": "",
            "reason": "์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค."
        }
    ]
}
  • validation์— ์‹คํŒจํ•œ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ›์Œ
  • ์˜ต์…˜์ธ ๊ฒฝ์šฐ ๊ฐ’์ด ์žˆ์„ ๋•Œ(๊ฐ’์ด ์—†๋Š”๊ฑด null, "", " "๋กœ ์ •์˜)๋งŒ ๊ฒ€์ฆํ•˜๊ณ  ์‹ถ์„๋•Œ ๊ธฐ์กด validation์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Œ
  • ๋˜ํ•œ email, recommendedEmail๊ณผ ๊ฐ™์ด ์ด๋ฉ”์ผ์€ ๋‹ค๋ฅธ ์š”์ฒญ์—๋„ ๋ฐ˜๋ณต์ ์œผ๋กœ ์‚ฌ์šฉ๋  ์ˆ˜ ์žˆ๋Š”๋ฐ ์ด๋•Œ ์ด๋ฉ”์ผ ๊ธธ์ด ์ œํ•œ์— ๋Œ€ํ•œ ์š”๊ตฌ์‚ฌํ•ญ์ด ๋ณ€๊ฒฝ๋˜๋Š” ๊ฒฝ์šฐ ์ผ์ผ์ด ๋ชจ๋“  ํ•„๋“œ๋ฅผ ํ™•์ธํ•˜๋ฉฐ ์ •๊ทœ์‹์„ ์ˆ˜์ •ํ•ด ์ค˜์•ผํ•จ
  • ๊ธฐ์กด validation์˜ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด custom validation annotation์„ ๋งŒ๋“ค์–ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค

(validation ์ˆ˜ํ–‰ ํ›„ ๋ฐœ์ƒํ•˜๋Š” exception์„ ๊ณตํ†ต response๋กœ ์ •์˜ํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ์ด์ „ ๊ธ€ ์ฐธ๊ณ  : 2024.11.09 - [BE/๐Ÿƒ Spring] - [Spring Boot] RestControllerAdvice๋กœ Validation Exception ํ•ธ๋“ค๋งํ•˜๊ธฐ)

 

๊ธฐ๋Œ€ํšจ๊ณผ

  • ๊ฐ€๋…์„ฑ ํ–ฅ์ƒ
  • ์ค‘๋ณต๋˜๋Š” ์ฝ”๋“œ ์ตœ์†Œํ™”
  • ๊ธฐ์กด validtion์—์„œ ๊ตฌํ˜„ํ•˜๊ธฐ ํž˜๋“  ๊ธฐ๋Šฅ ๊ตฌํ˜„

 

Custom annotation

@Constraint(validatedBy = RestrictionValidator.class)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Restriction {
    boolean required() default false;
    int min() default Integer.MIN_VALUE;
    int max() default Integer.MAX_VALUE;
    PatternType patternType() default PatternType.NONE;
    String message() default "์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฐ’์ž…๋‹ˆ๋‹ค.";

    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    enum PatternType {
        NONE,
        PHONE,
        EMAIL
    }
}

 

  • ์• ๋„ˆํ…Œ์ด์…˜์—์„œ ์‚ฌ์šฉํ•  ์˜ต์…˜ required, min, max, patternType, message๋ฅผ ์ถ”๊ฐ€
  • ์ž์ฃผ ์‚ฌ์šฉ๋˜๋Š” ํŒจํ„ด์€ PatternType์œผ๋กœ ์ •์˜

ConstraintValidator

public class RestrictionValidator implements ConstraintValidator<Restriction, Object> {

    private boolean required;
    private int min;
    private int max;
    private Restriction.PatternType patternType;
    
    private static final Pattern PHONE_PATTERN = Pattern.compile("^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$");
    private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$");

    @Override
    public void initialize(Restriction constraintAnnotation) {
        this.required = constraintAnnotation.required();
        this.min = constraintAnnotation.min();
        this.max = constraintAnnotation.max();
        this.patternType = constraintAnnotation.patternType();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (required && value == null) {
            return false;
        }

        if (value instanceof String stringValue) {
            if (required && stringValue.trim().isEmpty()) {
                return false;
            }

            if (!required && stringValue.trim().isEmpty()) {
                return true;
            }

            if (stringValue.length() < min || stringValue.length() > max) {
                return false;
            }

            if (patternType == Restriction.PatternType.PHONE && !PHONE_PATTERN.matcher(stringValue).matches()) {
                return false;
            }

            if (patternType == Restriction.PatternType.EMAIL && !EMAIL_PATTERN.matcher(stringValue).matches()) {
                return false;
            }

        } else if (value instanceof Integer intValue) {
            if (intValue < min || intValue > max) {
                return false;
            }
        }

        return true;
    }
}

 

  • isValid์—์„œ false๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฉด MethodArgumentNotValidException์ด ๋ฐœ์ƒํ•จ
  • ConstraintValidator<Restirction, Object>๋Š” ๊ฐ๊ฐ custom annotation, ๊ฒ€์ฆํ•  ๊ฐ์ฒด์˜ ํƒ€์ž…
  • ๊ฒ€์ฆํ•  ๊ฐ์ฒด๋Š” int, String ๋“ฑ ๋‹ค์–‘ํ•˜๋ฏ€๋กœ Object๋กœ ์ง€์ •

์ ์šฉ

@Getter
@ToString
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class RegistUserRequest {
    @Restriction(required = true, patternType = Restriction.PatternType.EMAIL, message = "์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.")
    private String email;

    @Restriction(required = true, patternType = Restriction.PatternType.PHONE, message = "์œ ํšจํ•œ ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.")
    private String phoneNumber;

    @Restriction(required = true, min = 19, message = "19์„ธ ์ด์ƒ ๋ถ€ํ„ฐ ๊ฐ€์ž… ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.")
    private int age;

    @Restriction(required = true, message = "์ด๋ฆ„์€ ํ•„์ˆ˜ ๊ฐ’์ž…๋‹ˆ๋‹ค.")
    private String name;

    @Restriction(required = false, patternType = Restriction.PatternType.EMAIL, message = "์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.")
    private String recommendedEmail;
}

 

  • ๊ธฐ์กด Validation ๋กœ์ง๋“ค์„ ์ œ๊ฑฐํ•˜๊ณ  Restriction ์ถ”๊ฐ€

 

ํ…Œ์ŠคํŠธ

{
    "email" : "test@naver.com",
    "phone_number" : "010-2034-1122",
    "age" : 12,
    "name" : "test",
    "recommended_email" : ""
}

 

//    @Restriction(required = true, min = 19, message = "19์„ธ ์ด์ƒ ๋ถ€ํ„ฐ ๊ฐ€์ž… ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.")
//    private int age;

{
    "code": 40001,
    "msg": "๋ฐ์ดํ„ฐ ๊ฒ€์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.",
    "field_errors": [
        {
            "field": "age",
            "rejectedValue": "12",
            "reason": "19์„ธ ์ด์ƒ ๋ถ€ํ„ฐ ๊ฐ€์ž… ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค."
        }
    ]
}

 

  • request์—์„œ ์œ ํšจํ•˜์ง€ ์•Š์€ age๋ฅผ ๋ณด๋‚ด๋ฉด, ๊ฒ€์ฆ์— ์‹คํŒจํ–ˆ๋‹ค๋Š” ๊ฒฐ๊ณผ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์Œ

    @Restriction(required = false, patternType = Restriction.PatternType.EMAIL, message = "์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.")
    private String recommendedEmail;
  • recommended_email์˜ ๊ฒฝ์šฐ ์˜ต์…˜ ๊ฐ’์ด๊ธฐ ๋•Œ๋ฌธ์— ๊ฐ’์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ด๋ฉ”์ผ ํŒจํ„ด ๊ฒ€์ฆ
// ํ…Œ์ŠคํŠธ 1
{
    "email" : "test@naver.com",
    "phone_number" : "010-2034-1122",
    "age" : 19,
    "name" : "test",
    "recommended_email" : ""
}

// ํ…Œ์ŠคํŠธ 2
{
    "email" : "test@naver.com",
    "phone_number" : "010-2034-1122",
    "age" : 19,
    "name" : "test",
    "recommended_email" : "test"
}

 

  • ์œ„์™€ ๊ฐ™์ด request๋ฅผ ๋ณด๋‚ธ ๊ฒฝ์šฐ
// test1
RegistUserRequest(email=test@naver.com, phoneNumber=010-2034-1122, age=19, name=test, recommendedEmail=)

// test2
{
    "code": 40001,
    "msg": "๋ฐ์ดํ„ฐ ๊ฒ€์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.",
    "field_errors": [
        {
            "field": "recommendedEmail",
            "rejectedValue": "test",
            "reason": "์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค."
        }
    ]
}

 

  • test1์—์„œ๋Š” recommendedEmail์˜ ๊ฐ’์ด ์—†์–ด ๊ฒ€์ฆ์„ ํ•˜์ง€ ์•Š์•˜๊ณ , ์˜๋„ํ•œ response๊ฐ’์„ ๋ฆฌํ„ด
  • test2์—์„œ๋Š” recommendedEmail์˜ ๊ฐ’์ด ์žˆ์œผ๋ฏ€๋กœ ์ด๋ฉ”์ผ ํŒจํ„ด์„ ๊ฒ€์ฆํ•˜๊ณ  ์—๋Ÿฌ ๋ฉ”์„ธ์ง€๋ฅผ request๋ฅผ ์š”์ฒญํ•œ ํ”„๋ก ํŠธ์—๊ฒŒ ์ „๋‹ฌ

 

 

728x90
๋ฐ˜์‘ํ˜•

๋Œ“๊ธ€