Spring MVC
Spring MVC(6) - API 계층 - DTO(Data Transfer Object)
상상날개
2022. 9. 22. 15:18
1. DTO(Data Transfer Object)
- DTO는 Data Transfer Object의 약자로 마틴 파울러(Martin Fowler)가 ‘Patterns of Enterprise Application Architecture’ 라는 책에서 처음 소개한 엔터프라이즈 애플리케이션 아키텍처 패턴이다
- Transfer라는 의미에서 알 수 있듯이 데이터를 전송하기 위한 용도의 객체이다
- 데이터 전송은 클라이언트에서 서버 쪽으로 전송하는 요청 데이터, 서버에서 클라이언트 쪽으로 전송하는 응답 데이터의 형식으로 클라이언트와 서버 간에 데이터 전송이 이루어진다
2. DTO를 사용하는 이유
1) 코드의 간결성
public class MemberController {
@PostMapping
public /*String*/ResponseEntity postMember(@RequestHeader Map<String, String> headers,
@RequestParam("email")String email,
@RequestParam("name")String name,
@RequestParam("phone")String phone) {
// 코드(1)
// System.out.println("# email: " + email);
// System.out.println("# name: " + name);
// System.out.println("# phone: " + phone);
// JSON문자열 응답 타입을 수작업 코드에서 Map객체로 변경->produce애트리뷰트를 삭제할 수 있다
//MAp 객체를 리턴하면 내부적으로 응답 데이터를 JSON데이터로 자동 변환해야 한다고 인식한다
// Map<String, String> map = new HashMap<>();
// map.put("email", email);
// map.put("name", name);
// map.put("phone", phone);
//코드(2)
//@RequestHeader Map을 사용하여 전체 헤더 정보를 받아 온다
for (Map.Entry<String, String> entry : headers.entrySet()){
System.out.println("key: " + entry.getKey() +
", value: " + entry.getValue());
}
//리턴 값을 변경된 ResponseEntity로 대체
//ResponseEntity 객체를 생성하고 생성자 파라미터로 map과 HttpStatus.CREATED를 반환한다
//HttpStatus.CREATED 는 201, created를 의미한다
// 코드(1)
// return new ResponseEntity<>(map, HttpStatus.CREATED);
//코드(2)
//@RequestHeader Map 을 사용함으로 리턴 값의 코드가 변경된다
return new ResponseEntity<>(new Member(email, name, phone), HttpStatus.CREATED);
- 코드(1)에서 작성한 MemberController의 postMember() 핸들러 메서드에서 개선해야 될 부분을 찾아본다
- 코드(1)에서는 회원 정보를 저장하기 위해서 총 세 개의 @RequestParam 애너테이션을 사용하고 있다
- 요청 데이터는 회원의 주소 정보, 로그인 패스워드, 패스워드 확인 정보 등 추가 내용이 회원 정보에 포함될 수 있다
- postMember()에 파라미터로 추가되는 @RequestParam의 개수는 늘어날 수 밖에 없다 - 클라이언트의 요청 데이터를 하나의 객체로 모두 전달 받을 수 있다면 코드 자체가 간결해질 수 있다
- DTO 클래스는 요청 데이터를 하나의 객체로 전달 받는 역할을 한다
// public /*String*/ResponseEntity postMember(@RequestHeader Map<String, String> headers,
// @RequestParam("email")String email,
// @RequestParam("name")String name,
// @RequestParam("phone")String phone) {
//코드(3)
public class ResponseEntity postMember(MemberDto memberDto){
로 변경하면
//코드(2)
//@RequestHeader Map 을 사용함으로 리턴 값의 코드가 변경된다
// return new ResponseEntity<>(new Member(email, name, phone), HttpStatus.CREATED);
//코드(3)
return new ResponseEntity<MemberDto>(memberDto, HttpStatus.CREATED);
으로 변경된다
- postMember()에서 @RequestParam을 사용하는 부분이 사라지고 MemberDto memberDto 를 추가한다
- 비즈니스 로직이 없는 상태이지만
- @RequestParam을 통해 전달 받은 요청 데이터들을 Map에 추가하는 로직을 삭제하고
- MemberDto 객체를 ResponseEntity 클래스의 생성자 파라미터로 전달하도록 변경하였다 - 일단, 코드는 매우 간결해졌다
2) 데이터 유효성(Validation) 검증의 단순화
- 지금까지 작성한 Controller의 핸들러 메서드는 클라이언트의 요청 데이터에 대한 유효성 검증 작업을 거치지 않았다
- 클라이언트 쪽에서 회원 정보의 email 주소를 ‘1111@gmail.com’ 같은 이메일 주소 형식이 아닌 ‘1111’ 같은 단순 문자열로 전송해도 정상적으로 핸들러 메서드 쪽에서 전달 받을 수 있다
- 서버 쪽에서 유효한 데이터를 전달 받기 위해 데이터를 검증하는 것을 유효성(Validation)검증이라고 한다
@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);
}
...
...
}
- (1)은 정규 표현식을 사용해서 이메일 주소의 유효성을 검증하는 로직으로 핸들러 메서드 내에 직접적으로 포함되어 있다
- name, phone에 대한 유효성 검증도 필요하면 핸들러 내의 코드는 유효성을 검증하는 로직들로 코드가 복잡해 진다
- HTTP 요청을 전달 받는 핸들러 메서드는 요청을 전달 받는 것이 주 목적이기 때문에 최대한 간결하게 작성되는 것이 좋다
- 핸들러 메서드 내부에 있는 유효성 검사 로직을 외부로 뺄 수 있다면 핸들러 메서드의 간결함을 유지할 수 있을 것이다
- DTO 클래스를 사용하면 유효성 검증 로직을 DTO 클래스로 빼내어 핸들러 메서드의 간결함을 유지할 수 있다
import javax.validation.constraints.Email;
public class MemberDto {
//MemberDto의 email 멤버 변수에 유효성 검증을 적용한다
//email 멤버 변수에 @Email을 추가하여 클라이언트의 요청 데이터에 유효한 이메일 주소가 포함되어 있지 않을 경우
//유효성 검증에 실패하므로 클라이언트의 요청은 거부(reject)된다
//MemberDto 클래스에서 이메일에 대한 유효성 검증을 진행하므로
//MemberController의 postMember() 핸들러 메서드는 코드(5)와 같이 간결해진다
@Email //'org.springframework.boot:spring-boot-starter-validation'을 추가해 주어야 한다
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;
}
}
- 코드(5)가 적용된 MemberController class 코드이다
@RestController
@RequestMapping("v1/members"/*,produce=MediaType.APPLICATION_JSON_VALUE*/)
public class MemberController {
@PostMapping
// 코드(1)
// public /*String*/ResponseEntity postMember(@RequestHeader Map<String, String> headers,
// @RequestParam("email")String email,
// @RequestParam("name")String name,
// @RequestParam("phone")String phone) {
//코드(3)
// public class ResponseEntity postMember(MemberDto memberDto){
//코드(5)
public ResponseEntity postMember (@Valid/*'org.springframework.boot:spring-boot-starter-validation'을 추가해 주어야 한다*/MemberDto memberDto) {
// 코드(1)
// System.out.println("# email: " + email);
// System.out.println("# name: " + name);
// System.out.println("# phone: " + phone);
// JSON문자열 응답 타입을 수작업 코드에서 Map객체로 변경->produce애트리뷰트를 삭제할 수 있다
//MAp 객체를 리턴하면 내부적으로 응답 데이터를 JSON데이터로 자동 변환해야 한다고 인식한다
// Map<String, String> map = new HashMap<>();
// map.put("email", email);
// map.put("name", name);
// map.put("phone", phone);
//코드(4)
//email 유효성 검증 : 코드(1) @RequestParam 과 함게 사용 시 에러 없음
// 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);
//코드(2)
//@RequestHeader Map을 사용하여 전체 헤더 정보를 받아 온다
// for (Map.Entry<String, String> entry : headers.entrySet()){
// System.out.println("key: " + entry.getKey() +
// ", value: " + entry.getValue());
// }
//리턴 값을 변경된 ResponseEntity로 대체
//ResponseEntity 객체를 생성하고 생성자 파라미터로 map과 HttpStatus.CREATED를 반환한다
//HttpStatus.CREATED 는 201, created를 의미한다
// 코드(1)
// return new ResponseEntity<>(map, HttpStatus.CREATED);
//코드(2)
//@RequestHeader Map 을 사용함으로 리턴 값의 코드가 변경된다
// return new ResponseEntity<>(new Member(email, name, phone), HttpStatus.CREATED);
//코드(3)
return new ResponseEntity<MemberDto>(memberDto, HttpStatus.CREATED);
// 코드(1)
// String reponse =
// "{\"" +
// "email\":\""+email+"\"," +
// "\"name\":\""+name+"\",\"" +
// "phone\":\"" + phone+
// "\"}";
// return reponse;
}
- @Valid 애너테이션은 MemberDto 객체에 유효성 검증을 적용하게 해주는 애너테이션이다
- @Valid를 사용하기 위해서는 build.gradle에 아래 코드를 적용해야 한다
implementation 'org.springframework.boot:spring-boot-starter-validation'
Spring boot 2.3 version 이상부터는 spring-boot-starter-web 의존성이 분리되서
@validation 사용을 하기 위해 추가 해줘야 한다.
- DTO 클래스를 사용하는 가장 중요한 목적은
- 비용이 많이 드는 작업인 HTTP 요청의 수를 줄이고
- 도메인 객체와 분리하는 것이다
3. HTTP 요청/응답 데이터에 DTO 적용하기
- MemberController의 핸들러 메서드에 DTO 클래스를 적용해 본다
- 지금까지는 HTTP Request Body가 JSON 형식이 아닌 ‘x-www-form-urlencoded’ 형식의 데이터였다
- 프런트엔드와 함께 프로젝트를 진행 또는 하나의 제품이나 서비스를 제작할 경우
- 프런트엔드의 웹앱과 백엔드의 애플리케이션 간 기본 API 통신 프로토콜은 대부분 JSON 형식으로 한다 - 클라이언트에서 전달되는 요청 데이터 중에서 바디에 해당되는 데이터를 Request Body라고 한다
- @RequestBody 애너테이션의 추가에 따라 Request Body를 전달 받는 방식이 JSON 형식으로 변경된다
1) DTO 클래스 적용을 위한 코드 리팩토링 절차
- 회원 정보를 전달 받을 DTO 클래스를 생성한다
- MemberCotroller에서 현재 회원 정보로 전달 받는 각 데이터 항목(email, name, phone)들을 DTO 클래스의 멤버 변수로 추가한다 - 클라이언트 쪽에서 전달하는 요청 데이터를 @RequestParam 애너테이션으로 전달 받는 핸들러 메서드를 찾는다
- Request Body가 필요한 핸들러는 HTTP POST, PATCH, PUT 같이 리소스의 추가나 변경이 발생하는 경우이다
- HTTP GET은 리소스를 조회하는 용도이기 때문에 Request Body는 필요가 없다
- @PostMapping, @PatchMapping 애너테이션이 붙은 핸들러 메서드를 찾는것과 동일하다 - @RequestParam의 코드를 DTO 클래스의 객체로 수정한다
- Map 객체로 작성되어 있는 Response Body를 DTO 클래스의 객체로 변경한다
import javax.validation.constraints.Email;
public class MemberDto {
@Email
private String email;
private String name;
private String phone;
private long memberId;
public String getEmail() {
return email;
}
public String getName() {
return name;
}
public String getPhone() {
return phone;
}
//--------------------------------- 회원정보 Dto class 작성
//--------------------------------- RequestBody를 전달받기 위한 Dto class 작성
public void setEmail(String email) {
this.email = email;
}
public void setName(String name) {
this.name = name;
}
public void setPhone(String phone) {
this.phone = phone;
}
public long getMemberId(){
return memberId;
}
public void getMemberId(long memberId) {
this.memberId = memberId;
}
}
- 위의 코드는 회원 정보 등록과 수정 시 Request Body를 전달 받기 위한 Dto 클래스이다
- DTO 클래스를 만들 때에는 멤버 변수 이외에 각 멤버 변수에 해당하는 getter 메서드가 있어야 한다
- getter 메서드가 없으면 Response Body에 해당 멤버 변수의 값이 포함되지 않는 문제가 발생한다
- setter 메서드는 개발자의 필요에 의해서 생성할 수 있다
- getter/setter를 만들 경우 IntelliJ IDE에서 지원하는 Generate Code 기능을 사용하면 편리하다
- IntelliJ Generate Code 기능 : https://www.jetbrains.com/idea/guide/tips/generate-getters-and-setters/
Generate constructors and accessor methods - IntelliJ IDEA Guide
You can use ⌘N (macOS), or Alt+Insert (Windows/Linux) for the Generate menu and then select Constructor, Getter, Setter or Getter and Setter.
www.jetbrains.com
※ lombok 라이브러리를 등록하면 getter/setter 메서드가 내부에서 자동으로 만들어진다
- @RequestParam을 사용했던 코드를 DTO 클래스를 사용하여 수정하였다
- postMember()에서는 MemberPostDto 클래스의 객체를
- patchMember()에서는 MemberPatchDto 클래스의 객체를 통해 Request Body를 한번에 전달 받을 수 있도록 하였다 - @RequestParam을 사용했던 코드가 간결해진 것을 확인할 수 있다
@RestController
@RequestMapping("v1/members"/*,produce=MediaType.APPLICATION_JSON_VALUE*/)
@RestController
@RequestMapping("v1/members"/*,produce=MediaType.APPLICATION_JSON_VALUE*/)
public class MemberController {
@PostMapping
public ResponseEntity postMember (@RequestBody MemberPostDto memberPostDto) {
return new ResponseEntity<>(memberPostDto, HttpStatus.CREATED);
}
//추가 코드 : patchMember
@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, MemberGetDto memberGetDto) {
System.out.println("# memberId: " + memberId); //터미널에 출력되는 코드이다
// not implementation
return new ResponseEntity<>(memberGetDto, HttpStatus.OK);
}
@GetMapping //별도의 URI를 지정해주지 않았기 때문에 클래스 레벨의 URI(“/v1/members”)에 매핑된다
public ResponseEntity getMembers(MemberGetDto memberGetDto) {
System.out.println("# get Members"); //터미널에 출력되는 코드이다
// not implementation
return new ResponseEntity<>(memberGetDto, HttpStatus.OK);
}
//추가 코드 : getDelete
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(@PathVariable("member-id")long memberId) {
// No need business logic
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
}
- postman을 통해 post를 보내보면 아래와 같이 데이터가 입력되는 것을 확인할 수 있다
4. Annotation
1) @RequestBody
- JSON 형식의 Request Body를 Dto 클래스의 객체로 변환을 시켜주는 역할을 한다
- 클라이언트에서 전송하는 Request Body가 JSON 형식이어야 하기 때문이다 - JSON 형식이 아닌 다른 형식의 데이터를 전송하면 Spring 내부에서 ‘Unsupported Media Type’ 과 같은 에러 메시지를 포함한 응답을 전달한다
2) @ResponseBody
- JSON 형식의 Response Body를 클라이언트에게 전달하기 위해 DTO 클래스의 객체를 Response Body로 변환한다
- postMember(), patchMember()에는 @ResponseBody 를 사용하는 곳이 없다
- postMember(), patchMember() 핸들러 메서드의 리턴 값이 ResponseEntity 클래스의 객체이기 때문이다 - Spring MVC에서는 핸들러 메서드에 @ResponseBody 애너테이션이 붙거나 핸들러 메서드의 리턴 값이 ResponseEntity일 경우, 내부적으로 HttpMessageConverter가 동작하게 되어 응답 객체(여기서는 DTO 클래스의 객체)를 JSON 형식으로 바꿔준다
3) JSON 직렬화(Serialization)와 역직렬화(Deserialization)
- 클라이언트에서 JSON 형식의 데이터를 서버 쪽으로 전송하면 서버의 웹 애플리케이션은 전달 받은 JSON 형식의 데이터를 DTO 같은 Java의 객체로 변환하는 기능을 역직렬화(Deserialization)이라고 한다
- 서버에서 클라이언트에게 응답 데이터를 전송하기 위해 DTO 같은 Java의 객체를 JSON 형식으로 변환하는 것을 직렬화(Serialization)라고 한다
- JSON 직렬화(Serialization) : Java 객체 → JSON
- JSON 역직렬화(Deserialization) : JSON → Java 객체