Hateoas(Hypermedia as the Engine of Application State)
현재 리소스와 연관된 자원 상태 정보를 제공
WebMvcLinkBuilder
RESTful API를 사용하는 클라이언트가 서버와 동적인 상호작용이 가능하도록 하는 것을 HATEOAS라고 한다.
서버는 현재 리소스와 연관된 링크 정보를 클라이언트에게 제공하고 클라이언트는 연관된 링크 정보를 바탕으로 리소스에 접근할 수 있게 된다.
요청 URI이 변경되더라도 동적으로 생성된 URI을 사용할 수 있기에 코드를 변경하지 않아도 되는 편리함을 제공한다.
// UserController.java
@GetMapping("/users/{id}")
public EntityModel<User> retrieveUser(@PathVariable int id) {
User user = service.findOne(id);
if (user == null) {
throw new UserNotFoundException(String.format("ID[%s] not found", id));
}
// HATEOAS
EntityModel<User> model = new EntityModel<>(user);
WebMvcLinkBuilder linkTo = linkTo(methodOn(this.getClass()).retrieveAllUsers()); // user가 사용할 수 있는 추가적 정보
model.add(linkTo.withRel("all-users")); // 링크작업
return model;
}
WebMvcLinkBuilder를 이용하여 링크를 추가해 주었다. 스프링 2.2 이전에서는 ControllerLinkBuilder를 이용한다고 한다.
@Data
@RequiredArgsConstructor
@JsonFilter("UserInfoV2")
public class UserV2 extends User {
private String grade;
}
URI을 이용한 버전관리
/{버전}/users/{id}형식으로 URL을 받아 버전별 정보를 설정해 주었다.
// AdminUserController.java
@GetMapping("/v1/users/{id}") // URI를 이용한 버전관리
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"); // 포함시키고자 하는 필터
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(user);
mapping.setFilters(filters);
return mapping;
}
@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 -> User2
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;
}
v1 호출v2 호출
Request 파라미터를 이용한 버전 관리
params={버전정보} 을 설정하여 파라미터로 버전정보를 받도록 하였다.
// AdminUserController.java
@GetMapping(value = "/users/{id}/", params = "version=1") // request 파라미터를 이용한 버전관리
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"); // 포함시키고자 하는 필터
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(user);
mapping.setFilters(filters);
return mapping;
}
@GetMapping(value = "/users/{id}/", params = "version=2")
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 -> User2
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;
}
기존 URL에 파라미터를 붙여 버전별 API를 호출할 수 있다.
헤더값을 이용한 버전관리
headers={버전정보}을 설정하여 헤더값으로 버전정보를 받도록 하였다.
// AdminUserController.java
@GetMapping(value="/users/{id}", headers="X-API-VERSION=1") // 헤더값을 이용한 버전관리
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"); // 포함시키고자 하는 필터
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(user);
mapping.setFilters(filters);
return mapping;
}
@GetMapping(value="/users/{id}", headers="X-API-VERSION=2")
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 -> User2
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;
}
포스트맨을 통해 헤더값을 설정해주고 API를 실행하였다. 헤더값을 설정하지 않고 요청하면 404에러가 발생하게 된다.
Mime Type을 이용한 버전관리
produces={버전정보}을 설정하여 헤더값으로 버전정보를 받도록 하였다.
// AdminUserController.java
@GetMapping(value = "/users/{id}", produces = "application/vnd.company.appv1+json") // 마임타임을 이용한 방법
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"); // 포함시키고자 하는 필터
FilterProvider filters = new SimpleFilterProvider().addFilter("UserInfo", filter);
MappingJacksonValue mapping = new MappingJacksonValue(user);
mapping.setFilters(filters);
return mapping;
}
@GetMapping(value = "/users/{id}", produces = "application/vnd.company.appv2+json")
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 -> User2
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;
}
//User.java
@Data
@AllArgsConstructor
public class User {
private Integer id;
@Size(min = 2, message = "Name은 2글자 이상 입력해 주세요.")
private String name;
@Past // 과거 데이터만 가능
private Date joinDate;
}
//UserController.java
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
// Post 메서드, put 과 같은 http 메서드에서 오브젝트형태의 데이터를 받기위해 매개변수 타입에 @RequestBody 선언해야함
User saveUser = service.save(user);
URI location = ServletUriComponentsBuilder.fromCurrentRequest() // 현재 요청되어진 request 값 사용
.path("/{id}") // 반환시켜주고자 하는 path
.buildAndExpand(saveUser.getId()) // 가변번수 id에 새롭게 만들어진 id값 지정
.toUri(); // uri 형태로 변경
return ResponseEntity.created(location).build();
}
// CustomizedResponseEntityExceptionHandler.java
// 유효성 검사 오류가 있을때 오류메세지를 출력하기 위한 메서드
@Override // 재정의
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
ExceptionResponse exceptionResponse = new ExceptionResponse(new Date(),
"Validation Failed", ex.getBindingResult().toString());
return new ResponseEntity(exceptionResponse, HttpStatus.BAD_REQUEST);
}
위와 같이 User 클래스 및 컨트롤러에 validation 체크를 추가하고
handleMethodArgumentNotValid 메서드를 재정의 하여 오류메세지를 보여줄 수 있다.
유효성 오류가 있을 때 details에서 상세 정보를 확인할 수 있다.
handleMethodArgumentNotValid에서 메세지를 "Validation Failed" 로 변경하여
정보를 바로 파악할 수 있도록 하였다.
name 에 message 를 정의 해주어 더 간단하게 오류정보를 출력할 수 있다.
다국어 처리를 위한 Internationalization 구현
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
// 스프링부트가 초기화 될 때 정보에 해당하는 값이 메모리에 올라가서 다른 클래스에서 사용할 수 있다.
@Bean
public SessionLocaleResolver localResolver(){
SessionLocaleResolver localeResolver = new SessionLocaleResolver();
localeResolver.setDefaultLocale(Locale.KOREA);
return localeResolver;
}
}
사용자 목록, 조회 및 등록 api를 실행하여 서버로 부터 200응답 코드를 받아 정상적으로 실행된 것을 확인하였다.
목록과 등록 api는 둘 다 localhost:8088/users 로 각각 get, post로 요청된다.
용도에 맞춰 응답코드를 다르게 사용하는 것이 좋다.
사용자 등록 - Servleturicomponentsbuilder 를 이용한 uri 반환
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody User user) {
// Post 메서드, put 과 같은 http 메서드에서 오브젝트형태의 데이터를 받기위해 매개변수 타입에 @RequestBody 선언해야함
User saveUser = service.save(user);
URI location = ServletUriComponentsBuilder.fromCurrentRequest() // 현재 요청되어진 request 값 사용
.path("/{id}") // 반환시켜주고자 하는 path
.buildAndExpand(saveUser.getId()) // 가변번수 id에 새롭게 만들어진 id값 지정
.toUri(); // uri 형태로 변경
return ResponseEntity.created(location).build();
}
등록에 성공한 경우, 생성한 id를 반환 시켜주도록 하였다. id를 반환하면 서버에 또 한번 물어보지 않아도 되기에 더 효율적이다.
servleturicomponentsbuilder 을 이용하면 사용자에게 특정값을 포함한 uri를 전달할 수 있다.
ResponseEntity는 사용자의 HttpRequest에 대한 응답 데이터를 포함하는 클래스이며 응답 상태코드를 설정할 수 있다.
uri와 함께 201 create 코드를 반환하도록 하였다.
201 응답코드를 받았으며 생성된 id값인 4가 반환되는 것을 볼 수 있다.
사용자 조회 - Exception Handling
@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;
}
// HTTP Status Code
// 2XX -> OK
// 4XX -> Client 측 적절하지 않은 요청(존재하지 않는 리소스, 권한 등)
// 5XX -> Server 측 문제(프로그램상 문제, 리소스 연결 문제 등)
@ResponseStatus(HttpStatus.NOT_FOUND) // 400번대 오류로 전송하기 위해
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}
존재하지 않는 사용자를 요청하였을 때 200코드가 아닌, 적절한 Exception을 발생시키도록 하였다.
에러 처리를 위한 클래스를 따로 생성하여 사용자의 id가 없을 경우 404에러를 전송하도록 하였다.
존재하지 않는 사용자 조회 요청 시 404 코드를 확인 할 수 있다.
trace 통해 예외발생에 원인되는 코드라인 노출되는데 보안상의 문제가 있을 수 있기에 보완이 필요하다.
ControllerAdvice 를 이용한 예외클래스 생성
@RestController
@ControllerAdvice// 모든 컨트롤러가 실행될때 반드시 빈이 실행됨
public class CustomizedResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(Exception.class)
public final ResponseEntity<Object> handlerAllException(Exception ex, WebRequest request) {
ExceptionResponse exceptionResponse = new ExceptionResponse(new Date(), ex.getMessage(), request.getDescription(false));
return new ResponseEntity(exceptionResponse, HttpStatus.INTERNAL_SERVER_ERROR); // 일반화된 오류 500번
}
@ExceptionHandler(UserNotFoundException.class)
public final ResponseEntity<Object> handlerUserNotFoundException(Exception ex, WebRequest request) {
ExceptionResponse exceptionResponse = new ExceptionResponse(new Date(), ex.getMessage(), request.getDescription(false));
return new ResponseEntity(exceptionResponse, HttpStatus.NOT_FOUND); // 404 에러
}
}
@Data
@AllArgsConstructor // 모든생성자
@NoArgsConstructor // default 생성자
public class ExceptionResponse {
private Date timestamp;
private String message;
private String details;
}
기본적인 restful api를 작성하고 postman을 이용하여 결과를 확인 해 보았다.
Restful api 생성
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserDaoService service;
@GetMapping("/users") // 사용자 목록
public List<User> retrieveAllUsers() {
return service.findAll();
}
@GetMapping("/users/{id}") // 사용자 조회
public User retrieveUser(@PathVariable int id){
return service.findOne(id);
}
@PostMapping("/users") // 사용자 등록
public void createUser(@RequestBody User user){
// Post 메서드, put 과 같은 http 메서드에서 오브젝트형태의 데이터를 받기위해 매개변수 타입에 @RequestBody 선언해야함
User saveUser = service.save(user);
}
}
전체 사용자 목록: GET, http://localhost:8088/users
개별 사용자 조회: GET, http://localhost:8088/users/{id}
@RestController 어노테이션을 사용하여 restful api를 만든다.
사용자 전체 목록
사용자 목록을 가져오는 api를 실행한 결과이다. 상태값이 200이고 사용자 목록을 잘 가져오는 것을 볼 수 있다.
사용자 개별 조회
id가 1번에 해당하는 사용자 정보를 가져온 결과이다.
사용자 등록
사용자 등록도 마찬가지로 postman을 이용하여 확인 할 수 있었다. body에 등록 json데이터를 설정하여 전송하였다.