[Spring] RESTful의 HATEOAS 관련 내용 정리 - RESTful 하려면 어떤 조건들이 필요할까?
Hypermedia As The Engine Of Application state
링크에 사용 가능한 URL을 리소스로 전달하여 client가 참고하여 사용할 수 있도록 하는 것
참조문서:https://spring.io/guides/gs/rest-hateoas/
Resource Representation class 만들기
서비스 상호작용을 생각하면서 진행 해보자. 서비스는 GET 요청을 핸들하기 위해 리소스를 /greeting에 노출시킨다. (선택적으로 name 파라미터로 쿼리 스트링을 준다) GET 요청은 200 OK로 "greeting"을 JSON 으로 바디에 내려준다.
그 밖에, 리소스의 상태를 나타내는 JSON은 _links 속성안에 하이퍼미디어 요소들의 리스트가 매우 많다. 가장 기본적인 형태는 리소스 자체를 가리키고 있다. 그래서 응답은 다음과 같을 것이다.
{
"content":"Hello, World!",
"_links":{
"self":{
"href":"http://localhost:8080/greeting?name=World"
}
}
}
content는 greeting의 텍스트 형태의 응답이다. _links 요소는 링크들의 리스트를 가지고 있고 이 경우에는 한 가지 만 가지고 있는데 리소스가 접근됐다는 것을 가리키는 rel 타입과 href 속성이다.
greeting 모델에게는 "resource representation" 클래스를 만들어야 한다. _links 속성이 표현 모델에게 기본적인 속성이므로 Spring HATEOAS는 베이스 클래스인 ResourceSupport를 가지는데 그것은 Link 인스턴스를 추가할 수 있게 해주고 위에서 보듯이 렌더링된 것을 보장해준다.
그래서 간단히 ResourceSupport를 상속하는 pojo를 만들고 생성자와 함께 필드와 접근자를 추가 해준다.
package hello;
import org.springframework.hateoas.ResourceSupport;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class Greeting extends ResourceSupport {
private final String content;
@JsonCreator
public Greeting(@JsonProperty("content") String content) {
this.content = content;
}
public String getContent() {
return content;
}
}
@JsonCreator 애노테이션 - Jackson이 이 POJO 인스턴스를 어떻게 만들지에 대한 신호 ← (문서에 나와 있는 내용)
조금 더 보충 설명을 하자면, json을 역직렬화를 하려면 Jackson이 필요한데 생성자를 통해 생성하거나 setter를 통해 생성한다. setter를 쓴다면 가변객체기 때문에 이것을 불변객체로 만들기 위해 생성자에 저렇게 @JsonCreator를 써주어 setter 메서드를 없애는 모습이다. 위의 코드도 실제 공식 문서 예제인데 setter가 없다.
-
단일 인자 생서성자는 @JsonProperty를 쓰지 않는다면 이것은 delegate creator라고 불린다. Jackson이 바로 바인딩을 해버린다.
-
생성자/팩토리 메서드는 이름을 명시하여 모든인자에 JsonProperty나 JsonInject를 쓰게 된다. ← 단일 인자는 쓰지 않아도 되지만 여러 인자가 들어오게 되면 리플렉션할 때 필드를 찾아가는데 필요한 설정 같아 보인다.
@JsonProperty 애노테이션 - Jackson이 어떤 필드에 인자를 넣어야할지 확실하게 명시해준다.
RestController 만들기
Spring이 RESTful 웹서비스를 만들 때 접근하는 방식은, HTTP 요청을 컨트롤러가 핸들하는 것이다. 컴포넌트들은 @RestController 애노테이션을 쉽게 알아보는데 그것은 @Controller와 @ResponseBody 애노테이션이 합쳐진 것이다.
GreetingController는 /greeting 에 대한 GET요청을 처리하고 Greeting 클래스로부터 새로운 인스턴스를 만든다.
package hello;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@RestController
public class GreetingController {
private static final String TEMPLATE = "Hello, %s!";
@RequestMapping("/greeting")
public HttpEntity<Greeting> greeting(
@RequestParam(value = "name", required = false, defaultValue = "World") String name) {
Greeting greeting = new Greeting(String.format(TEMPLATE, name));
greeting.add(linkTo(methodOn(GreetingController.class).greeting(name)).withSelfRel());
return new ResponseEntity<>(greeting, HttpStatus.OK);
}
}
컨트롤러는 명확하고 간단하다. 그런데 할게 많다. 차근차근 살펴보자.
// 스프링의 기본 내용은 번역정리를 생략하였습니다. @RequestMapping 에 대한 내용을 모르면 다른 문서를 참조하길 바랍니다. 지금은 Hateoas에 관한 내용만 정리할 것임.
이 메서드에서 가장 흥미로운점은 컨트롤러를 가리키는 link를 만들고 표현 모델에 붙인 방법이다. linkTo()와 methodOn()은 ControllerBuilder의 static 메서드인데 컨트롤러에 가짜 메서드 콜을 만든다. 리턴 된 LinkBuilder는 컨트롤러 메서드의 매핑 애노테이션을 검사하여 메서드가 매핑 된 URI를 정확하게 빌드한다. 그리고 withSelfRel()은 Greeting 표현모델의 Link 인스턴스를 만든다.
프로젝트를 실행해보자.
http://localhost:8080/greeting?name=Jay
어떤것이 RESTful하게 만드는 것인가?
아래의 것들은 "RESTful"하다고 하기에 충분하지 않은 것들이다.
- 예쁜 URL들 /employees/3 는 REST가 아니다
- 단지 GET, POST를 쓰는 것은 REST가 아니다
- 모든 CRUD 작업이 나와있는 것은 REST가 아니다.
위의 것들을 포함해서 대충 그럴싸하게 만든 것들 (규칙을 지키지 않은 것들)은 그냥 단지 RPC (원격 프로시저 호출)라고 부른다. 왜냐하면 서비스와 어떻게 상호작용하는지 모르기 때문이다.
다음 구절은 Roy Fielding (로이 필딩) REST와 RPC를 구분하는 말이다.
I am getting frustrated by the number of people calling any HTTP-based interface a REST API. Today’s example is the SocialSite REST API. That is RPC. It screams RPC. There is so much coupling on display that it should be given an X rating. What needs to be done to make the REST architectural style clear on the notion that hypertext is a constraint? In other words, if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period. Is there some broken manual somewhere that needs to be fixed?
나는 모든 HTTP 기반 인터페이스를 REST API라고 부르는 사람들에게 빡친다. 오늘날 예시는 SocialSite REST API이다. 그것은 그냥 RPC이다. 그것들은 RPC라고 울부짖을 정도다. 하이퍼 텍스트가 제약 조건이라는 개념에서 REST 아키텍처 스타일을 명확히 하려면 어떻게 해야할까? 즉, 애플리케이션 상태 엔진 (및 API)이 하이퍼 텍스트에 의해 구동되지 않는 경우 RESTful이 될 수 없으며 REST API가 될 수 없다. (강조!) 어딘가 수정해야 할 작살난 메뉴얼이 있나?
로이필딩은 매우 화가난 것같다.
하이퍼 미디어를 포함하지 않는 것의 사이드 이펙트는 클라이언트는 반드시 API를 호출할 때 URI들을 하드코딩해야 한다는 것이다. JSON 아웃풋이 조금은 도와줘야할 신호이다.
HAL - 하이퍼텍스트 애플리케이션 언어
HAL은 리소스와 API 사이를 하이퍼링크를 쉽게 하고 일관성 있게 하기 위한 간단한 포맷이다. HAL을 도입하는 것은 API를 탐색가능하게 하고 문서화 된 것이 API 자체에서 쉽게 보인다. 간단히 말해서 API가 일을 쉽게 하게 하고 클라이언트 개발자들에게 더 매력적으로 다가온다.
HAL을 채택한 API들은 메이저 프로그래밍 언어들을 위한 오픈소스에서 쉽게 사용된다. JSON을 사용할 때도 쉽게 사용할 수 있다.
HAL 문서의 구조
HAL 문서는 최소한 빈 리소스를 포함한다.
{}
리소스
대부분, 리소스는 self URI를 가지고 있다.
{
"_links": {
"self": { "href": "/example_resource" }
}
}
링크
링크는 리소스안에 포함되어야 한다.
{
"_links": {
"next": { "href": "/page=2" }
}
}
링크는 관계를 가질 수 있는데 (rel 속성) 이것은 의미(sementic)를 나타낸다. - 특정 링크
링크 rel은 리소스의 링크들을 구별하기 위한 방법이다.
여러 관계를 가질 때
{
"_links": {
"items": [{
"href": "/first_item"
},{
"href": "/second_item"
}]
}
}
HAL은 가벼운 mediatype으로 데이터와 하이퍼미디어들을 인코딩할수 있고 API의 다른 부분으로 이동할 수 있도록 소비자에게 알려준다. 다음 예는 self 링크와 aggregate root로 가는 링크까지 포함한다.
@GetMapping("/employees")
Resources<Resource<Employee>> all() {
List<Resource<Employee>> employees = repository.findAll().stream()
.map(employee -> new Resource<>(employee,
linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(),
linkTo(methodOn(EmployeeController.class).all()).withRel("employees")))
.collect(Collectors.toList());
return new Resources<>(employees,
linkTo(methodOn(EmployeeController.class).all()).withSelfRel());
}
Resource<>는 Spring HATEOAS의 또다른 컨테이너로 컬렉션을 캡슐화한다. 이것 또한 링크를 포함하게 해준다. 여기서 컬렉션을 자바의 컬렉션개념을 생각하면 안된다. REST 관점에서 컬렉션 캡슐화는 employee 리소스들에 대한 것이다.
그것이 모든 employees를 fetch하는 이유이다. 결과는 다음과 같다.
{
"_embedded": {
"employeeList": [
{
"id": 1,
"name": "Bilbo Baggins",
"role": "burglar",
"_links": {
"self": {
"href": "http://localhost:8080/employees/1"
},
"employees": {
"href": "http://localhost:8080/employees"
}
}
},
{
"id": 2,
"name": "Frodo Baggins",
"role": "thief",
"_links": {
"self": {
"href": "http://localhost:8080/employees/2"
},
"employees": {
"href": "http://localhost:8080/employees"
}
}
}
]
},
"_links": {
"self": {
"href": "http://localhost:8080/employees"
}
}
}
이 aggregate root로는 employee 리소스들의 컬렉션을 제공하고 top-level에 self링크가 있는 것을 볼 수 있다. "컬렉션"은 "_embedded" 섹션에 리스트업되어 있는데 이것이 HAL이 컬렉션을 표현하는 방법이다.
그리고 각각의 컬렉션 멤버들은 각자의 연관된 링크 정보들을 가지고 있다.
도대체 왜 이런 모든 링크들을 추가하는 것일까? 이것은 REST 서비스를 시간이 지남에 따라 발전 가능하게 해준다. 기존의 링크는 남아있되 새로운 링크는 미래에 추가되는 것이다. 새로운 클라이언트는 새로운 link들의 정보를 얻고, 레거시한 클라이언트들은 이전 링크를 가질수가 있다. 이것은 서비스가 relocated되거나 moved around할 때 매우 도움이 된다. 링크 구조가 남아 있다면 클라이언트는 이런것들과 상호작용을 할 수 있다.
Link 생성 중복 코드 제거
한 employee를 생성하는데 중복이 너무 많은 것을 눈치챘는가. 여기에 아주 좋은 해결책이 있다.
단순히 Employee → Resource로 변환시켜주는 기능을 사용하면 된다. Spring HATEOAS는 ResourceAssembler 인터페이스를 제공한다.
package payroll;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceAssembler;
import org.springframework.stereotype.Component;
@Component
class EmployeeResourceAssembler implements ResourceAssembler<Employee, Resource<Employee>> {
@Override
public Resource<Employee> toResource(Employee employee) {
return new Resource<>(employee,
linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(),
linkTo(methodOn(EmployeeController.class).all()).withRel("employees"));
}
}
이 간단한 인터페이스는 toResource() 메서드 하나를 가지고 있다. 리소스 객체 아닌 것들을 resource-based 객체로 만들어준다.
참고: Spring HATEOAS는 ResourceSupport라는 추상클래스를 제공한다. 하지만 간단하게 POJO들을 리소스로 래핑하기위해 그냥 Resource를 사용하는 것을 추천한다.
@GetMapping("/employees/{id}")
Resource<Employee> one(@PathVariable Long id) {
Employee employee = repository.findById(id)
.orElseThrow(() -> new EmployeeNotFoundException(id));
return assembler.toResource(employee);
}
이런식으로 Assembler를 주입받고 작업을 assembler에게 위임한다.
리스트를 작업할 때는,
List<Resource<Employee>> employees = repository.findAll().stream()
.map(assembler::toResource)
.collect(Collectors.toList())
다음과 같이 stream을 이용하면 편하다.
REST API 진화시키기
하나의 외부 라이브러리와 약간의 코드로 애플리케이션에 하이퍼미디어를 추가할 수 있다. 하지만 이 서비스를 RESTful 하게 만드는 것에 있어서 그것 뿐만이 아니다. REST는 애플리케이션을 탄력적으로 만들기 위한 설계적 제약의 집합이라고 할 수 있다. 탄력성의 중요한 점은 서비스를 업그레이드할 때 고객들이 작동하지 않는 시간에 고통받지 않게 하는 것이다.
예전에는 업그레이드는 고객들을 잃는 것으로 악명이 높았다. 다른 말로 하면 서버를 업그레이드를 하는 것은 고객들을 업데이트하는 것이었다. 요즘 시대는 시간단위 혹은 분 단위로 서비스가 중지 되는 것은 엄청난 손해라고 할 수 있다. 과거에는 부하가 적은 일요일 새벽 2시에 업그레이드 할 때도 있었지만 오늘날 전세계의 고객들을 상대로 그런 전략은 효과적이지 못하다.
다음 문제를 상상해보자: Employee 라는 시스템을 만들었고 엄청난 히트를 쳤다. 정말 셀수없는 엔터프라이즈에 팔았다. 갑자기 employee 이름을 firstName과 lastName으로 바꾸라는 요구가 생겼다. 그건 생각못했다. Employee 클래스를 열어서 name을 firstName, lastName으로 바꾸기 전에 잠깐 생각해보라. 그것이 고객들을 빠져나가게 하는가? 얼마나 업그레이드 시간이 걸릴까. 당신의 서비스에 오는 고객들을 모두 통제할 수 있는가?
서비스가 정지되는 시간 = 돈을 잃는 것. 이것에 대해 준비 되었는가?
REST 이전에 아주 오래된 전략이 있다.
- 데이터베이스 컬럼은 절대 지우지 말 것...
데이터베이스 테이블의 컬럼은 언제나 추가할 수 있다. 한 가지 방법만 생각하지말자. RESTful 서비스 원칙도 똑같다. JSON 표현식에 새로운 필드를 추가한다.
{
"id": 1,
"firstName": "Bilbo",
"lastName": "Baggins",
"role": "burglar",
"name": "Bilbo Baggins",
"_links": {
"self": {
"href": "http://localhost:8080/employees/1"
},
"employees": {
"href": "http://localhost:8080/employees"
}
}
}
firstName, lastName과 Name이 보이는가? 정보의 중복으로 볼 수 있지만, 기존 사용자와 신규 사용자를 둘 다 서포트하는 목적을 달성한다. 이것은 서버를 업그레이드하는데 클라이언트쪽 업그레이드를 동시에 하지 않는 것을 의미한다. Downtime (고장나는 시간)을 줄일 수 있다.
서버쪽 코드는 다음 과 같이 변경 가능하다.
@Data
@Entity
class Employee {
private @Id @GeneratedValue Long id;
private String firstName;
private String lastName;
private String role;
Employee() {}
Employee(String firstName, String lastName, String role) {
this.firstName = firstName;
this.lastName = lastName;
this.role = role;
}
public String getName() {
return this.firstName + " " + this.lastName;
}
public void setName(String name) {
String[] parts =name.split(" ");
this.firstName = parts[0];
this.lastName = parts[1];
}
}
당연히 API 의 모든 변화가 위처럼 단순히 string 을 자르고 붙이는 것 처럼 간단히 해결되지는 않는다. 하지만 변화에 대해 대부분의 극복 시나리오는 불가능 하진 않다.
REST 메서드 fine tuning
POST 메서드로 기존, 신규 고객 처리하기
@PostMapping("/employees")
ResponseEntity<?> newEmployee(@RequestBody Employee newEmployee) throws URISyntaxException {
Resource<Employee> resource = assembler.toResource(repository.save(newEmployee));
return ResponseEntity
.created(new URI(resource.getId().expand().getHref()))
.body(resource);
}
Spring MVC의 ResponseEntity는 HTTP 201 created 상태메세지를 위해 사용된다. 이러한 타입의 응답은 전형적으로 Location 응답 헤더를 포함한다.
PUT 메서드 사용
@PutMapping("/employees/{id}")
ResponseEntity<?> replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) throws URISyntaxException {
Employee updatedEmployee = repository.findById(id)
.map(employee -> {
employee.setName(newEmployee.getName());
employee.setRole(newEmployee.getRole());
return repository.save(employee);
})
.orElseGet(() -> {
newEmployee.setId(id);
return repository.save(newEmployee);
});
Resource<Employee> resource = assembler.toResource(updatedEmployee);
return ResponseEntity
.created(new URI(resource.getId().expand().getHref()))
.body(resource);
}
resource에서 self를 조회하기 위해 getId() 메서드를 사용한다. 이 메서드는 Link를 만드는데 Java URI로 변환가능하다.
REST에서 리소스의 id는 해당 리소스의 URI이다. 그러므로 Spring HATEOAS는 근본적인 데이터타입의 아이디를 주지 않는다. 대신에 URI를 준다. ResourceSupport.getId()와 Employee.getId()를 헷갈리지 말것.
물론 수정하는 행위를 HTTP 201 Created로 보느냐는 논란의 여지가 있다. 하지만 응답 헤더가 pre-loaded 되어 있으므로 그냥 돌려보자.
DELETE 메서드 핸들링
@DeleteMapping("/employees/{id}")
ResponseEntity<?> deleteEmployee(@PathVariable Long id) {
repository.deleteById(id);
return ResponseEntity.noContent().build();
}
이것은 HTTP 204 No Content 응답을 내린다.
요약
정리하자면 REST는 예쁜 URI에 XML이 아닌 JSON을 내려준다고 해서 REST라고는 할 수 없다. 대신에 다음 전략을 따르며 기존 고객을 빠져나가지 않게 하는 것을 돕는 것이다.
1. 이전 필드를 삭제하지 말고 지원해라.
2. rel-based한 링크를 사용하여 client가 URI를 하드코드하지 않게 한다.
3. old 링크들을 가능한 한 오래 보유해라. URI가 바뀐다고 하더라도 기존 rels를 보유하여 기존 고객들이 새로운 기능으로 연결되게 하라.
4. 클라이언트에게 다양한 상황에서 행동들을 알려주기 위해 데이터를 단순 적재 하지말고 Link를 사용해라.