티스토리 뷰

728x90
반응형

 

 

 

지난 글까지 구조를 잡고 구현한 컨트롤러 클래스와 핸들러 메서드에 DTO를 적용했다.

 

이번 글에는 적용된 DTO 클래스의 데이터 유효성 검증 적용에 대해 알아보자.

 

 

유효성 검증(Validation)

 

유효성 검증이란 말 그대로 입력된 데이터가 정해진 규칙을 만족하는지 확인하는 것이다.

 

일반적으로 프론트엔드에선 자바스크립트를 이용해 사용자의 입력값에 대해 1차적으로 유효성 검증을 진행한다.

 

위 그림은 회원 가입 필드 중에서 휴대폰, 이메일 입력 값에 대한 유효성 검사를 진행하는 모습이다.

 

위처럼 잘못된 형식의 데이터를 입력하면 프론트엔드에서 먼저 유효성 검사를 진행한 후에 결과를 표시하게 된다.

 

이와 같이 프론트엔드에서의 유효성 검증사용자 편의성 측면에서 필요한 작업이라고 할 수 있다.

 

하지만 자바스크립트로 전송되는 데이터는 브라우저의 개발자 도구를 사용해서 얼마든지 그 값을 조작할 수 있기 때문에

 

백엔드 쪽에서도 반드시 추가적인 유효성 검증을 진행해야 한다.

 

 

DTO 클래스에 유효성 검증 적용

 

계속해서 지난 글에서 작성한 MemberPostDto, MemberPatchDto 클래스에 유효성 검증을 적용하는 과정을 살펴보자.

 

  • Jakarta Bean Validation

시작하기 전에, DTO 클래스에 유효성 검증을 적용하려면 Spring Boot에서 지원하는 Starter가 필요하다.

 

build.gradle에 다음과 같은 의존성을 추가한다.

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

위와 같은 의존성은 jakarta.validation:jakarta.validation-api gradle을 라이브러리에 추가해 주는데,

 

이 안에 이후로 사용할 애너테이션들이 정의되어 있다.

 

Jakarta Bean Validation애너테이션 기반 유효성 검증을 위한 표준 스펙이다.

 

Java Bean 스펙을 준수하는 Java 클래스라면 이곳의 애너테이션을 사용해서 유효성 검증을 할 수 있으며,

 

이를 이용하면 Controller 로직에서 유효성 검증 로직을 쉽게 분리할 수 있다.

 

또한 Jakarta Bean Validation은 Hibernate Validator와 같은 많은 구현체가 존재한다.

 

계속해서 두 개의 클래스에 하나씩 유효성 검증을 적용해 보자.

 

  • MemberPostDto 클래스에 유효성 검증 적용

먼저 유효성 검증이 적용되기 전의 MemberPostDto 클래스를 다시 불러오자.

package com.gnidinger.member;

public class MemberPostDto {
    private String email;
    private String name;
    private String phone;

    public String getEmail() {
        return email;
    }

    public String getName() {
        return name;
    }

    public String getPhone() {
        return phone;
    }
}

이어서 MemberPostDto 클래스에 적용할 유효성 제약 사항에 대해 생각해보자. 대략 아래와 같을 것이다.

 

  • email (이메일 주소)

    • 값이 비어있거나 공백이 아니어야 함
    • 유효한 이메일 주소 형식이어야 함
  • name

    • 값이 비어있거나 공백이 아니어야 함
  • phone

    • 값이 비어있거나 공백이 아니어야 함
    • 010으로 시작하는 11자리 숫자와 ‘-’ 구성된 문자열이어야 함 ex)010-1234-5678

설정한 조건에 따라 유효성 검증을 적용하면 아래와 같이 된다.

package com.gnidinger.member;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

public class MemberPostDto {
    @NotBlank
    @Email
    private String email;

    @NotBlank(message = "이름은 공백이 아니어야 합니다.")
    private String name;

    @Pattern(regexp = "^010-\\d{4}-\\d{4}$",
            message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
    private String phone;

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }
}

클라이언트에서 전달받는 데이터인 emil, name, phone에 유효성 검증을 위한 애너테이션이 추가된 것을 확인할 수 있다.

 

이와 같이 유효성 검증을 위한 애너테이션을 추가함으로써 MemberController의 핸들러 메서드에 

 

별도의 유효성 검증을 추가할 필요 없이 깔끔하게 유효성 검증 로직이 분리된 것 역시 확인할 수 있다.

 

추가된 애너테이션은 아래와 같은데, 공통적으로 유효성 검증에 실패하면 에러 메시지가 콘솔에 출력되게 된다.

 

  • @NotBlank - 데이터가 비어있지 않은지 검증. null이나 공백(””), 스페이스(” “) 등을 허용하지 않음
  • @Email - 유효한 이메일 주소인지 검증
  • @Pattern - 입력 데이터가 정규 표현식(Regular Expression)에 매치되는지 검증
    • 정규 표현식 - 문자열의 일정한 패턴을 표현하는 형식 언어. 예를 들면 '^'와 '$'는 문자열의 시작과 끝을 나타낸다.

검증을 적용한 MemberPostDto 클래스를 사용하는 MemberControllerpostMember() 메서드는 어떻게 바뀔까?

 

아래와 같이 매우 간단한 수정만 필요하다.

@RestController
@RequestMapping("/v1/members")
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
        return new ResponseEntity<>(memberDto, HttpStatus.CREATED);
    }
		...
	...
}

핸들러 메서드의 매개변수 앞에 @Valid가 추가된 것을 확인할 수 있다.

 

코드 추가 후 잘못된 양식의 데이터를 입력한 모습이다.

default message [이름은 공백이 아니어야 합니다.]
default message [휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.]
default message [올바른 형식의 이메일 주소여야 합니다]

인텔리제이에서 로그를 확인하면 위와 같은 세 개의 검증 에러가 발생한 것을 볼 수 있다.

 

  • MemberPatchDto 클래스에 유효성 검증 적용

계속해서 MemberPatchDto 클래스에 유효성 검증을 적용해 보자.

 

아까와 마찬가지로 유효성 검증 적용 이전 코드를 먼저 가져오자.

package com.gnidinger.member;

public class MemberPatchDto {
    private long memberId;
    private String email;
    private String name;
    private String phone;

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public long getMemberId() {
        return memberId;
    }

    public void setMemberId(long memberId) {
        this.memberId = memberId;
    }
}

이어서 유효성 제약 사항이다. 제약 사항은 아까와 조금 다르게 아래와 같다.

 

  • name

    • 값이 비어있을 수 있음
    • 값이 비어있지 않다면 공백이 아니어야 함
  • phone

    • 값이 비어있을 수 있음
    • 비어있지 않다면 010으로 시작하는 11자리 숫자와 ‘-’ 구성된 문자열이어야 함 ex) 010-1234-5678

바로 적용한 코드를 확인하자.

package com.gnidinger.member;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

public class MemberPatchDto {
    private long memberId;
    @NotBlank
    @Email
    private String email;
    @Pattern(regexp = "^(?=\\s*\\S).*$", message = "회원 이름은 공백이 아니어야 합니다.")
    private String name;
    @Pattern(regexp = "^(?=\\s*\\S).*$", message = "전화 번호는 공백이 아니어야 합니다.")
    @Pattern(regexp = "^010-\\d{4}-\\d{4}$",
            message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
    private String phone;

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public long getMemberId() {
        return memberId;
    }

    public void setMemberId(long memberId) {
        this.memberId = memberId;
    }
}

클라이언트에서 전달받는 데이터인 name, phone에 유효성 검증을 위한 애너테이션이 추가된 것을 확인할 수 있다.

 

memberId의 경우 전달 데이터(Request Body)에 해당되지 않으므로 검증이 필요 없다.

 

@Pattern 애너테이션에는 MemberPostDto 때와는 달리 정규표현식이 늘어났다.

 

계속해서 유효성 검증을 적용한 MemberPatchDto 클래스를 사용하는 patchMember() 메서드를 보자.

@RestController
@RequestMapping("/v1/members")
public class MemberController {
    ...
	    ...

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") long memberId,
                                    @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);

        return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
    }
}

MemberPostDto와 마찬가지로 @Valid 애너테이션만 추가된 것을 확인할 수 있다.

 

@Pattern의 설정은 값이 입력되지 않을 경우를 상정한 것인데, 결과는 아래와 같다.

 

“^(?=\\s*\\S).*$” 는 ‘공백이 있거나 없고, 문자열 있는 경우' 외에 공백만 있는 문자열은 검증에 실패하게 해 준다.

 

위 정규 표현식에 들어간 표현을 살짝만 뜯어보면 아래와 같다.

 

  • ‘^’ - 문자열의 시작
  • ‘$’ - 문자열의 끝
  • ‘*’ - ‘*’ 앞에서 평가할 대상이 0개 또는 1개 이상인지 평가
  • ‘\s’ - 공백 문자열
  • ‘\S’ - 공백 문자열이 아닌 문자열
  • ‘.’ - 임의의 문자 하나

 

  • 쿼리 파라미터(Query Parameter) 및 @Pathvariable에 대한 유효성 검증

마지막으로 patchMember() 메서드의 URI path에 @PathVariable("member-id") long memberId에 대한 검증이다.

 

memberId에 ‘1 이상의 숫자여야 한다’라는 제약 조건을 걸어보자.

@RestController
@RequestMapping("/v1/members")
@Validated
public class MemberController {
		...
		...

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") @Min(1) long memberId,
                                    @Valid @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);

        return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
    }
}

클래스 영역에 @Validated(Spring이 지원하는 검증 애너테이션)와 메서드 영역에 @Min(1)이 추가된 것을 확인할 수 있다.

 

요청 URL에 0을 집어넣으니 에러가 나는 것을 볼 수 있다.

 

  • Custom Validator 만들기

위의 @Pattern(regexp = "^(?=\\s*\\S).*$")과 같은 부분을 Custom Validator로 깔끔하게 바꿀 수도 있다. 

 

진행 단계는 다음과 같으며, 만들어낼 Validator의 이름은 @NotSpace이다. 

 

  1. Custom Annotation 정의
  2. 정의한 Custom Annotation에 바인딩 되는 Custom Validator 구현
  3. 유효성 검증이 필요한 DTO 클래스의 멤버 변수에 Custom Annotation 추가
  • Custom Annotation 정의
package com.gnidinger.member;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {NotSpaceValidator.class}) // (1)
public @interface NotSpace {
    String message() default "공백이 아니어야 합니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

이어서 실질적 제한조건을 가진 NotSpaceValidator.class(위 코드 (1)에서 사용)를 구현한다.

 

  • 정의한 Custom Annotation 바인딩 되는 Custom Validator 구현
package com.gnidinger.member;

import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class NotSpaceValidator implements ConstraintValidator<NotSpace, String> {

    @Override
    public void initialize(NotSpace constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value == null || StringUtils.hasText(value);
    }
}

글 처음 부분에서 살펴본 Jakarta Bean Validation 라이브러리의 ConstraintValidator 인터페이스를 구현한다.

 

또한  ConstraintValidator 안의 NotSpace는 Custom Annotation을 의미하며, String은 검증할 대상의 타입을 의미한다.

 

  • 유효성 검증이 필요한 DTO 클래스의 멤버 변수에 Custom Annotation 추가
package com.gnidinger.member;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

public class MemberPatchDto {
    private long memberId;
    @NotBlank
    @Email
    private String email;
    @NotSpace(message = "회원 이름은 공백이 아니어야 합니다") // (1)
    private String name;
    @NotSpace(message = "전화 번호는 공백이 아니어야 합니다")
    @Pattern(regexp = "^010-\\d{4}-\\d{4}$",
            message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
    private String phone;
	...
	...
}

마지막으로 DTO 클래스에 애너테이션을 적용하면 끝난다.

 

핸들러 메서드에 대한 응답은 당연하게도 이전과 동일한 것을 쉽게 확인할 수 있다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함