DTO는 우리말로 하면 데이터 전송 객체로, 프로세스 간에 데이터를 전달하는 객체이다. 프로세스라함은 클라이언트가 서버에 전송하는 요청 데이터, 서버에서 클라이언트로 전송하는 응답 데이터로 클라이언트-서버 간의 데이터 전송이 있다.
@PostMapping
public ResponseEntity postMember(@RequestParam("name") String name,
@RequestParam("phone") String phone,
@RequestParam("email") String email,
@RequestParam("grade") String grade) {
Map<String, String> map = new HashMap<>();
map.put("name", name);
map.put("phone", phone);
map.put("email", email);
map.put("grade", grade);
return new ResponseEntity<>(map, HttpStatus.CREATED);
}
앞서 작성한 컨트롤러의 일부를 보면, postMember()
의 파라미터를 받을 때 @RequestParam
애너테이션을 쓰고 있다. 회원정보가 더 많아진다면 그만큼 애너테이션을 계속 추가해야 한다. DTO 클래스를 쓰면 요청 데이터를 하나의 객체로 전달 받을 수 있다. 코드가 매우 간결해진다. (적용 결과는 하단에 있다.)
또한 데이터 유효성 검증(Validation Check)을 DTO 클래스에서 할 수 있다. 유효성 검증은 서버에서 데이터를 받을 때 지정한 조건에 따라 유효한지를 검증하는 것이다. (ex. 반드시 010으로 시작하는 휴대폰 번호를 받는 조건)
@PostMapping
public ResponseEntity postMember(@RequestParam("name") String name,
@RequestParam("phone") String phone,
@RequestParam("email") String email,
@RequestParam("grade") String grade) {
if(!phone.matches("01[016789]-[^0][0-9]{2,3}-[0-9]{3,4}"))
throw new InvalidParameterException();
Map<String, String> map = new HashMap<>();
map.put("name", name);
map.put("phone", phone);
map.put("email", email);
map.put("grade", grade);
return new ResponseEntity<>(map, HttpStatus.CREATED);
}
위와 같이 핸들러 메서드에 기나긴 정규표현식을 넣어 유효성 검증을 해야 하는데, 이러한 조건을 모두 DTO 클래스에 넣을 수 있다. 파라미터마다 검증 조건이 한없이 많을 수 있으니 코드의 간결성을 위해 DTO 클래스에 옮기는 것이 바람직하다. 그리고 핸들러 메서드는 요청을 전달 받는 것이 주목적이기 때문에 최대한 본래 목적에만 맞게 핵심만 담는 것이 맞기도 하다.
마지막으로 DTO를 쓰는 제일 중요하고 현실적인 이유는, 비용을 줄이기 위함이다. HTTP 요청은 비용이 많이 든다. 각 호출은 클라이언트-서버 간 왕복 시간과 관련되어 있는데, 호출의 수를 줄이기 위해 DTO 클래스의 객체를 이용해 데이터를 축적해 한 번에 처리하는 것이다.
@RestController
@RequestMapping("/v1/member")
public class MemberController {
@PostMapping
public ResponseEntity postMember(@RequestParam("name") String name,
@RequestParam("phone") String phone,
@RequestParam("email") String email,
@RequestParam("grade") String grade) {
Map<String, String> map = new HashMap<>();
map.put("name", name);
map.put("phone", phone);
map.put("email", email);
map.put("grade", grade);
return new ResponseEntity<>(map, HttpStatus.CREATED);
}
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(@PathVariable("member-id") long memberId,
@RequestParam("name") String name,
@RequestParam("phone") String phone,
@RequestParam("email") String email,
@RequestParam("grade") String grade) {
Map<String, Object> map = new HashMap<>();
map.put("memberId", "패밀리아이디");
map.put("name", "강동원");
map.put("phone", phone);
map.put("email", email);
map.put("grade", "VIP");
return new ResponseEntity<>(map, 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);
}
}
MemberController 레거시 코드
@RequestParam
애너테이션으로 데이터를 받는 핸들러 메서드를 찾아 해당 코드들을 DTO 클래스의 객체로 수정 (POST, PATCH)Post 핸들러 메서드에 대한 DTO 클래스이다. @RequestParam
으로 받는 회원 정보를 추가하고, Response Body에 해당 멤버 변수의 값이 포함되어야 하니까 getter
메서드를 추가해주었다. (이후에는 롬복 애너테이션으로 수정할 예정)
public class MemberPostDto {
private String phone;
private String name;
private String email;
private String grade;
public String getEmail() {
return email;
}
public String getGrade() {
return grade;
}
public String getName() {
return name;
}
public String getPhone() {
return phone;
}
}
Patch 핸들러 메서드에 대한 DTO 클래스이다. 수정하는 메서드이기 때문에 세터까지 작성해주었다. 우선은 회원정보 전부 수정가능하게 하고 싶기 때문에 전부 포함시켜주었다.
public class MemberPatchDto {
private long memberId;
private String name;
private String phone;
private String email;
private String grade;
public long getMemberId() {
return memberId;
}
public void setMemberId(long memberId) {
this.memberId = memberId;
}
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 String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getGrade() {
return grade;
}
public void setGrade(String grade) {
this.grade = grade;
}
}
먼저 DTO 클래스에 유효성 검증을 적용하기 위해 필요한 Starter를 dependencies
에 추가해준다.
@NotBlank
@Pattern(regexp = "^010-\\d{3,4}-\\d{4}$",
message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
private String phone;
@NotBlank(message = "이름은 공백이 아니어야 합니다.")
private String name;
@Email
@NotBlank
private String email;
private String grade;
email: 값이 비어도 된다. 비지 않을 경우 공백이 아니어야 하고, 유효한 이메일 주소 형식이어야 한다. 중복되지 않아야 한다.
phone: 비거나 공백이면 안 된다.
grade: 비거나 공백이면 안 된다.
비거나 공백이지 않아야 한다는 조건을 위해 Custom Validator
를 생성한다.
//NotSpace.java (인터페이스)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {NotSpaceValidator.class})
public @interface NotSpace {
String message() default "공백이 아니어야 합니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
//NotSpaceValidator.java (클래스)
public class NotSpaceValidator implements ConstraintValidator <NotSpace, String> {
@Override
public void initialize(NotSpace constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
return value == null || StringUtils.hasText(value);
}
}
//MemberPatchDto 클래스에 적용 (멤버 변수 부분만 복붙)
private long memberId;
private String name;
@NotSpace
private String phone;
@NotSpace(message = "회원 이메일은 비거나, 비지 않을 경우 공백이 아니어야 합니다")
@Email (message = "유효한 이메일 주소 형식이어야 합니다")
// @UniqueEmail (message = "이미 등록한 이메일입니다")
private String email;
@NotSpace
private String grade;
이미 등록한 이메일인지 검증하는 UniqueEmail
도 생성은 했는데 아직 회원정보 레파지토리가 없어서 당장 적용은 어려울듯 하다 ㅎㅎ...
// UniqueEmail.java (인터페이스)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {NotSpaceValidator.class})
public @interface UniqueEmail {
String message() default "이미 등록한 이메일입니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// UniqueEmailValidator.java (클래스)
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
@Autowired
private MemberRepository memberRepository;
@Override
public void initialize(UniqueEmail constraintAnnotation){}
@Override
public boolean isValid(String email, ConstraintValidatorContext cvContext) {
return !memberRepository.findByEmail(email).isPresent();
}
}
@RequestParam
이 아닌 @RequestBody
애너테이션을 사용해 각 메서드에 맞는 DTO 객체를 가져온다. Map 객체를 활용했을 때보다 코드가 확연히 간결해졌다.
@PathVariable
에 대해서도 유효성 검증을 적용했다. @Min()
에너테이션을 넣어 memberId는 1 이상의 숫자여야 한다는 조건을 넣었다. 아이디는 데이터 식별자로 쓰이기 때문에 0 이상의 숫자로 표현하기 때문이다.
...근데, 수정한 부분에 문제점이 있었다^^.
유효성 검증을 적용한 바디에는 꼭! @Valid
애너테이션을 붙여야한다. 안 그러면 아무리 DTO 클래스와 Validation 클래스를 잘 짜놓아도 조건에 맞지 않는 데이터를 넘겼을 때 숭숭 통과된다.
@RequestBody
애너테이션: 클라이언트가 전송한 JSON 형식의 Request Body를 DTO 클래스의 객체로 변환@ResponseBody
애너테이션: 반대로 DTO 클래스의 객체를 Response Body로 변환POST 핸들러 메서드
PATCH 핸들러 메서드:
위 수정코드에서 세터로 설정한 이메일 주소와 등급 정보가 입력된 것을 확인할 수 있다.
유효성 검증 테스트
@Valid
를 붙이니 모든 유효성 검증이 제대로 적용된 걸 확인할 수 있었다. 포스트맨으로 에러를 볼 수 있기도 하지만 인텔리제이 로그에 설정한 message
문구도 함께 나오니 확인하기 좋다👍
참고 자료