[실습] Controller 구현 실습 정답
MemberController 핸들러 메서드 구현 1
- 구현 내용
- memberId가 1인 회원의 회원 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- phone(휴대폰 번호) 정보를 ‘010-1111-2222’로 수정하세요.
- ⭐ 여러분들이 아직 데이터를 실제로 데이터베이스에 저장하는 학습을 진행하지 않았기 때문에 memberId가 1인 회원이 데이터베이스에 저장되어 있지 않고, 코드 h-1과 같이 members Map에 저장되어 있습니다. 따라서 members Map에서 memberId가 1인 회원 정보(member1)를 얻어서 요청으로 전달받은 phone 정보를 업데이트 한 뒤에 응답으로 전송하면 됩니다.
- members Map은 key를 memberId로 가지고, 회원 정보를 포함한 map 객체를 value로 가집니다.
- memberId가 1인 회원의 회원 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- 구현 조건
- memberId는 URI 경로에 포함되어야 합니다.
- 수정을 위한 휴대폰 번호는 클라이언트의 요청 데이터에 포함되어야 합니다.
- 응답 바디(Body)로 Map 객체를 사용하고 아래 데이터를 포함해야 합니다.
- 회원 식별자
- 이메일
- 이름
- 수정된 휴대폰 번호
- 핸들러 메서드의 리턴값은 ResponseEntity 객체여야 하며, 응답 데이터를 포함해야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 200이어야 합니다.
- 서비스 계층을 배우지 않았기 때문에 회원 정보를 실제로 수정하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-1]과 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “200 OK”로 표시되어야 합니다.
- 수정된 휴대폰 번호인 “010-1111-2222”가 응답 바디(Body)에 포함되어 있어야 합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-1]과 같이 응답 결과를 받아야 합니다.
MemberController 핸들러 메서드 구현 2
- 구현 내용
- memberId가 1인 회원 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 이 의미는 회원 탈퇴로 인한 회원 정보 삭제를 의미합니다.
- ⭐ 역시 아직 데이터를 실제로 데이터베이스에 저장하는 학습을 진행하지 않았기 때문에 members Map에 포함된 memberId가 1인 회원 정보를 제거(remove)하면 됩니다.
- memberId가 1인 회원 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 구현 조건
- memberId는 URI 경로에 포함되어야 합니다.
- 핸들러 메서드의 리턴 값은 ResponseEntity 객체여야 합니다.
- 응답 바디(Response Body) 데이터는 null이어야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 204여야 합니다.
- 서비스 계층을 배우지 않았기 때문에 회원 정보를 실제로 삭제하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, \[그림 h-2]와 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “204 NO Content”로 표시되어야 합니다.
- 전달받은 응답 바디(Body)가 없어야 합니다.
- Postman에서는 응답 바디(Response Body)가 null이거나, 서버 쪽에서 에러 발생으로 응답 바디가 정상적으로 전송되지 않았을 경우 [Pretty] 탭에서 1을 표시합니다.
- [Raw] 또는 [Preview] 탭에서는 아무것도 표시되지 않습니다.
- Postman에서는 응답 바디(Response Body)가 null이거나, 서버 쪽에서 에러 발생으로 응답 바디가 정상적으로 전송되지 않았을 경우 [Pretty] 탭에서 1을 표시합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, \[그림 h-2]와 같이 응답 결과를 받아야 합니다.
MemberController
package com.springboot.member;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/v1/members")
public class MemberController {
private final Map<Long, Map<String, Object>> members = new HashMap<>();
@PostConstruct
public void init() {
Map<String, Object> member1 = new HashMap<>();
long memberId = 1L;
member1.put("memberId", memberId);
member1.put("email", "hgd@gmail.com");
member1.put("name", "홍길동");
member1.put("phone", "010-1234-5678");
members.put(memberId, member1);
}
//---------------- 여기서 부터 아래에 코드를 구현하세요! --------------------//
// 1. 회원 정보 수정을 위한 핸들러 메서드 구현
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(@PathVariable("member-id") long memberId,
@RequestParam String phone) {
// memberId가 없을 때는 404 NOT FOUND
if (!members.containsKey(memberId)) {
return new ResponseEntity(HttpStatus.NOT_FOUND);
}
Map<String, Object> member = members.get(memberId);
member.put("phone", phone);
return new ResponseEntity(member, HttpStatus.CREATED);
}
// 2. 회원 정보 삭제를 위한 핸들러 메서드 구현
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(@PathVariable("member-id") long memberId) {
// memberId가 없을 때는 404 NOT FOUND
if (!members.containsKey(memberId)) {
return new ResponseEntity(HttpStatus.NOT_FOUND);
}
members.remove(memberId);
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
CoffeeController 핸들러 메서드 구현 1
- 구현 내용
- coffeeId가 1인 커피 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- 커피의 한글 커피명을 ‘바닐라 빈 라떼’로 수정하세요.
- 커피 가격을 5000원으로 수정하세요.
- ⭐ 회원 정보와 마찬가지로 커피 정보 역시 coffeeId가 1인 커피 정보가 데이터베이스에 저장되어 있지 않고, 코드 h-2와 같이 coffees Map에 저장되어 있습니다. 따라서 coffees Map에서 coffeeId가 1인 커피 정보(coffee1)를 얻어서 요청으로 전달받은 한글 커피명과 커피 가격을 업데이트 한 뒤에 응답으로 전송하면 됩니다.
- coffees Map은 key를 coffeeId로 가지고, 커피 정보를 포함한 map 객체를 value로 가집니다.
- coffeeId가 1인 커피 정보 중에서 아래 정보를 수정하는 핸들러 메서드를 구현하세요.
- 구현 조건
- coffeeId는 URI 경로에 포함되어야 합니다.
- 수정을 위한 커피 정보는 클라이언트의 요청에 포함되어야 합니다.
- 응답 바디(Body)로 Map 객체를 사용하고 아래 데이터를 포함해야 합니다.
- 커피 식별자
- 수정된 한글 커피명
- 영문 커피명
- 수정된 커피 가격
- 핸들러 메서드의 리턴값은 ResponseEntity 객체여야 하며, 응답 바디(Body)를 포함해야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 200이어야 합니다.
- 서비스 계층을 배우지 않았기 때문에 커피 정보를 실제로 수정하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-3]과 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “200 OK”로 표시되어야 합니다.
- 수정된 한글 커피명인 “바닐라 빈 라떼”가 응답 바디(Body)에 포함되어 있어야 합니다.
- 수정된 커피 가격인 5000이 응답 바디(Body)에 포함되어 있어야 합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-3]과 같이 응답 결과를 받아야 합니다.
CoffeeController 핸들러 메서드 구현 2
- 구현 내용
- coffeeId가 1인 커피 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 이 의미는 커피 정보의 삭제를 의미합니다.
- ⭐ 역시 아직 데이터를 실제로 데이터베이스에 저장하는 학습을 진행하지 않았기 때문에 coffees Map에 포함된 coffeeId가 1인 커피 정보를 제거(remove)하면 됩니다.
- coffeeId가 1인 커피 정보를 삭제하는 핸들러 메서드를 구현하세요.
- 구현 조건
- coffeeId는 URI 경로에 포함되어야 합니다.
- 핸들러 메서드의 리턴 값은 ResponseEntity 객체여야 합니다.
- 응답 바디(Response Body) 데이터는 null이어야 합니다.
- HTTP 응답 상태 코드(Response Status Code)는 204여야 합니다.
- 서비스 계층을 배우지 않았기 때문에 커피 정보를 실제로 삭제하는 비즈니스 로직은 필요 없습니다.
- 실행 결과
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-4]와 같이 응답 결과를 받아야 합니다.
- HTTP Response Status가 “204 NO Content”로 표시되어야 합니다.
- 전달받은 응답 바디(Body)가 없어야 합니다.
- Postman에서 핸들러 메서드에게 API 요청을 보냈을 때, [그림 h-4]와 같이 응답 결과를 받아야 합니다.
package com.springboot.coffee;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/v1/coffees")
public class CoffeeController {
private final Map<Long, Map<String, Object>> coffees = new HashMap<>();
@PostConstruct
public void init() {
Map<String, Object> coffee1 = new HashMap<>();
long coffeeId = 1L;
coffee1.put("coffeeId", coffeeId);
coffee1.put("korName", "바닐라 라떼");
coffee1.put("engName", "Vanilla Latte");
coffee1.put("price", 4500);
coffees.put(coffeeId, coffee1);
}
//---------------- 여기서 부터 아래에 코드를 구현하세요! -------------------//
// 1. 커피 정보 수정을 위한 핸들러 메서드 구현
@PatchMapping("/{coffee-id}")
public ResponseEntity updateCoffee(@PathVariable("coffee-id") long coffeeId,
@RequestParam("korName") String korName,
@RequestParam("price") int price) {
// coffeeId가 없을 때는 404 NOT FOUND
if (!coffees.containsKey(coffeeId)) {
return new ResponseEntity(HttpStatus.NOT_FOUND);
}
Map<String, Object> coffee = coffees.get(coffeeId);
coffee.put("korName", korName);
coffee.put("price", price);
return new ResponseEntity(coffee, HttpStatus.OK);
}
// 2. 커피 정보 삭제를 위한 핸들러 서드 구현
@DeleteMapping("/{coffee-id}")
public ResponseEntity deleteCoffee(@PathVariable("coffee-id") long coffeeId) {
//coffeeId가 없을 때는 404 NOT FOUND
if (!coffees.containsKey(coffeeId)) {
return new ResponseEntity(HttpStatus.NOT_FOUND);
}
coffees.remove(coffeeId);
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
Chapter - DTO(Data Transfer Object)
이번 챕터에서는 DTO(Data Transfer Object)가 무엇인지 알아보고, 클라이언트의 요청을 DTO로 변환한 후에 이 DTO를 다시 응답으로 변환하는 방법을 살펴보도록 하겠습니다.
그리고 클라이언트의 요청 데이터에 Validation(유효성 검증)을 적용하여 요청 데이터의 안전성을 보장하는 방법 또한 살펴보도록 하겠습니다.
학습 목표
- DTO(Data Transfer Object)
- DTO가 무엇인지 이해할 수 있다.
- DTO를 Controller 클래스에 적용할 수 있다.
- DTO Validation이 무엇인지 이해할 수 있다.
[기본] HTTP 요청/응답에서의 DTO(Data Transfer Object)
DTO(Data Transfer Object)란?
DTO란 무엇일까요? DTO는 Data Transfer Object의 약자로 마틴 파울러(Martin Fowler)가 ‘Patterns of Enterprise Application Architecture’라는 책에서 처음 소개한 엔터프라이즈 애플리케이션 아키텍처 패턴의 하나입니다.
Transfer라는 의미에서 알 수 있듯이 데이터를 전송하기 위한 용도의 객체 정도로 생각할 수 있습니다.
지금까지 배운 내용만으로 생각해 본다면 데이터 전송은 어떤 구간에서 이루어질까요?
맞습니다. 바로 클라이언트에서 서버 쪽으로 전송하는 요청 데이터, 서버에서 클라이언트 쪽으로 전송하는 응답 데이터의 형식으로 클라이언트와 서버 간에 데이터 전송이 이루어집니다.
여러분은 이 구간에서 DTO를 사용할 수 있습니다.
마틴 파울러(Martin Fowler)에 대해서 더 궁금하다면 아래 [심화 학습]을 확인하세요.
DTO가 필요한 이유
그렇다면 클라이언트와 서버 간에 데이터를 주고받을 때 DTO가 필요한 이유는 무엇일까요?
이 전 챕터에서 작성한 MemberController를 통해 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> map = new HashMap<>();
map.put("email", email);
map.put("name", name);
map.put("phone", phone);
return new ResponseEntity<Map>(map, HttpStatus.CREATED);
}
...
...
}
[코드 3-25] ResponseEntity까지 적용한 레거시 MemberController 코드 일부
DTO 클래스를 이용한 코드의 간결성
코드 3-25는 이 전 챕터까지 작성한 MemberController의 postMember() 핸들러 메서드입니다.
postMember()에서 개선해야 될 부분을 살펴보면서 DTO가 필요한 이유를 확인해 보도록 하겠습니다.
코드 3-25에서는 회원 정보를 저장하기 위해서 총 세 개의 @RequestParam 애너테이션을 사용하고 있습니다.
그런데 저희는 학습용이기 때문에 요청 데이터가 세 개밖에 없지만 실제로는 회원의 주소 정보, 로그인 패스워드, 패스워드 확인 정보 등 더 많은 정보들이 회원 정보에 포함되어 있을 수 있습니다.
따라서 postMember()에 파라미터로 추가되는 @RequestParam의 개수는 계속 늘어날 수밖에 없습니다.
이 경우, 클라이언트의 요청 데이터를 하나의 객체로 모두 전달받을 수 있다면 코드 자체가 굉장히 간결해질 것 같습니다.
DTO 클래스가 바로 요청 데이터를 하나의 객체로 전달받는 역할을 해줍니다.
코드 3-25를 DTO 클래스를 적용해서 바꾼다면 코드 3-26과 같은 모습이 될 것입니다.
@RestController
@RequestMapping("/v1/members")
public class MemberController {
@PostMapping
public ResponseEntity postMember(MemberDto memberDto) {
return new ResponseEntity<MemberDto>(memberDto, HttpStatus.CREATED);
}
...
...
}
[코드 3-26] 회원 정보를 전달받기 위해 DTO 클래스를 적용한 모습
코드 3-26을 보면 postMember()에서 @RequestParam을 사용하는 부분이 사라지고 MemberDto memberDto가 추가되었습니다.
그리고 아직 비즈니스 로직이 없긴 하지만 어쨌든 @RequestParam을 통해 전달받은 요청 데이터들을 Map에 추가하는 로직이 사라지고, MemberDto 객체를 ResponseEntity 클래스의 생성자 파라미터로 전달하도록 변경되었습니다.
결과적으로 DTO 클래스를 사용하니 코드 자체가 매우 간결해진 것을 알 수 있습니다.
데이터 유효성(Validation) 검증의 단순화
여러분들이 지금까지 작성한 Controller의 핸들러 메서드는 클라이언트의 요청 데이터에 대한 유효성 검증 작업을 거치지 않았습니다.
예를 들면, 클라이언트 쪽에서 회원 정보의 email 주소를 ‘hgd1234@gmail.com’ 같은 이메일 주소 형식이 아닌 ‘hgd1234’ 같은 단순 문자열로 전송해도 정상적으로 핸들러 메서드 쪽에서 전달받을 수 있습니다.
이처럼 서버 쪽에서 유효한 데이터를 전달받기 위해 데이터를 검증하는 것을 유효성(Validation) 검증이라고 합니다.
만약 코드 3-25의 레거시 코드에서 유효한 이메일 주소인지를 검증하는 상황을 생각하면 아마도 아래의 코드 3-27과 같이 작성할 수 있을 것입니다.
@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);
}
...
...
}
[코드 3-27] 핸들러 메서드 내에서의 이메일 주소 유효성 검증 예
코드 3-27의 코드를 보면 (1)과 같이 정규 표현식을 사용해서 이메일 주소의 유효성을 검증하는 로직이 핸들러 메서드 내에 직접적으로 포함이 된 것을 볼 수 있습니다.
만일 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;
}
}
[코드 3-28] MemberDto 클래스의 email 멤버 변수에 유효성 검증을 적용하는 예
코드 3-28은 MemberDto의 email 멤버 변수에 유효성 검증을 적용하는 예제 코드입니다.
email 멤버 변수에 @Email 애너테이션을 추가하면 클라이언트의 요청 데이터에 유효한 이메일 주소가 포함되어 있지 않을 경우 유효성 검증에 실패하기 때문에 클라이언트의 요청은 거부(reject)됩니다.
MemberDto 클래스에서 이메일에 대한 유효성 검증을 진행하므로, MemberController의 postMember() 핸들러 메서드는 아래의 코드 3-29와 같이 간결해지는 것을 볼 수 있습니다.
@RestController
@RequestMapping("/v1/members")
public class MemberController {
@PostMapping
public ResponseEntity postMember(@Valid MemberDto memberDto) {
return new ResponseEntity<MemberDto>(memberDto, HttpStatus.CREATED);
}
...
...
}
[코드 3-29] 핸들러 메서드에 유효성 검증이 포함된 DTO 클래스를 사용하는 예
DTO 유효성 검증
[코드 3-29]의 postMember() 핸들러 메서드의 파라미터인 MemberDto 앞에 붙은 @Valid 애너테이션은 MemberDto 객체에 유효성 검증을 적용하게 해주는 애너테이션입니다.
DTO 클래스의 유효성 검증에 대해서는 이어지는 챕터에서 자세히 설명하도록 하겠습니다.
아직 이야기하지 않은 DTO의 사용 목적 DTO 클래스를 사용하는 가장 중요한 목적은 비용이 많이 드는 작업인 HTTP 요청의 수를 줄이기 위함이라고 할 수 있습니다.
그리고, 도메인 객체와의 분리라는 또 다른 목적이 있긴 하지만 여러분들이 아직 서비스 계층에 대한 학습을 진행하지 않았기 때문에 제대로 와닿지 않을 가능성이 큽니다.
따라서 아직 이야기하지 않은 DTO의 사용 목적은 서비스 계층 학습이 어느 정도 진행된 이후에 설명을 하도록 하겠습니다.
지금은 앞에서 언급한 두 가지 이유만 이해할 수 있다면 여러분들이 DTO를 사용하여 학습을 진행하는 데에 큰 무리가 없을 거라고 생각합니다.
HTTP 요청/응답 데이터에 DTO 적용하기
DTO가 무엇인지 왜 필요한지 이제 어느 정도는 감이 잡혔을 거라고 생각합니다.
그럼 이제 이 전 챕터까지 작성되어 있는 MemberController의 핸들러 메서드에 DTO 클래스를 적용해 보도록 하겠습니다.
HTTP Request Body가 JSON 형식이 아닐 경우
우리가 이 전 챕터까지 클라이언트에서 전달하는 요청 데이터는 [그림 3-19]와 같이 ‘x-www-form-urlencoded’ 형식의 데이터였습니다.
[그림 3-19] JSON 형식이 아닌 클라이언트의 요청 데이터 형식 예
그런데 여러분들이 백엔드 과정을 마치고 프론트엔드 수강생들과 프로젝트를 진행하거나 현업에서 프론트엔드 개발자와 백엔드 개발자가 하나의 제품이나 서비스를 제작할 경우, 프론트엔드 쪽 웹앱과 백엔드 쪽 애플리케이션의 기본 API 통신 프로토콜은 대부분 JSON이 될 것입니다.
따라서 저희 백엔드 과정에서도 클라이언트와 서버 간에 JSON 형식의 요청 데이터를 주고받는 위주로 강의를 진행할 예정이니 이 점 참고 바랍니다.
지금까지는 여러분들의 이해를 돕기 위해서 클라이언트 쪽에서 전달되는 데이터를 요청 데이터로 불렀지만 이제부터는 요청 데이터 중에서 바디에 해당되는 데이터는 >Request Body라는 정확한 표현을 사용하도록 하겠습니다.
JSON 형식이 아닌 Request Body를 전달받는 방식과 바로 이어서 학습하게 되는 JSON 형식의 Request Body를 전달받는 방식은 @RequestBody 애너테이션 하나를 추가하느냐 그렇지 않으냐 외에 큰 차이점이 있는 것은 아니니 여러분들이 JSON 형식 이외의 Request Body를 전달받는 학습을 별도로 진행하는 데는 큰 무리가 없을 거라고 생각합니다.
우리는 JSON 형식의 Request Body를 처리하는 부분에 집중을 하도록 하겠습니다
HTTP Request Body가 JSON 형식일 경우
그럼 이 전 챕터까지 작성된 MemberController의 코드를 보면서 HTTP Request Body에 DTO를 적용할 부분을 먼저 살펴보도록 합시다.
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);
}
}
[코드 3-30] DTO가 적용되지 않은 레거시 MemberController
코드 3-30은 이 전 챕터까지 작성된 DTO가 적용되지 않은 레거시 MemberController의 코드입니다.
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 클래스의 객체로 변경한다.
✔️ MemberPostDto 및 MemberPatchDto 클래스 생성
코드 리팩토링 절차에 따라서 회원 정보에 대한 DTO 클래스를 먼저 생성합니다.
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;
}
}
[코드 3-31] 회원 정보 등록에 사용되는 MemberPostDto 클래스
코드 3-31은 회원 정보 등록 시, Request Body를 전달받을 때 사용하기 위한 MemberPostDto 클래스입니다.
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;
}
}
[코드 3-31] 회원 정보 수정에 사용되는 MemberPatchDto 클래스
코드 3-31은 회원 정보 수정 시, Request Body를 전달받을 때 사용하기 위한 MemberPatchDto 클래스입니다.
DTO 클래스를 만들 때, 주의해야 할 부분은 멤버 변수 이외에 각 멤버 변수에 해당하는 getter 메서드가 있어야 한다는 것입니다.
getter 메서드가 없으면 Response Body에 해당 멤버 변수의 값이 포함되지 않는 문제가 발생합니다.
setter 메서드는 필수 항목은 아니지만 개발자의 필요에 의해서 있을 수도 있고, 없을 수도 있습니다.
자바 베이스의 개발을 진행하다 보면 getter/setter 메서드를 작성해야 되는 경우가 상당히 빈번합니다.
이 경우, 개발자가 일일이 getter/setter 메서드를 작성하는 것은 비효율적인 측면이 있습니다.
따라서 getter/setter를 만들어야 한다면 IntelliJ IDE에서 지원하는 Generate Code 기능을 사용하기 바랍니다.
- IntelliJ Generate Code 기능 사용법 링크: https://www.jetbrains.com/idea/guide/tips/generate-getters-and-setters/
현업에서는 아예 lombok이라는 라이브러리를 이용해서 getter/setter 메서드를 내부에서 자동으로 만들어 사용하는데 이 부분은 추후에 설명드리도록 하겠습니다.
✔️ 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);
}
}
[코드 3-32] Dto가 적용된 MemberController
코드 3-32에서는 @RequestParam을 사용하는 대신에 DTO 클래스를 사용해서 postMember()에서는 MemberPostDto, patchMember()에서는 MemberPatchDto 클래스의 객체를 통해서 Request Body를 한 번에 전달받을 수 있도록 수정했습니다.
코드 3-30에서 postMember()의 경우, @RequestParam을 각각 세 번 사용하여 Request Body를 전달받은 것과 달리 MemberPostDto 객체로 Request Body를 한 번에 전달받음으로써 코드가 간결해졌습니다.
그럼 코드 3-32의 MemberController의 postMember()와 patchMember() 핸들러 메서드를 Postman에서 실행해 봅시다.
[그림 3-20] postMember() 핸들러 메서드 호출 결과
[그림 3-20]은 Postman으로 postMember() 핸들러 메서드를 호출한 결과입니다.
Response Body 결과는 우리가 이 전 챕터에서 확인했던 것처럼 JSON 형식입니다.
그런데 코드 3-32에서 전달받는 HTTP Request Body가 JSON 형식이어야 하기 때문에 클라이언트 쪽에서 전달하는 Request Body 역시 JSON 형식으로 입력을 해야 합니다.
Postman에서 Request Body를 JSON 형식으로 입력하려면 [그림 3-20]과 같이 [Body] 탭에서 [raw] 라디오 버튼에 체크한 후, 셀렉트 박스에서 ‘JSON’을 선택하면 됩니다.
[그림 3-21] patchMember() 핸들러 메서드 호출 결과
[그림 3-21]은 Postman으로 patchMember() 핸들러 메서드를 호출한 결과입니다.
postMember()의 호출 때와 마찬가지로 수정하고자 하는 phone 번호를 JSON 형식으로 전달하고 있습니다.
✔️ @RequestBody 애너테이션
코드 3-32에서 MemberPostDto 클래스 앞에 붙은 @RequestBody 애너테이션은 JSON 형식의 Request Body를 MemberPostDto 클래스의 객체로 변환을 시켜주는 역할을 합니다.
이 말의 의미는 클라이언트 쪽에서 전송하는 Request Body가 JSON 형식이어야 한다는 말과 같습니다.
만일 JSON 형식이 아닌 다른 형식의 데이터를 전송한다면, Spring 내부에서 ‘Unsupported Media Type’과 같은 에러 메시지를 포함한 응답을 전달합니다.
✔️ @ResponseBody 애너테이션
@RequestBody의 역할이 클라이언트 쪽에서 전송한 JSON 형식의 Request Body를 DTO 클래스의 객체로 변환하는 것이라면, @ResponseBody는 JSON 형식의 Response Body를 클라이언트에게 전달하기 위해 DTO 클래스의 객체를 Response Body로 변환하는 역할을 합니다.
그런데 postMember(), patchMember()에는 **@ResponseBody**를 사용하는 곳이 없습니다.
어떻게 된 것일까요?
이유는 바로 postMember(), patchMember() 핸들러 메서드의 리턴 값이 ResponseEntity 클래스의 객체이기 때문입니다.
Spring MVC에서는 핸들러 메서드에 @ResponseBody 애너테이션이 붙거나 핸들러 메서드의 리턴 값이 ResponseEntity일 경우, 내부적으로 HttpMessageConverter가 동작하게 되어 응답 객체(여기서는 DTO 클래스의 객체)를 JSON 형식으로 바꿔줍니다.
HttpMessageConverter에 대해서 더 알아보고 싶다면 아래 [심화 학습]을 참고하세요.
JSON 직렬화(Serialization)와 역직렬화(Deserialization)
클라이언트 쪽에서 JSON 형식의 데이터를 서버 쪽으로 전송하면 서버 쪽의 웹 애플리케이션은 전달받은 **JSON 형식의 데이터를 DTO 같은 Java의 객체로 변환하는데 이를 역직렬화(Deserialization)**이라고 합니다.
반면에 서버 쪽에서 클라이언트에게 응답 데이터를 전송하기 위해서 DTO 같은 **Java의 객체를 JSON 형식으로 변환하는 것을 직렬화(Serialization)**라고 합니다.
JSON 직렬화(Serialization): Java 객체 → JSON JSON 역직렬화(Deserialization): JSON → Java 객체
DTO 클래스의 대표적인 단점
우리가 앞에서 살펴본 DTO 클래스는 MemberController에 해당되는 MemberPostDto와 MemberPatchDto입니다.
그런데 우리가 현재 작성하고 있는 Controller 클래스는 CoffeeController와 OrderController도 있습니다. 따라서 이 두 개 Controller 클래스에 해당하는 DTO 클래스를 추가적으로 작성해야 합니다.
결과적으로 Controller 클래스가 늘어남에 따라 DTO 클래스가 두 배(ex. xxxxPostDto + xxxxPatchDto)씩 늘어나는 것을 볼 수 있습니다.
이 부분은 공통된 멤버 변수의 추출 및 내부 클래스를 이용해서 어느 정도 개선이 가능한데, 이 부분은 필요하다면 코스 후반부에 개선을 해보도록 하겠습니다.
지금은 DTO 클래스가 무엇이고, DTO 클래스를 기본적으로 어떻게 사용할 수 있는지에 대한 부분에 집중을 해주세요!
핵심 포인트
- DTO는 Data Transfer Object의 약자로 마틴 파울러(Martin Fowler)가 ‘Patterns of Enterprise Application Architecture’라는 책에서 처음 소개한 엔터프라이즈 애플리케이션 아키텍처 패턴의 하나이다.
- DTO는 주로 클라이언트에서 서버 쪽으로 전송하는 요청 데이터를 전달받을 때, 서버에서 클라이언트 쪽으로 전송하는 응답 데이터를 전송하기 위한 용도로 사용된다.
- DTO가 필요한 이유
- 클라이언트의 Request Body를 하나의 객체로 모두 전달받을 수 있기 때문에 코드 자체가 간결해진다.
- Request Body의 데이터 유효성(Validation) 검증이 단순해진다.
- JSON 형식의 Request Body를 전달받기 위해서는 DTO 객체에 @RequestBody 애너테이션을 붙여야 한다.
- Response Body를 JSON 형식으로 전달하기 위해서는 @ResponseBody 애너테이션을 메서드 앞에 붙여 주어야 하지만 ResponseEntity 객체를 리턴 값으로 사용할 경우 @ResponseBody를 생략할 수 있다.
- 클라이언트 쪽에서 JSON 형식의 데이터를 서버 쪽으로 전송하면 서버 쪽의 웹 애플리케이션은 전달받은 JSON 형식의 데이터를 DTO 같은 Java의 객체로 변환하는데 이를 역직렬화(Deserialization)이라고 한다.
- 서버 쪽에서 클라이언트에게 응답 데이터를 전송하기 위해서 DTO 같은 Java의 객체를 JSON 형식으로 변환하는 것을 직렬화(Serialization)라고 한다.
심화 학습
- MemberController를 참고해서 CoffeeController의 postCoffee(), patchCoffee() 핸들러 메서드에 DTO 클래스를 적용해 보세요.
CoffeeController의 레거시 코드는 [그림 p-1]과 같을 것입니다.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/v1/coffees")
public class CoffeeController {
@PostMapping
public ResponseEntity postCoffee(@RequestParam("korName") String korName,
@RequestParam("engName") String engName,
@RequestParam("price") int price) {
Map<String, Object> map = new HashMap<>();
map.put("korName", korName);
map.put("engName", engName);
map.put("price", price);
return new ResponseEntity<Map>(map, HttpStatus.CREATED);
}
@PatchMapping("/{coffee-id}")
public ResponseEntity patchCoffee(@PathVariable("coffee-id") long coffeeId,
@RequestParam("korName") String korName,
@RequestParam("price") int price) {
Map<String, Object> body = new HashMap<>();
body.put("coffeeId", coffeeId);
body.put("korName", korName);
body.put("engName", "Vanilla Latte");
body.put("price", price);
return new ResponseEntity(body, HttpStatus.OK);
}
@GetMapping("/{coffee-id}")
public ResponseEntity getCoffee(@PathVariable("coffee-id") long coffeeId) {
System.out.println("# coffeeId: " + coffeeId);
// not implementation
return new ResponseEntity<Map>(HttpStatus.OK);
}
@GetMapping
public ResponseEntity getCoffees() {
System.out.println("# get Coffees");
// not implementation
return new ResponseEntity<Map>(HttpStatus.OK);
}
@DeleteMapping("/{coffee-id}")
public ResponseEntity deleteCoffee(@PathVariable("coffee-id") long coffeeId) {
// No need business logic
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
[p-1] DTO가 적용되지 않은 레거시 CoffeeController<br />
- 구현 조건
- patchCoffee()의 경우 커피명(한글, korName), 커피명(영문, engName), 가격(price) 모두 수정 가능하나, Postman에서는 가격(price)만 6000원으로 수정한다고 가정합니다.
- DB에 저장되어 있는 커피 정보는 아래와 같다고 가정합니다.
- coffeeId: 1
- korName: “바닐라 라떼”
- engName: “Vanilla Latte”
- price: 5000
DTO 실습 챕터에서 CoffeeController의 최종 완성본을 실습해 볼 예정입니다.
지금은 DTO에 익숙해지는 시간이라 생각하면서 편하게 작성해 보세요.
- 아래 링크에서 마틴 파울러(Martin Fowler)에 대해서 좀 더 알아보세요.
- HttpMessageConverter에 대해서 좀 더 알아보고 싶다면 아래 링크를 클릭하세요.
- HttpMessageConverter 관련 자료: https://itvillage.tistory.com/46
[기본] DTO 유효성 검증(Validation)
이 전 챕터에서 HTTP 요청/응답 데이터에 DTO 클래스를 적용해 보았습니다.
이번 시간에는 지난 시간에 잠깐 언급하고 넘어갔던 DTO 클래스의 유효성 검증에 대해서 알아보도록 하겠습니다.
DTO 유효성 검증(Validation)이 필요한 이유
DTO 클래스의 유효성 검증은 왜 필요할까요?
먼저 우리가 만드는 샘플 애플리케이션과 REST API 통신을 하는 프론트엔드(Frontend) 쪽 웹 앱이 아래 [그림 3-22]와 같다고 가정하겠습니다.
[그림 3-7] 계층 기반 구조 패키지 구조 예
일반적으로 프론트엔드 쪽 웹 사이트에서는 자바스크립트를 이용해서 사용자가 입력하는 입력 폼 필드의 값에 대해 1차적으로 유효성 검증을 진행합니다.
[그림 3-7] 계층 기반 구조 패키지 구조 예
[그림 3-23]은 커피 주문 웹 앱의 회원 가입 필드 중에서 이메일 입력 필드 값에 대한 유효성 검사를 진행하는 모습입니다.
[그림 3-23]처럼 잘못된 형식의 이메일 주소를 입력하면 프론트엔드 쪽에서 1차적으로 먼저 유효성 검사를 진행한 후에 사용자에게 이를 알려주는 것이 일반적입니다.
프론트엔드 쪽에서 유효성 검증을 하는데 굳이 백엔드 애플리케이션 쪽에서 유효성 검증을 할 필요가 있나 싶지만 프론트엔드 쪽에서 유효성 검사에 통과되었다고 그 값이 반드시 유효한 값이라고 보장할 수 없습니다.
프론트엔드 영역은 저희 강의 주제에서 벗어나기 때문에 자세히 언급하지는 않겠지만 어쨌든 [그림 3-23]에서 유효성 검사를 통과한 뒤 [등록] 버튼을 누르면 서버 쪽으로 HTTP Request 요청이 전송될 것입니다.
하지만 자바스크립트로 전송되는 데이터는 브라우저의 개발자 도구를 사용해서 브레이크포인트(breakpoint)를 추가한 뒤에 얼마든지 그 값을 조작할 수 있기 때문에 프론트엔드 쪽에서 유효성 검사를 진행했다고 하더라도 서버 쪽에서 한번 더 유효성 검사를 진행해야 된다는 사실을 기억하세요!
프론트엔드 쪽에서 진행하는 유효성 검사는 사용자 편의성 때문에 진행한다고 생각하는 것이 좋습니다.
DTO 클래스에 유효성 검증 적용하기
그럼 이제 MemberController에서 사용된 MemberPostDto 클래스와 MemberPatchDto 클래스에 유효성 검증을 적용해 보도록 하겠습니다.
유효성 검증을 위한 의존 라이브러리 추가
DTO 클래스에 유효성 검증을 적용하기 위해서는 Spring Boot에서 지원하는 Starter가 필요합니다.
아래와 같이 build.gradle 파일의 dependencies 항목에 'org.springframework.boot:spring-boot-starter-validation’을 추가하세요.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
...
...
}
MemberPostDto 유효성 검증
✔️ MemberPostDto 유효성 검증 제약 사항
- email (이메일 주소)
- 값이 비어있지 않거나 공백이 아니어야 합니다.
- 유효한 이메일 주소 형식이어야 합니다
- name (이름)
- 값이 비어있지 않거나 공백이 아니어야 합니다.
- phone (휴대폰 번호)
- 값이 비어있지 않거나 공백이 아니어야 합니다.
- 아래와 같이 010으로 시작하는 11자리 숫자와 ‘-’로 구성된 문자열이어야 합니다.
- 예) 010-1234-5678
먼저 유효성 검증 요구 사항에 맞게 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;
}
}
[그림 3-7] 계층 기반 구조 패키지 구조 예
[코드 3-33] 유효성 검증(Validation)이 적용된 MemberPostDto
코드 3-33은 유효성 검증이 적용된 MemberPostDto의 코드입니다.
회원 등록을 위해 클라이언트에서 전달받는 Request Body의 데이터인 emil, name, phone 정보에 유효성 검증을 위한 애너테이션이 추가되었습니다.
MemberPostDto의 멤버 변수에 적용된 유효성 검증 내용은 다음과 같습니다.
- email
- @NotBlank
- 이메일 정보가 비어있지 않은지를 검증합니다.
- null 값이나 공백(””), 스페이스(” “) 같은 값들을 모두 허용하지 않습니다.
- 유효성 검증에 실패하면 에러 메시지가 콘솔에 출력됩니다.
- @Email
- 유효한 이메일 주소인지를 검증합니다.
- 유효성 검증에 실패하면 내장된 디폴트 에러 메시지가 콘솔에 출력됩니다.
- @NotBlank
- name
- @NotBlank
- 이름 정보가 비어있지 않은지를 검증합니다.
- null 값이나 공백(””), 스페이스(” “) 같은 값들을 모두 허용하지 않습니다.
- 유효성 검증에 실패하면 @NotBlank의 message 애트리뷰트에 지정한 문자열이 에러 메시지로 콘솔에 출력됩니다.
- @NotBlank
- phone
- @Pattern
- 휴대폰 정보가 정규표현식(Reqular Expression)에 매치되는 유효한 번호인지를 검증합니다.
- 유효성 검증에 실패하면 내장된 디폴트 에러 메시지가 콘솔에 출력됩니다.
- @Pattern
요청으로 전달받는 MemberPostDto 클래스의 각 멤버 변수에 유효성 검증을 위한 애너테이션을 추가함으로써 MemberController의 핸들러 메서드에 별도의 유효성 검증을 추가하지 않고, 깔끔하게 유효성 검증 로직이 분리되었습니다.
💡 Deprecated 된 @Email 애너테이션?
구글에서 @Email 애너테이션에 대해 검색을 하다 보면 @Email 이 Deprecated 되었다는 내용을 확인할 수도 있습니다.
여기서 Deprecated 된 @Email 애너테이션은 Hibernate Validator에서 지원하는 org.hibernate.validator.constraints.Email 애너테이션입니다.
우리는 javax에서 지원하는 표준 Email 애너테이션(javax.validation.constraints.Email)을 사용하고 있으니 혼동하지 않길 바랍니다.
💡 kevin1111@abcd와 같이 gmail.com 같은 이메일 주소 형식이 아닌데도 유효성 검증에 통과하는 이유
gmail.com과 같은 이메일 주소에서 gmial.com 은 도메인 네임(Domain Name)을 의미합니다. 그 중에서 .com은 최상위 도메인(TLD, Top-Level Domain)을 의미하는데 이메일 주소의 스펙(사양, Specification)을 확인해 보면 최상위 도메인이 없는 이메일 주소의 경우도 때로는 정상 이메일 주소로 허용을 하는 것을 확인할 수 있습니다.
따라서 만약 현실적으로 최상위 도메인까지 포함되어야 유효한 이메일 주소라고 판단하고 싶은 경우에는 정규 표현식을 이용해 조금 더 세밀한 유효성 검사 조건을 지정할 수 있다는 사실을 기억하기 바랍니다.
이메일 주소의 스펙에 대해 알아보고 싶다면 [심화 학습]을 참고해 주세요.
정규 표현식(Regular Expression)에 대해서 더 알아보려면 아래 **[심화 학습]**을 참고하세요.
유효성 검증 애너테이션을 추가한 MemberPostDto 클래스를 사용하는 MemberController 클래스의 postMember() 핸들러 메서드의 코드는 [코드 3-34]와 같습니다.
@RestController
@RequestMapping("/v1/members")
public class MemberController {
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
return new ResponseEntity<>(memberDto, HttpStatus.CREATED);
}
...
...
}
[코드 3-34] 유효성 검증이 적용된 MemberPostDto 클래스를 사용하는 postMember()
유효성 검증 애너테이션이 추가된 DTO 클래스에서 유효성 검증 로직이 실행되게 하기 위해서는 [코드 3-34]의 postMember()와 같이 DTO 클래스에 @Valid 애너테이션을 추가해야 합니다.
이제 샘플 애플리케이션을 가동하고 Postman으로 postMember() 핸들러 메서드에 요청을 전송해 보도록 하겠습니다.
[그림 3-24] Postman에서 유효하지 않은 데이터를 postMember() 핸들러 메서드로 전송
[그림 3-24]에서는 email, name, phone 정보를 모두 유효하지 않은 정보로 입력해서 postMember() 핸들러 메서드로 요청을 전송했습니다.
응답 결과는 Response Satus가 400인 ‘Bad Request’를 전달받았습니다.
이번에는 Spring MVC에서 출력한 로그를 IntelliJ IDE에서 확인해 보도록 하겠습니다.
2022-05-09 19:30:11.684 WARN 23164 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved
[org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public
org.springframework.http.ResponseEntity
com.codestates.chapter.d_dto.member.DtoValidationMemberController.postMember(com.codestates.chapter.d_dto.member.dto_vali
// ⬇️ 바로 아래의 3 errors를 주목하세요.
dation.ValidationMemberPostDto) with 3 errors: [Field error in object 'validationMemberPostDto' on field 'name': rejected value [];
codes [NotBlank.validationMemberPostDto.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]; arguments
[org.springframework.context.support.DefaultMessageSourceResolvable: codes [validationMemberPostDto.name,name]; arguments
[]; default message [name]]; default message [이름은 공백이 아니어야 합니다.]] [Field error in object 'validationMemberPostDto' on field
'email': rejected value [hgd@]; codes [Email.validationMemberPostDto.email,Email.email,Email.java.lang.String,Email]; arguments
[org.springframework.context.support.DefaultMessageSourceResolvable: codes [validationMemberPostDto.email,email]; arguments
[]; default message [email],[Ljavax.validation.constraints.Pattern$Flag;@2eb735aa,.*]; default message [올바른 형식의 이메일 주소여야
합니다]] [Field error in object 'validationMemberPostDto' on field 'phone': rejected value [010-1234-56781]; codes
[Pattern.validationMemberPostDto.phone,Pattern.phone,Pattern.java.lang.String,Pattern]; arguments
[org.springframework.context.support.DefaultMessageSourceResolvable: codes [validationMemberPostDto.phone,phone];
arguments []; default message [phone],[Ljavax.validation.constraints.Pattern$Flag;@1375b56c,^\\d{3}-\\d{3,4}-\\d{4}$]; default
message [휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.]] ]
로그를 보면 유효성 검증에서 총 3개의 검증 에러를 발견했다는 사실을 알 수 있습니다.
[그림 3-24]에서 전달받은 Response Body의 내용만으로는 클라이언트 쪽에서 전송한 요청 데이터 중에서 어떤 항목이 유효성 검증에 실패했는지 알 수가 없습니다.
다행히 Spring MVC에서 로그를 출력해 주지만 클라이언트 입장에서는 여전히 불친절한 메시지를 확인할 수밖에 없습니다.
이 부분은 친절한 응답 메시지를 클라이언트에게 전달되도록 [예외 처리] 학습에서 바꿔보도록 하겠습니다.
지금은 DTO 클래스에 유효성 검증 애너테이션을 추가해서 Request Body의 유효성 검증을 깔끔하게 할 수 있다는 사실에 집중해 주세요!
이번에는 MemberPatchDto 클래스를 유효성 검증 요구 사항에 맞게 유효성 검증을 적용해 보도록 하겠습니다.
MemberPatchDto 유효성 검증
✔️ MemberPatchDto 유효성 검증 제약 사항
- name (이름)
- 값이 비어있을 수 있습니다.
- 값이 비어있지 않다면 공백이 아니어야 합니다.
- phone (휴대폰 번호)
- 값이 비어있을 수 있습니다.
- 아래와 같이 010으로 시작하는 11자리 숫자와 ‘-’로 구성된 문자열이어야 합니다.
- 예) 010-1234-5678
import javax.validation.constraints.Pattern;
public class MemberPatchDto {
private long memberId;
// 공백 아닌 문자 1개 이상((공백인 문자 0개 또는 1개)(공백이 아닌 문자 1개 이상)) -> 마지막 맨 바깥 쪽 괄호 조건이 0개 이상(즉, 있어도 되고 없어도 된다)
@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 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;
}
}
[코드 3-35] 유효성 검증(Validation)이 적용된 MemberPatchDto
코드 3-35은 유효성 검증이 적용된 MemberPatchDto의 코드입니다.
회원 등록을 위해 클라이언트에서 전달받는 Request Body의 데이터인 emil, name, phone 정보에 유효성 검증을 위한 애너테이션이 추가되었습니다.
MemberPatchDto 클래스의 멤버 변수에 적용된 유효성 검증 내용은 다음과 같습니다..
- memberId
- Request Body에 포함되는 데이터가 아니므로 유효성 검증이 필요하지 않습니다.
- name
- @Pattern
- 정규 표현식으로 다음 내용을 체크합니다.
- 이름 정보가 비어있으면(null) 유효성 검증을 하지 않습니다.
- 이름 정보가 비어 있지 않고(not null), 공백 문자열이라면 검증에 실패합니다.
- 시작 문자가 공백이면 검증에 실패합니다.
- 끝 문자가 공백이면 검증에 실패합니다.
- 문자와 문자 사이 공백이 1개를 초과하면 검증에 실패합니다.
- 정규 표현식으로 다음 내용을 체크합니다.
- @Pattern
- phone
- @Pattern
- 정규 표현식으로 다음 내용을 체크합니다.
- 휴대폰 정보가 비어있으면(null) 유효성 검증을 하지 않습니다.
- 휴대폰 정보가 비어 있지 않고, 010으로 시작하는 11자리 숫자와 ‘-’로 구성된 문자열이 아니라면 검증에 실패합니다.
- 정규 표현식으로 다음 내용을 체크합니다.
- @Pattern
MemberPostDto 클래스와 달리 MemberPatchDto에서는 모두 정규 표현식을 사용했습니다.
여러분들이 웹 브라우저에서 회원 가입을 한 후에 회원 정보를 수정하는 상황을 머릿속에 떠올려 보세요. 이름(또는 닉네임)만 수정할 수도 있고, 휴대폰 번호만 수정할 수도 있으며 둘 다 수정할 수도 있습니다.
그리고 이름이나 휴대폰 번호의 값으로 공백 문자열(”” 또는 “ “)만 포함이 되어 있을 경우를 검증해야 할 수 있습니다.
이처럼 다양한 조건을 선택적으로 검증하고자 할 때 유용한 방법 중 하나가 바로 정규 표현식(Reqular Experssion)입니다.
name 멤버 변수에 사용한 “^\\S+(\\s?\\S+)*$” 정규 표현식에서
- ‘^’은 문자열의 시작을 의미합니다.
- ‘$’는 문자열의 끝을 의미합니다.
- ‘’는 ‘’ 앞에 평가할 대상이 0개 또는 1개 이상인지를 평가합니다.
- ‘\\\\s’는 공백 문자열을 의미합니다.
- ‘\\\\S’ 공백 문자열이 아닌 나머지 문자열을 의미합니다.
- ‘?’는 ‘?’ 앞에 평가할 대상이 0개 또는 1개인지를 의미합니다.
- ‘+’는 ‘+’ 앞에 평가할 대상이 1개인지를 의미합니다.
“^\\\\S+(\\\\s?\\\\S+)*$”를 정규 표현식으로 추가하면 아래 예시의 문자열은 유효성 검증에 실패합니다.
유효성 검증 실패 예) ”” → 공백 문자만 있으므로 검증 실패
” 홍길동” → 시작 문자가 공백이므로 검증 실패
”홍길동 “ → 끝 문자가 공백이므로 검증 실패
“홍 길동” → 문자와 문자 사이의 공백이 1개를 초과하므로 검증 실패
💡 정규 표현식에 대한 더 자세한 내용은 아래 **[심화 학습]**을 참고하세요.
유효성 검증 애너테이션을 추가한 MemberPatchDto 클래스를 사용하는 MemberController 클래스의 patchMember() 핸들러 메서드의 코드는 [코드 3-36]과 같습니다.
@RestController
@RequestMapping("/v1/members")
@Validated
public class MemberController {
...
...
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(@PathVariable("member-id") @Min(2) long memberId,
@Valid @RequestBody MemberPatchDto memberPatchDto) {
memberPatchDto.setMemberId(memberId);
// No need Business logic
return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
}
}
[코드 3-36] 유효성 검증이 적용된 MemberPatchDto 클래스를 사용하는 patchMember()
[코드 3-36]에서도 MemberPatchDto 클래스에서 유효성 검증 로직이 실행되도록 @Valid 애너테이션을 추가해야 합니다.
이번에는 Spring MVC에서 출력한 IntelliJ IDE에서 확인해 보도록 하겠습니다.
[그림 3-25] Postman에서 유효하지 않은 데이터를 patchMember() 핸들러 메서드로 전송한 결과
[그림 3-25]에서는 name은 공백 문자열을, phone은 유효하지 않은 휴대폰 번호로 입력해서 patchMember() 핸들러 메서드로 요청을 전송했습니다.
응답 결과는 역시 Response Status가 400인 ‘Bad Request’를 전달받았습니다.
쿼리 파라미터(Query Parameter 또는 Query String) 및 @Pathvariable에 대한 유효성 검증
지금까지 Request Body의 유효성 검사를 진행하는 방법을 알아보았는데, 검증 대상에서 빠진 항목이 하나 있습니다
그것은 바로 patchMember() 핸들러 메서드의 URI path에서 사용되는 @PathVariable("member-id") long memberId 변수입니다.
일반적으로 수정이 필요한 데이터의 식별자는 0 이상의 숫자로 표현을 합니다.
patchMember() 핸들러 메서드에서 사용되는 memberId에 ‘1 이상의 숫자여야 한다’라는 제약 조건을 걸어보도록 하겠습니다.
@RestController
@RequestMapping("/v1/members")
@Validated // (1)
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);
}
}
[코드 3-37] @PathVariable 애너테이션이 추가된 변수에 유효성 검증을 적용하는 예
[코드 3-37]에서는 @PathVariable("member-id") long memberId에 1 이상의 숫자일 경우에만 유효성 검증에 통과하도록 @Min(1)이라는 검증 애너테이션을 추가했습니다.
그런데 이 상태에서 Postman으로 URI path에 유효하지 않은 값을 입력해도 유효성 검증이 정상적으로 수행되지 않습니다.
@PathVariable이 추가된 변수에 유효성 검증이 정상적으로 수행되려면 (1)과 같이 클래스 레벨에 @Validated 애너테이션을 반드시 붙여주어야 한다는 사실을 기억하기 바랍니다.
[그림 3-26] Postman에서 patchMember() 핸들러 메서드 요청 URI에 유효하지 않은 값을 입력한 예
[그림 3-26]에서는 patchMember() 핸들러 메서드 요청 URI에 유효하지 않은 숫자인 0을 입력한 후, 요청을 전송했습니다.
응답 결과는 Response Status가 500인 ‘Internal Server Error’를 전달받았습니다.
@Request Body에 대한 유효성 검증 실패 메시지와 조금 다르기는 하지만 어쨌든 유효성 검증은 정상적으로 이루어진 것입니다.
Spring MVC에서 출력한 로그는 어떤지 IntelliJ IDE에서 확인해 보도록 하겠습니다.
javax.validation.ConstraintViolationException: patchMember.memberId: 1 이상이어야 합니다
at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:120) ~[spring-context-5.3.18.jar:5.3.18]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.18.jar:5.3.18]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:753) ~[spring-aop-5.3.18.jar:5.3.18]
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:698) ~[spring-aop-5.3.18.jar:5.3.18]
at com.codestates.chapter.d_dto.member.DtoValidationMemberController$$EnhancerBySpringCGLIB$$1ee4a648.patchMember(<generated>) ~[main/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205) ~[spring-web-5.3.18.jar:5.3.18]
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150) ~[spring-web-5.3.18.jar:5.3.18]
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117) ~[spring-webmvc-5.3.18.jar:5.3.18]
....
....
Spring MVC에서 출력한 로그를 보면 첫 번째 라인에서 ConstraintViolationException이 발생하면서 유효성 검증이 정상적으로 진행이 된 것을 확인할 수 있습니다.
Jakarta Bean Validation이란?
지금까지 DTO 클래스의 유효성 검증을 위해서 사용한 애너테이션은 Jakarta Bean Validation이라는 유효성 검증을 위한 표준 스펙에서 지원하는 내장 애너테이션들입니다.
Jakarta Bean Validation은 여러분들이 라이브러리처럼 사용할 수 있는 API가 아닌 스펙(사양, Specification) 자체입니다.
즉, 이러이러한 애너테이션들을 이런 식으로 구현해서 사용하라는 일종의 기능 명세를 의미합니다.
따라서 Jakarta Bean Validation 스펙을 구현한 구현체가 존재할 것입니다.
이 Jakarta Bean Validation 스펙을 구현한 구현체가 바로 Hibernate Validator입니다.
사실 Jakarta Bean Validation의 애너테이션을 DTO 클래스에만 사용할 수 있는 것은 아닙니다.
Java Bean 스펙을 준수하는 Java 클래스라면 Jakarta Bean Validation의 애너테이션을 사용해서 유효성 검증을 할 수 있다는 사실을 기억하길 바랍니다.
Java Bean에 대한 더 자세한 내용은 아래 [심화 학습]을 참고하세요.
Custom Validator를 사용한 유효성 검증
DTO 클래스에 유효성 검증을 적용하다 보면 Jakarta Bean Validation에 내장된(Built-in) 애너테이션 중에 여러분들의 목적에 맞는 애너테이션이 존재하지 않을 수 있습니다.
이 경우 여러분이 원하는 목적에 맞는 애너테이션을 직접 만들어서 유효성 검증에 적용할 수 있습니다.
[코드 3-35]에서 MemberPatchDto 클래스의 name과 phone 멤버 변수에서 공백 여부를 검증하는 @Pattern(regexp = "^(?=\\\\s*\\\\S).*$") 애너테이션을 Custom Validator를 사용하도록 바꿔보도록 하겠습니다.
정규 표현식(Regular Expression)은 성능적인 면에서 때로는 비싼 비용을 치러야 될 가능성이 있습니다.
따라서 모든 로직을 정규표현식 위주로 작성하는 것은 좋은 개발 방식이 아닙니다.
정규 표현식의 성능적인 부분에 대해서 더 알아보려면 아래 [심화 학습]을 참고하세요.
Custom Validator를 구현하기 위한 절차는 다음과 같습니다.
- Custom Validator를 사용하기 위한 Custom Annotation을 정의한다.
- 정의한 Custom Annotation에 바인딩되는 Custom Validator를 구현한다.
- 유효성 검증이 필요한 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 {};
}
[코드 3-38] 공백을 허용하지 않는 Custom Annotation
[코드 3-38]은 공백을 허용하지 않는 Custom Annotation입니다.
애너테이션 생성에 대한 학습은 [Java] 학습에서 학습을 했을 거라 생각합니다.
여기서 중요한 부분은 NotSpace 애너테이션이 멤버 변수에 추가되었을 때, 동작할 Custom Validator를 (1)과 같이 추가하는 것입니다.
(2)는 애너테이션 추가 시 별도로 정의하지 않으면 유효성 검증 실패 시, 표시되는 디폴트 메시지입니다.
이제 (1)에서 지정한 NotSpaceValidator 클래스를 구현해봅시다.
✔️ 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);
}
}
[코드 3-38] 공백을 허용하지 않는 Custom Annotation
기본적으로 CustomValidator를 구현하기 위해서는 ConstraintValidator 인터페이스를 구현해야 합니다.
[코드 3-38]의 ConstraintValidator<NotSpace, String>에서 NotSpace는 CustomValidator와 매핑된 Custom Annotation을 의미하며, String은 Custom Annotation으로 검증할 대상 멤버 변수의 타입을 의미합니다.
✔️ 유효성 검증을 위해 Custom Annotation 추가
import javax.validation.constraints.Pattern;
public class MemberPatchDto {
private long memberId;
@NotSpace(message = "회원 이름은 공백이 아니어야 합니다") // (1)
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;
}
}
[코드 3-39] 공백을 허용하지 않는 Custom Annotation을 MemberPatchDto 멤버 변수에 추가
[코드 3-39]에서는 (1)과 같이 name 멤버 변수에 적용된 @Pattern(regexp ="^\\\\S+(\\\\s?\\\\S+)*$") 애너테이션을 제거하고, 대신에 앞에서 작성한 Custom Annotation인 @NotSpace를 추가했습니다.
이제 샘플 애플리케이션을 재시작하고 [그림 3-25]와 같이 patchMember() 핸들러 메서드에 해당되는 URI로 요청을 전송하면 [그림 3-25]와 동일한 응답 결과를 확인할 수 있습니다.
핵심 포인트
- 프론트엔드 쪽에서 유효성 검증을 진행했다 하더라도 서버 쪽에서 추가적으로 유효성 검증을 반드시 진행해야 한다.
- 프론트엔드 쪽에서의 유효성 검증 프로세스는 사용자 편의성 측면에서 필요한 작업이다.
- Jakarta Bean Validation의 애너테이션을 이용하면 Controller 로직에서 유효성 검증 로직을 분리할 수 있다.
- Jakarta Bean Validation은 애너테이션 기반 유효성 검증을 위한 표준 스펙이다.
- Hibernate Validator는 Jakarta Bean Validation 스펙을 구현한 구현체이다.
- Spring에서 지원하는 @Validated 애너테이션을 사용하면 쿼리 파라미터(Query Parameter 또는 Query String) 및 @Pathvariable에 대한 유효성 검증을 진행할 수 있다.
- Jakarta Bean Validation에서 빌트인(Built-in)으로 지원하지 않는 애너테이션은 Custom Validator를 통해 Custom Annotation을 구현한 후, 적용할 수 있다.
심화 학습
- 정규 표현식에 대해서 더 알아보고 싶다면 아래 링크를 확인하세요.
- 정규 표현식은 프로그래밍 언어에 종속되지 않은 표현식입니다. 따라서 아래 관련 자료에서 Java와 관련된 자료가 아니어도 정규 표현식을 학습하는데 지장이 없으니 이 점 참고해서 관련 자료를 살펴보기 바랍니다.
- Jakarta Bean Validation에 대해서 더 알아보고 싶다면 아래 링크를 확인하세요.
- Jakarta Bean Validation Specification
- Jakarta Bean Validation Built-in Constraint definitions
- Hibernate Validator
- Java Bean에 대해서 더 알아보고 싶다면 아래 링크를 확인하세요.
- Java Bean 관련 자료
- 이메일 주소의 스펙에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.