Spring Boot/MVC

[Spring MVC] DTO(Data Transfer Object), Validation

WY J 2022. 8. 22. 20:43

DTO(Data Transfer Object)란?

DTO는 Data Transfer Object의 약자로 마틴 파울러(Martin Fowler)가 ‘Patterns of Enterprise Application Architecture’ 라는 책에서 처음 소개한 엔터프라이즈 애플리케이션 아키텍처 패턴의 하나이다.

 

클라이언트에서 서버 쪽으로 전송하는 요청 데이터, 서버에서 클라이언트 쪽으로 전송하는 응답 데이터의 형식으로 데이터 전송이 이루어진다. 이 구간에서 DTO를 적용할 수 있다.

 

DTO가 필요한 이유

  • 클라이언트의 Request Body를 하나의 객체로 모두 전달 받을 수 있기때문에 코드 자체가 간결해진다.
  • Request Body의 데이터 유효성(Validation) 검증이 단순해진다.

 

DTO가 적용되지 않은 레거시 MemberController

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/v1/members")
public class MemberController {
    // 회원 정보 등록
    @PostMapping
    public ResponseEntity postMember(@RequestParam("email") String email,
                                     @RequestParam("name") String name,
                                     @RequestParam("phone") String phone) {
        Map<String, String> body = new HashMap<>();
        body.put("email", email);
        body.put("name", name);
        body.put("phone", phone);

        return new ResponseEntity<Map>(body, HttpStatus.CREATED);
    }

    // 회원 정보 수정
    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") long memberId,
                                      @RequestParam String phone) {
        Map<String, Object> body = new HashMap<>();
        body.put("memberId", memberId);
        body.put("email", "hgd@gmail.com");
        body.put("name", "홍길동");
        body.put("phone", phone);

        // No need Business logic

        return new ResponseEntity<Map>(body, HttpStatus.OK);
    }
    
    // 한명의 회원 정보 조회
    @GetMapping("/{member-id}")
    public ResponseEntity getMember(@PathVariable("member-id") long memberId) {
        System.out.println("# memberId: " + memberId);

        // not implementation
        return new ResponseEntity<Map>(HttpStatus.OK);
    }

    // 모든 회원 정보 조회
    @GetMapping
    public ResponseEntity getMembers() {
        System.out.println("# get Members");

        // not implementation

        return new ResponseEntity<Map>(HttpStatus.OK);
    }
    
    // 회원 정보 삭제
    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(@PathVariable("member-id") long memberId) {
        // No need business logic

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

 

 

DTO 클래스 적용을 위한 코드 리팩토링 절차

  1. 회원 정보를 전달 받을 DTO 클래스를 생성한다.
    • MemberController에서 현재 회원 정보로 전달 받는 각 데이터 항목(email, name, phone)들을 DTO 클래스의 멤버 변수로 추가한다.
  2. 클라이언트 쪽에서 전달하는 요청 데이터를 @RequestParam 애너테이션으로 전달 받는 핸들러 메서드를 찾는다.
    • Request Body가 필요한 핸들러는 HTTP POST, PATCH, PUT 같이 리소스의 추가나 변경이 발생할 때 이다.
  3. @RequestParam 부분 코드를 DTO 클래스의 객체로 수정한다.
  4. Map 객체로 작성되어 있는 Response Body를 DTO 클래스의 객체로 변경한다.

 

 

 

MemberPostDto 및 MemberPatchDto 클래스 작성

 

회원 정보 등록 시, Request Body를 전달 받을  때 사용하는 MemberPostDto 클래스

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;
    }
}

 

회원 정보 수정 시, Request Body를 전달 받을 때 사용하는 MemberPatchDto 클래스

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

    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;
    }
}

주의할 점으로 DTO 클래스 만들 때 각 멤버 변수에 해당하는 getter 메서드가 필요하다.

getter 메서드가 없으면 Response Body에 해당 멤버 변수의 값이 포함되지 않는 문제가 발생한다.

 

 

MemberController에 DTO 클래스 적용

import com.codestates.member.MemberPatchDto;
import com.codestates.member.MemberPostDto;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/v1/members")
public class MemberController {
    // 회원 정보 등록
    @PostMapping
    public ResponseEntity postMember(@RequestBody MemberPostDto memberPostDto) {
        return new ResponseEntity<>(memberPostDto, HttpStatus.CREATED);
    }

    // 회원 정보 수정
    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") long memberId,
                                      @RequestBody MemberPatchDto memberPatchDto) {
        memberPatchDto.setMemberId(memberId);
        memberPatchDto.setName("홍길동");

        // No need Business logic

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

    // 한명의 회원 정보 조회
    @GetMapping("/{member-id}")
    public ResponseEntity getMember(@PathVariable("member-id") long memberId) {
        System.out.println("# memberId: " + memberId);

        // not implementation
        return new ResponseEntity<>(HttpStatus.OK);
    }

    // 모든 회원 정보 조회
    @GetMapping
    public ResponseEntity getMembers() {
        System.out.println("# get Members");

        // not implementation

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

    // 회원 정보 삭제
    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(@PathVariable("member-id") long memberId) {
        // No need business logic

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

@RequestParam을 사용하는 대신 DTO 클래스를 사용하여 postMember()에서는 MemberPostDto, PostMember() 에서는 MemberPatchDto 클래스의 객체를 통해서 Request Body를 한번에 전달 받을 수 있도록 수정했다.

 

 

애너테이션 설명

  • @RequestBody : 클라이언트 쪽에서 전송한 JSON 형식의 Request Body를 DTO 클래스의 객체로 변환
  • @ResponseBody : DTO 클래스의 객체를 Response Body로 변환하는 역할

 

postMember(), patchMember() 핸들러 메서드에 @ResponseBody 애너테이션을 사용하는 곳이 없는 것을 볼 수 있다.

이는 해당 핸들러 메서드의 리턴 값이 ResponseEntity 클래스의 객체이고, Spring MVC 에서는 핸들러 메서드의 리턴 값이 ResponseEntity 이면 내부적으로 HttpMessageConverter가 동작하게 되어 응답 객체(DTO 클래스)를 JSON 형식으로 바꿔주기 때문이다.

 

 

JSON 직렬화(Serialzation)와 역직렬화(Deserialization)

JSON 직렬화 : Java 객체 -> JSON

JSON 역직렬화 : JSON -> Java 객체

 

 


DTO 유효성 검증(Validation)

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

하지만, 그 값이 반드시 유효한 값이라고 보장할 수 없으므로 백엔드 애플리케이션 쪽에서도 유효성 검증이 필요하다.

 

 

Jakarta Bean Validation이란?

  • 유효성 검증을 위한 표준 스펙에서 지원하는 내장 애너테이션들이다. 
  • Jakarta Bean Validation은 라이브러리처럼 사용할 수 있는 API가 아닌 스펙 자체이다.
  • 이 Jakarta Bean Validation 스펙을 구현한 구현체가 바로 Hibernate Validator이다.
  • Jakarta Bean Validation의 애너테이션을 이용하면 Controller 로직에서 유효성 검증 로직을 분리할 수 있다.

 

 

 

 

유효성 검증이 적용된 MemberPostDto

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{3,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;
    }
}

 

유효성 검증 애너테이션 설명

@NotBlank

  • null 값이나 공백(""), 스페이스(" ") 같은 값들은 모두 허용되지 않는다.
  • 유효성 검증에 실패하면 에러 메세지가 콘솔에 출력된다.

@Email

  • 유효한 이메일 주소인지 검증한다.
  • 유효성 검증에 실패하면 내장된 디폴트 에러 메세지가 콘솔에 출력된다.

@Pattern

  • 정규표현식에 매치되는지 검증한다.
  • 유효성 검증에 실패하면 내장된 디폴트 에러 메세지가 콘솔에 출력된다.

 

정규 표현식 설명

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

 

 

유효성 검증이 적용된 MemberPatchDto

import javax.validation.constraints.Pattern;

public class MemberPatchDto {
    private long memberId;

    @Pattern(regexp = "^(?=\\s*\\S).*$", message = "회원 이름은 공백이 아니어야 합니다.")
    private String name;

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

    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;
    }
}

 

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

 

 


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

patchMember() 핸들러 메서드의 URI path에서 사용되는 @PathVariable("member-id") long memberId 변수 또한 유효성 검증이 가능하다.

 

Spring에서 지원하는 @Validated 애너테이션을 사용하면 쿼리 파라미터(Query Parameter 또는 Query String) 및 @Pathvariable에 대한 유효성 검증을 진행할 수 있다.

 

patchMember() 핸들러 메서드에서 사용되는 memberId에 ‘1 이상의 숫자여야 한다’라는 제약 조건을 걸어보도록 하겠다.

@RestController
@RequestMapping("/v1/members")
@Validated   // @PathVariable 유효성 검증 추가
public class MemberController {
		...
		...

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

        // No need Business logic

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

@Validated 애너테이션 추가 및 @PathVariable 옆에 @Min(1) 이라는 검증 애너테이션을 추가하였다.

 

 

 

 


Custom Validator를 사용한 유효성 검증

Jakarta Bean Validation에 내장된(Built-in) 애너테이션 중에 목적에 맞는 애너테이션이 존재하지 않을 수 있다. 이를 위해 원하는 목적에 맞는 애너테이션을 직접 만들어서 유효성 검증에 적용할 수 있다.

 

 

 

Custom Validator를 구현하기 위한 절차

  1. Custom Validator를 사용하기 위한 Custom Annotation을 정의한다.
  2. 정의한 Custom Annotation에 바인딩 되는 Custom Validator를 구현한다.
  3. 유효성 검증이 필요한 DTO 클래스의 멤버 변수에 Custom Annotation을 추가한다.

 

 

1. Custom Vaildator 정의

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}) // Custom Vaildator 추가
public @interface NotSpace {
    String message() default "공백이 아니어야 합니다"; // 유효성 검증 실패 디폴트 메세지
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

 

2. Custom Vaildator 인터페이스 구현

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);
    }
}

ConstraintValidator<NotSpace, String>에서 NotSpace는 CustomValidator와 매핑된 Custom Annotation을 의미하며, String은 Custom Annotation으로 검증할 대상 멤버 변수의 타입을 의미한다.

 

 

3. 유효성 검증을 위해 Custom Annotation 추가

import javax.validation.constraints.Pattern;

public class MemberPatchDto {
    private long memberId;

    @NotSpace(message = "회원 이름은 공백이 아니어야 합니다") // Custom Annotation 추가
    private String name;

    @NotSpace(message = "휴대폰 번호는 공백이 아니어야 합니다") // Custom Annotation 추가
    @Pattern(regexp = "^010-\\d{3,4}-\\d{4}$",
            message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다")
    private String phone;

    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;
    }
}