Spring Boot를 이용한 RESTful Web Services 개발
유효성 체크를 위한 Validation API 사용
Validation API에는 두 가지 API가 존재한다.
- JDK에 포함된 Validation API
- Hibernate Validation API
- Hibernate는 자바에서 어플리케이션을 개발할 때 사용하는 API
- 자바의 객체와 데이터베이스의 엔티티를 매핑해주는 프레임워크 제공
이번 포스팅에서는 JDK에 포함된 Validation API만 사용해보자. Hibernate Validation API는 추후에 사용..
JDK에 포함된 Validation API
스프링부트 2.3 버전 이상 시에는 gradle에 validation을 따로 추가해줘야한다.
- build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
- User.java 수정
private Integer id;
@Size(min = 2) // 최소 2글자
private String name;
@Past // 현재 시간보다 과거의 날짜 및 시간이 저장되어야 한다는 제약
private LocalDateTime joinDate;
- UserController.java 수정
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {...}
새로운 사용자를 만드는 createUser 메서드의 매개변수에 '@Valid'를 추가한다. 이러면 User 객체가 넘어올 때 Validation 체크를 한다.
- 서버 재실행 후 포스트맨 확인
name의 값을 "N"(한 자리)만 보냈더니 400 Bad Request가 발생한다. User 클래스의 필드 중 name 위에 '@Size(min = 2)'라는 validation을 통과하지 못해서이다.
에러 코드는 400이지만 body에 아무것도 보이지 않는다. 사용자 입장에서 보면 이게 뭐지?하면서 물음표를 100개 찍어 낼 상황이다. 좀 더 사용자 친화스럽게 만들어보자.
- CustomizedResponseEntityExceptionHandler.java 수정
상속받고 있는 ResponseEntityExceptionHandler 클래스의 메서드를 재정의한다.
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
ExceptionResponse exceptionResponse = new ExceptionResponse(LocalDateTime.now(),
ex.getMessage(), ex.getBindingResult().toString());
return new ResponseEntity(exceptionResponse,HttpStatus.BAD_REQUEST);
}
- 서버 재실행 후 포스트맨 확인
message와 details에 name과 '크기가 2에서 2147483647 사이어야 합니다.' 라는 문구가 보여 사용자가 대충 어디에서 잘못된 것인지 유추할 수 있게 됐다. 그래도 한 눈에 들어오지 않아 사용자가 불편함을 느끼기 충분하다. 일단 나같은 경우는 길어서 뭐야 하고 바로 뒤로가기를 누를 것 같다. 강의에서는 그냥 messgae 부분을 'Validation Failed'로 변경했지만 나는 어디에서 오류가 났는지 알려주고 싶어 디버깅을 통해 에러가 발생한 데이터를 가져올 수 있는지 확인했다.
그래서 코드를 아래와 같이 바꾸었다.
List<FieldError> list = ex.getBindingResult().getFieldErrors();
ExceptionResponse exceptionResponse = new ExceptionResponse(LocalDateTime.now(),
"Validation Failed : " + list.get(0).getField(), ex.getBindingResult().toString());
return new ResponseEntity(exceptionResponse,HttpStatus.BAD_REQUEST);
더 좋은 방법이 있을 수도 있으나 여기까지가 나의 최선...
- User.java 수정
이번엔 한글로 출력되는 default message를 변경해보자.
@Size(min = 2, message = "Name은 2글자 이상 입력해 주세요.")
private String name;
서버 재실행 후 포스트맨 확인
- 서버 재실행 후 포스트맨 확인
다국어 처리를 위한 Internationalization 구현 방법
- 다국어 처리는 하나의 출력 값을 여러가지 언어로 표현해주는 기능이다. 자동 번역 기능이 있는 건 아니고 언어 별로 문장을 어디에 저장해두었다가 지역 코드 또는 언어 설정에 따라 저장해놓은 알맞은 값을 출력해주는 것이다.
- 다국어는 전체적으로 적용해야하기 때문에 스프링부트 어플리케이션 클래스에 등록해서 스프링 부트가 초기화될 때 메모리에 등록할 수 있도록 한다.
- RestfulWebServiceApplication.java 수정
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver localeResolver = new SessionLocaleResolver();
localeResolver.setDefaultLocale(Locale.KOREA);
return localeResolver;
}
만약 return문에 에러가 발생한다면 LocaleResolver의 import를 확인해보자.. 나는 다른 클래스를 import해서 에러가 발생해 해결 방법을 찾는데 괜히 시간만 버렸다...ㅎ
import org.springframework.web.servlet.LocaleResolver;
다국어 파일 설정 및 적용
- application.yml
spring:
messages:
basename: messages // message 파일의 이름
- messages.properties 파일 생성(en, fr)
greeting.message=안녕하세요 // messages.properties
greeting.message=hello // messages_en.properties
greeting.message=Bonjour // messages_fr.properties
- HelloWorldController.java 수정
간단한 테스트를 위해 User가 아닌 HelloWorldController에서 진행한다.
클래스 상단에 아래 코드를 작성한다.
@Autowired
private MessageSource messageSource;
메세지 값을 반환하기 위해 MessageSource 객체를 가져와야한다. @Autowired는 현재 스프링 프레임워크에 등록되어 있는 Bean들 중에서 같은 타입을 가지고 있는 Bean을 자동으로 주입시켜준다.
@GetMapping(path = "/hello-world-internationalized")
public String helloWorldInternationalized(
@RequestHeader(name = "Accept-Language",required = false) Locale locale) {
return messageSource.getMessage("greeting.message", null, locale);
// 첫 번째 매개변수에 messages에서 입력한 Key 값을 입력한다.
}
매개변수의 @RequestHeader를 사용하면 RequestHeader 정보를 가져올 수 있다. Accept-Language를 사용하면 Request를 보낸 웹 페이지의 언어 정보를 가져올 수 있다. required는 필수로 가져와야할 값인지 체크하며 false일 경우 Appcept-Language 값이 비어있어도 눈 감아준다.
- 서버 재실행 후 포스트맨 확인
Header에 Accept-Language를 추가하고 send 해보자.
그러면 '안녕하세요'가 아닌 'hello'가 출력된다. 맨 처음에 나왔던 한글은 스프링부트 메인 클래스의 localeResolver 메서드에서 디폴트 값을 정해줬기 때문이다.
localeResolver.setDefaultLocale(Locale.KOREA);
Response 데이터 형식 변환 - XML format
여태까지는 클라이언트의 요청 값을 JSON으로 전달했다. 이번에는 XML 포맷으로 전달해보자.
XML로 전달해주기 위해선 Request Header에 값을 추가해줘야한다.
값 추가 후 send를 클릭하면 아래와 같이 null 값이 전달된 걸 확인할 수 있다.
그리고 status code는 406번인데 서버 쪽에서 준비되어있지 않은 xml 코드를 요청했기 때문에 에러가 발생한 것이다.
- build.gradle
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.11.2'
JSON을 XML 포맷으로 파싱해주는 라이브러리 추가
- 서버 재실행 후 포스트맨 확인
Response 데이터 제어를 위한 Filtering
사용자 정보에 비밀번호나 주민번호 같은 정보가 담겨있다고 생각하면 서버측에서 사용자 정보를 보내줄 때 최소 일부분을 가리거나 아니면 전체를 가려서, 아니면 아예 없애서 전달해줘야한다. 이런 부분을 처리해보자.
- User.java 수정
private String password;
private String ssn;
패스워드와 ssn 필드를 생성한다.
- UserDaoService.java 수정
static {
users.add(new User( ++userCount, "길동", LocalDateTime.now(), "pass1", "701010-1111111"));
users.add(new User(++userCount, "영미", LocalDateTime.now(), "pass2", "801010-2222222"));
users.add(new User(++userCount, "철수", LocalDateTime.now(), "pass3", "901010-3333333"));
}
user 초기 데이터 생성자에 비밀번호와 ssn 값을 넣어준다.
- 서버 재실행 후 포스트맨 확인
전체 사용자 조회 시 비밀번호와 주민등록번호가 모두 보이게 된다.
사용자의 다섯가지 정보 중에 패스워드와 ssn을 아예 빼버리자.
- User.java
@JsonIgnore
private String password;
@JsonIgnore
private String ssn;
// 클래스 블록에서 한꺼번에 처리할 수도 있다.
@JsonIgnoreProperties(value = {"password", "ssn"})
public class User { ... }
위에서 추가했던 jackson 라이브러리 안에 있는 기능인 '@JsonIgnore'를 사용하면 외부에 노출시키지 않는다.
- 서버 재실행 후 포스트맨 확인
프로그래밍으로 제어하는 Filtering 방법 - 개별 사용자 조회
- User.java
//@JsonIgnoreProperties(value = {"password", "ssn"})
@JsonFilter("UserInfo")
public class User {
...
private String password;
private String ssn;
}
Jackson 라이브러리 기능으로 사용했던 @JsonIgnoreProperties와 @JsonIgnore를 주석처리 후 '@JsonFilter'를 추가해준다.
- AdminUserController.java
기존에 있던 UserController.java를 복사해서 AdminUserController.java를 생성한다. 메서드는 사용자 생성, 삭제는 지워버리자. 그리고 UserController와의 url이 달라야하기 때문에 클래스 블록에 '@RequestMapping'을 추가해준다. 이러면 해당 컨트롤러에 있는 모든 url 앞에 prefix로 '/admin'이 붙는다. 즉, 사용자 전체 조회의 url은 '/admin/users'가 된다.
...
@RequestMapping("/admin")
public class AdminUserController {
...
// 사용자 전체 조회
@GetMapping("/users")
public List<User> retrieveAllUsers() {
return service.findAll();
}
// 개별 사용자 조회
@GetMapping("/users/{id}")
public User retrieveUser(@PathVariable int id) {
User user = service.findOne(id);
if(user == null) {
throw new UserNotFoundException(String.format("ID[%s] not found", id));
}
return user;
}
}
관리자의 경우엔 사용자의 모든 정보를 볼 수 있도록 하고, 일반 사용자가 사용자를 조회했을 땐 일부 정보만 확인할 수 있도록 하자.
- UserDaoService.java 수정
@GetMapping("/users/{id}")
public MappingJacksonValue retrieveUser(@PathVariable int id) {
User user = service.findOne(id);
if(user == null) {
throw new UserNotFoundException(String.format("ID[%s] not found", id));
}
SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
.filterOutAllExcept("id", "name", "joinDate", "ssn");
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(user);
mapping.setFilters(filters);
return mapping;
}
- 서버 재실행 후 포스트맨 확인
프로그래밍으로 제어하는 Filtering 방법 - 전체 사용자 조회
- AdminUserController.java 수정
@GetMapping("/users")
public MappingJacksonValue retrieveAllUsers() {
List<User> users = service.findAll();
SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
.filterOutAllExcept("id", "name", "joinDate", "password");
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(users);
mapping.setFilters(filters);
return mapping;
}
- 서버 재실행 후 포스트맨 확인
URI를 이용한 REST API Version 관리
URI를 이용한 버전관리는 URI에 버전을 직접 명시해주면 된다.
- AdminUserController.java
@GetMapping("/v1/users")
public MappingJacksonValue retrieveAllUsers() {
List<User> users = service.findAll();
SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
.filterOutAllExcept("id", "name", "joinDate", "password");
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(users);
mapping.setFilters(filters);
return mapping;
}
@GetMapping("/v1/users/{id}")
public MappingJacksonValue retrieveUserV1(@PathVariable int id) {
User user = service.findOne(id);
if(user == null) {
throw new UserNotFoundException(String.format("ID[%s] not found", id));
}
SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
.filterOutAllExcept("id", "name", "joinDate", "ssn");
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(user);
mapping.setFilters(filters);
return mapping;
}
retrieveUserV1의 경우엔 비교를 위해 다른 버전을 하나 더 만들어보자.
- UserV2.java 생성
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonFilter("UserInfoV2")
public class UserV2 extends User{
private String grade;
}
- AdminUserController.java
@GetMapping("/v2/users/{id}")
public MappingJacksonValue retrieveUserV2(@PathVariable int id) {
User user = service.findOne(id);
if(user == null) {
throw new UserNotFoundException(String.format("ID[%s] not found", id));
}
// User를 UserV2로 변환
UserV2 userV2 = new UserV2();
BeanUtils.copyProperties(user, userV2); //두 객체 간 동일한 프로퍼티의 경우 복사
userV2.setGrade("VIP");
SimpleBeanPropertyFilter filter = SimpleBeanPropertyFilter
.filterOutAllExcept("id", "name", "joinDate", "grade");
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfoV2", filter);
MappingJacksonValue mapping = new MappingJacksonValue(userV2);
mapping.setFilters(filters);
return mapping;
}
- 서버 재실행 후 포스트맨 확인
버전 1.0 조회 시
버전 2.0 조회 시
Request Parameter와 Header를 이용한 REST API Version 관리
Request Parameter
- AdminUserController.java
위에서 작성했던 '@GetMapping'을 주석처리하고 새로운 @GetMapping을 작성한다. 주의할 점은 value 맨 마지막에 '/'를 넣어줘야한다는 점이다. params 정보가 url 뒤에 붙기 때문이다.
// @GetMapping("/v1/users/{id}")
@GetMapping(value = "/users/{id}/", params = "version=1")
public MappingJacksonValue retrieveUserV1(@PathVariable int id) { ... }
//@GetMapping("/v2/users/{id}")
@GetMapping(value = "/users/{id}/", params = "version=2")
public MappingJacksonValue retrieveUserV2(@PathVariable int id) { ... }
- 서버 재실행 후 포스트맨 확인
버전 1.0일때
버전 2.0일때
Header
- AdminUserController.java
// @GetMapping("/v1/users/{id}")
// @GetMapping(value = "/users/{id}/", params = "version=1")
@GetMapping(value = "/users/{id}", headers = "X-API-VERSION=1")
public MappingJacksonValue retrieveUserV1(@PathVariable int id) { ... }
// @GetMapping("/v2/users/{id}")
// @GetMapping(value = "/users/{id}/", params = "version=2")
@GetMapping(value = "/users/{id}", headers = "X-API-VERSION=2")
public MappingJacksonValue retrieveUserV2(@PathVariable int id) { ... }
- 서버 재실행 후 포스트맨 확인
버전 1.0일때
버전 2.0일때
Produces
- AdminUserController.java
// @GetMapping("/v1/users/{id}")
// @GetMapping(value = "/users/{id}/", params = "version=1")
// @GetMapping(value = "/users/{id}", headers = "X-API-VERSION=1")
@GetMapping(value = "/users/{id}", produces = "application/vnd.company.appv1+json")
public MappingJacksonValue retrieveUserV1(@PathVariable int id) { ... }
// @GetMapping("/v2/users/{id}")
// @GetMapping(value = "/users/{id}/", params = "version=2")
// @GetMapping(value = "/users/{id}", headers = "X-API-VERSION=2")
@GetMapping(value = "/users/{id}", produces = "application/vnd.company.appv2+json")
public MappingJacksonValue retrieveUserV2(@PathVariable int id) { ... }
- 서버 재실행 후 포스트맨 확인
버전 1.0일때
버전 2.0일때
정리
- 단순히 사용자에게 보여주는 항목을 제어하는 용도가 아니라 REST API 설계가 변경되거나, Application의 구조가 변경될 때에도 버전을 번경해서 사용해야한다.
- 사용자는 어떤 버전의 API를 사용해야하는지도 알려줄 수 있음
- 버전 관리 방법
- URI Versioning
- Request Parameter versioning
- Media type versioning (a.k.a "content negotiation" or "accept header")
- (Custom) headers versioning
- URI, Request Parameter 방식의 경우 일반 브라우저에서 실행 가능
- Media type versioning, headers versioning은 일반 브라우저에서 실행 불가능
- 출처 : 인프런 Spring Boot를 이용한 RESTful Web Services 개발 강의