10 분 소요

스프링 복습 및 정리 8P

DTO(Data Transfer Object)란?

데이터를 객체로 표현하는 데 사용되는 디자인 패턴이다.

DTO는 데이터의 전송과 관련된 역할을 수행하며 주로 데이터베이스에서 가져온 데이터나 서비스 간에 데이터를 전달할 때 사용된다.

일반적으로 DTO 클래스는 데이터 필드(멤버 변수)와 해당 필드에 접근할 수 있는 getter, setter 메서드로 구성된다.

DTO 클래스는 주로 불변(immutable)하게 구현되며, 데이터 전송에 필요한 필드만 포함된다.

예를 들어, 웹 애플리케이션에서 사용자 등록을 위한 DTO 클래스를 만들 수 있으며,

이 클래스는 사용자의 이름, 이메일, 비밀번호 등을 저장하고 전송할 목적으로 사용된다.

데이터 전송

DTO는 서로 다른 계층 또는 서비스 간에 데이터를 효율적으로 전송하기 위해 사용된다.

예를 들어, 데이터베이스에서 읽은 데이터를 비즈니스 로직 계층으로 전달하거나,

클라이언트와 서버 간에 데이터를 주고받을 때 사용된다.

데이터 캡슐화

DTO는 여러 데이터 필드를 하나의 객체로 묶어서 관리함으로써 데이터의 일관성을 유지하고 데이터를 보다 쉽게 관리할 수 있도록 도와준다.

불필요한 정보 제거

클라이언트에게 전송할 때 불필요한 데이터를 제거하여 트래픽을 최소화 하고 보안을 강화할 수 있다.

데이터 형식 변환

서로 다른 데이터 형식 간에 데이터를 변환할 때 DTO를 사용하여 데이터를 매핑하고 변환한다.

DTO가 필요한 이유 (예시)

@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> map = new HashMap<>();
        map.put("email", email);
        map.put("name", name);
        map.put("phone", phone);

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

DTO 클래스를 이용한 코드의 간결성

위 코드에서는 회원 정보를 저장하기 위해서 총 세 개의 @RequestParam 어노테이션을 사용하고 있다.

하지만 더 많은 데이터가 회원 정보에 포함되어 있을 경우 postMember()에 파라미터로 추가되는 @RequestParam의 개수는 계속 늘어나게 된다.

이 경우, DTO 클래스가 바로 요청 데이터를 하나의 객체로 전달받는 역할을 해 코드 자체가 간결해진다.

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

@RequestParam을 통해 전달받은 요청 데이터들을 Map에 추가하는 로직이 사라지고,

MemberDto 객체를 ResponseEntity 클래스의 생성자 파라미터로 전달하도록 변경되었다.

데이터 유효성 검증의 단순화

위 코드에서는 클라이언트의 요청 데이터에 대한 유효성 검증 작업을 거치지 않았다.

예를 들면, 클라이언트 쪽에서 회원 정보의 email 주소를 1234aaa 같은 문자열로 전송해도 정상적으로 핸들러 메서드 쪽에서 전달받을 수 있다.

이처럼 서버 쪽에서 유효한 데이터를 전달받기 위해 데이터를 검증하는 것을 유효성 검증이라고 한다.

만약 위 코드에서 유효성 검증을 구현하면 아래와 같이 작성할 수 있다.

@RestController
@RequestMapping("/no-dto-validation/v1/members")
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@RequestParam("email") String email,
                                     @RequestParam("name") String name,
                                     @RequestParam("phone") String phone) {
				// (1) email 유효성 검증
        if (!email.matches("^[a-zA-Z0-9_!#$%&'\\*+/=?{|}~^.-]+@[a-zA-Z0-9.-]+$")) {
            throw new InvalidParameterException();
        }
        Map<String, String> map = new HashMap<>();
        map.put("email", email);
        map.put("name", name);
        map.put("phone", phone);

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

정규 표현식을 사용해서 이메일 주소의 유효성을 검증하는 로직을 작성했다.

만일 name이나 phone에 대한 유효성 검증도 필요하다면 핸들러 내의 코드는 유효성을 검증하는 로직들이 넘쳐나고 그만큼 코드의 복잡도도 높아지게 된다.

HTTP 요청을 전달받는 핸들러 메서드는 요청을 전달받는 것이 주목적이기 때문에 최대한 간결하게 작성되는 것이 좋다.

이럴 때, DTO 클래스를 사용하면 유효성 검증 로직을 DTO 클래스로 빼내어 핸들러 메서드의 간결함을 유지할 수 있다.

public class MemberDto {
    @Email
    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;
    }
}

email 멤버 변수에 @Email 어노테이션을 추가하면 클라이언트의 요청 데이터에 유효한 이메일 주소가 포함되어 있지 않을 경우 유효성 검증에 실패하기 때문에 클라이언트의 요청은 거부된다.

MemberDto 클래스에서 이메일에 대한 유효성 검증을 진행하므로, postMember() 핸들러 메서드는 간결해졌다.

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

DTO 클래스를 사용하는 가장 중요한 목적은 비용이 많이 드는 작업인 HTTP 요청의 수를 줄이기 위함이다.

DTO 유효성 검증에 대한 자세한 내용은 아래에서 다루겠다.

DTO 적용

HTTP 요청/응답 데이터에 DTO 적용하기

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

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

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

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

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

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

위 코드는 DTO가 적용되지 않은 레거시 코드이다.

위 코드를 DTO 클래스 적용을 위해 리팩토링 하겠다.

MemberPostDto 및 MemberPatchDto 클래스 생성

public class MemberPostDto {
    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 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;
    }
}

주의해야 할 부분은 멤버 변수 이외에 각 멤버 변수에 해당하는 getter 메서드가 있어야 한다.

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

setter 메서드는 필수 항목은 아니지만 개발자의 필요에 의해 있을 수도, 없을 수도 있다.

MemberController에 DTO 클래스 적용

@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("홍길동");

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

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

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

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

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

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

        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

@RequestBody는 JSON 형식의 Request Body를 MemberPostDto 클래스의 객체로 변환 시켜주는 역할이다.

@RequestBody 어노테이션에 대한 자세한 정보는 [@RequestBody] ← 여기서 확인할 수 있다.

@ResponseBody는 JSON 형식의 Response Body를 클라이언트에게 전달하기 위해 DTO 클래스의 객체를 Response Body로 변환하는 역할을 한다.

@ResponseBody 어노테이션에 대한 자세한 정보는 [@ResponseBody] ← 여기서 확인할 수 있다.

Request Body = 클라이언트에서 서버로 전달되는 데이터
Response Body = 서버에서 클라이언트로 전송되는 데이터

위의 postMember(), patchMember()@ResponseBody를 사용하는 곳이 없는 이유는

postMember(), patchMember() 핸들러 메서드의 리턴 값이 ResponseEntity 클래스의 객체이기 때문이다.

Spring MVC에서는 핸들러 메서드에 @ResponseBody 어노테이션이 붙거나 핸들러 메서드의 리턴 값이 ResponseEntity일 경우,

내부적으로 HttpMessageConverter가 동작하게 되어 응답 객체(여기서는 DTO 클래스의 객체)를 JSON 형식으로 바꿔준다.

클라이언트 쪽에서 JSON 형식의 데이터를 서버 쪽으로 전송하면 서버 쪽의 웹 애플리케이션은 전달받은 JSON 형식의 데이터를 DTO 같은 Java의 객체로 변환하는데 이를 역직렬화이라고 한다.

반면에 서버 쪽에서 클라이언트에게 응답 데이터를 전송하기 위해서 DTO 같은 **Java의 객체를 JSON 형식으로 변환하는 것을 직렬화라고 한다.

JSON 직렬화(Serialization) = Java 객체 → JSON
JSON 역직렬화(Deserialization) = JSON → Java 객체

DTO 유효성 검증 (Validation)

email - 유효한 이메일 주소 형식, 공백이 아니어야 함.

name - 공백이 아니어야 함.

phone - 010으로 시작하는 11자리 숫자와 ‘-’로 구성된 문자열이며 공백이 아니어야 함.

의존 라이브러리 추가

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

MemberPostDto

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

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

    @Pattern(regexp = "^\\S+(\\s?\\S+)*$", 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 - 정규표현식에 매치되는 유효한 값인지 검증한다.

name

  • 이름 정보가 비어있으면 유효성 검증을 하지 않는다.
  • 이름 정보가 비어있지 않고, 공백 문자열이라면 검증에 실패한다.
  • 시작 문자가 공백 이면 검증에 실패한다.
  • 끝 문자가 공백이면 검증에 실패한다.
  • 문자와 문자 사이 공백이 1개를 초과하면 검증에 실패한다.

구글에서 @Email 어노테이션에 대해 검색하면 Deprecated 되었다는 내용이 있다.

여기서 Deprecated된 @Email 어노테이션은 Hibernate Validator에서 지원하는 어노테이션이다.

여기선 javax에서 지원하는 표준 Email 어노테이션을 사용하고 있다.

demuu111@gmail 같이 gmail.com 같은 이메일 주소 형식이 아닌데도 유효성 검증에 통과하는 경우가 있다.

이메일 주소의 스펙을 확인해 보면 최상위 도메인이 없는 이메일 주소의 경우도 때로는 정상 이메일 주소로 허용한다.

따라서 최상위 도메인까지 포함되어야 유효한 이메일 주소라고 판단하고 싶은 경우에는

정규 표현식을 이용해 더 세밀한 유효성 검사 조건을 지정할 수 있다.

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

        // No need Business logic

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

위 코드에서는 @PathVariable(”member-id”) long memberId [더보기] 에 1 이상의 숫자일 경우에만 유효성 검증에 통과하도록 @Min(1) [더보기] 이라는 검증 어노테이션을 추가했다.

@PathVariable이 추가된 변수에 유효성 검증이 정상적으로 수행되려면 @Validated 어노테이션 [더보기] 을 반드시 붙여주어야 한다.

Jakarta Bean Validation 이란?

지금까지 DTO 클래스의 유효성 검증을 위해서 사용한 어노테이션은 Jakarta Bean Validation이라는 유효성 검증을 위한 표준 스펙에서 지원하는 내장 어노테이션들이다.

Jakarta Bean Validation은 라이브러리처럼 사용할 수 있는 API가 아닌 스펙 자체이다.

즉, 이러이러한 어노테이션들을 이런 식으로 구현해서 사용하라는 일종의 기능 명세를 의미한다.

Jakarta Bean Validation 스펙을 구현한 구현체가 바로 Hibernate Validator이다.

Jakarta Bean Validation의 어노테이션을 DTO 클래스에만 사용할 수 있는 것은 아니다.

Java Bean 스펙을 준수하는 Java 클래스라면 Jakarta Bean validation의 어노테이션을 사용해서 유효성 검증을 할 수 있다.

Custom Validator 유효성 검증

MemberPatchDto 클래스의 name 멤버 변수에서 공백 여부를 검증하는 @Pattern(regexp = "^(?=\\s*\\S).*$") 어노테이션을 Custom Validator를 사용하도록 바꿔보겠다.

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

정규 표현식은 성능적인 면에서 때로는 비싼 비용을 치러야 될 가능성이 있다.
모든 로직을 정규표현식 위주로 작성하는 것은 좋은 개발 방식이 아니다.

Custom Annotation을 정의

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 "공백이 아니어야 합니다"; // (2)
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

NotSpace 어노테이션이 멤버 변수에 추가되었을 때, 동작할 Custom Validator를 (1)과 같이 추가한다.

(2)는 별도로 정의하지 않으면 유효성 검증 실패 시, 표시되는 디폴트 메시지이다.

Custom Validator 구현

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

CustomValidator를 구현하기 위해서는 ConstraintValidator 인터페이스를 구현해야 한다.

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

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

import javax.validation.constraints.Pattern;

public class MemberPatchDto {
    private long memberId;

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

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

name 멤버 변수에 적용된 @Pattern(regexp ="^\\S+(\\s?\\S+)*$") 어노테이션을 제거하고, 대신에 앞에서 작성한 Custom Annotation인 @NotSpace를 추가했다.

Spring Boot 핵심 정리 모음

1P - POJO, IoC, DI, AOP, PSA >

1.1P - DI / 의존성 주입은 왜 필요한가? >

2P - 빈(Bean) 생명주기와 범위 >

3P - 컴포넌트 스캔과 의존성 자동 주입, @Autowired >

4P - AOP (관심 지향 프로그래밍) >

5P - Spring MVC / MVC의 동작 방식과 구성 요소 >

6P - REST, REST API >

7P - HTTP Header, Rest Client >

8P - DTO, DTO 유효성 검증 >

9P - Service / Entity 클래스, Mapper / MapStruct >

10P - 예외 처리 (@ExceptionHandler, @RestControllerAdvice) >

10.1P - 비즈니스 로직에 대한 예외 처리 >

11P - JDBC 데이터 액세스 계층 >

11.1P - DDD, 도메인 엔티티 및 테이블 설계 >

11.2P - 데이터 액세스 계층 구현 (도메인 엔티티 클래스 정의) >

11.3P - 데이터 액세스 계층 구현 (서비스, 리포지토리 구현) >

12P - JPA(Java Persistence API) >

12.1P - JPA 엔티티 매핑 >

12.2P - JPA 엔티티 간의 연관 관계 매핑 >

12.3P - Spring Data JPA 데이터 액세스 계층 구현 >

13P - Spring MVC 트랜잭션 >

13.1P - 선언형 방식의 트랜잭션 적용 >

13.2P - JTA를 이용한 분산 트랜잭션 적용 >

14P - Spring MVC Testing 단위 테스트 >

14.1P - JUnit 단위 테스트 >

14.2P - Hamcrest Assertion >

14.3P - 슬라이스 테스트 (API, 데이터 액세스 계층) >

14.4P - Mockito >

14.5P - TDD (Test Driven Development) >

15P - API 문서화 (Documentation) >

15.1P - API 문서 생성, Asciidoc, Asciidoctor >

16P - 애플리케이션 빌드 / 실행 / 배포 >

17P - Spring Security >

17.1P - Spring Security 2 >

18P - JWT (JSON Web Token) >

댓글남기기