[Spring REST API #9] 스프링 HATEOAS 개념 및 적용

스프링 HATEOAS


HATEOAS는 Hypermedia As The Engine Of Application State 의 쟉자로 REST 아키텍처의 한 구성요소입니다. 이 HATEOAS를 통해서 어플리케이션의 상태를 전이할 수 있는 메커니즘을 제공할 수 있습니다.


예로 들어, 송금 어플리케이션이 현재 Home 화면을 나타내는 상태에 있다고 생각해 봅시다. 이 Home 화면에는 입금, 출금, 송금 등 다른 화면 혹은 기능, 리소스로 갈 수 있는 링크들이 존재할 것입니다. 이 링크를 통해서 다른 페이지로 가는 것을 다른 상태로 전이한다고 보고 이 링크들에 대한 레퍼런스를 서버 측에서 전송합니다. 그럼으로서 클라이언트가 명시적으로 링크를 작성하지 않고도 서버 측에서 받은 링크의 레퍼런스를 통해 어플리케이션의 상태 및 전이를 표현할 수 있습니다. 이것이 바로 올바른 REST 아키텍처에서의 HATEOAS 구성법입니다.


<서버 측에서 보내온 링크 레퍼런스 예시>


스프링 진영에서는 스프링 HATEOAS라는 프로젝트를 통해 스프링 사용자들에게 HATEOAS 기능을 손쉽게 쓸 수 있도록 제공하고 있습니다. 이 프로젝트의 중요 기능은 HTTP 응답에 들어갈 유저, 게시판 글, 이벤트 등과 같은 Resource. 다른 상태 혹은 리소스에 접근할 수 있는 링크 레퍼런스인 Links 를 제공하는 것입니다.



HATEOAS 링크에 들어가는 정보는 위에서 보았듯이 현재 Resource의 관계이자 링크의 레퍼런스 정보인 REL 과 하이퍼링크인 HREF 두 정보가 들어갑니다.


모든 소스 코드는 여기에서 보실 수 있습니다.


프로젝트 구조

+---src
| +---main
| | +---java
| | | \---com
| | | \---example
| | | \---springrestapi
| | | | SpringRestApiApplication.java
| | | |
| | | +---common
| | | | ErrorsSerializer.java
| | | | TestDescription.java
| | | |
| | | \---events
| | | Event.java
| | | EventController.java
| | | EventDto.java
| | | EventRepository.java
| | | EventResource.java
| | | EventStatus.java
| | | EventValidator.java
| | |
| | \---resources
| | | application.yml
| | |
| | +---static
| | \---templates
| \---test
| \---java
| \---com
| \---example
| \---springrestapi
| | SpringRestApiApplicationTests.java
| |
| \---events
| EventControllerTests.java
| EventTest.java


의존성 관리

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<!-- <scope>test</scope>-->
</dependency>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>pl.pragmatists</groupId>
<artifactId>JUnitParams</artifactId>
<version>1.1.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
<version>0.25.1.RELEASE</version>
</dependency>
</dependencies>

위 중 

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

을 추가해야 스프링 부트에서 HATEOAS 프로젝트를 쉽게 사용할 수 있습니다.


테스트 코드

@Test
@TestDescription("정상적으로 이벤트를 입력")
public void createEvent() throws Exception {
EventDto event = EventDto.builder()
.name("Spring")
.description("REST API Development")
.beginEnrollmentDateTime(LocalDateTime.of(2010, 11, 23, 14, 23))
.closeEnrollmentDateTime(LocalDateTime.of(2018, 11, 30, 14, 23))
.beginEventDateTime(LocalDateTime.of(2018, 12, 5, 14, 30))
.endEventDateTime(LocalDateTime.of(2018, 12, 6, 14, 30))
.basePrice(100)
.maxPrice(200)
.limitOfEnrollment(100)
.location("D Start up Factory")
.build();

mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaTypes.HAL_JSON_UTF8)
.content(objectMapper.writeValueAsString(event)))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("id").exists())
.andExpect(header().exists(HttpHeaders.LOCATION))
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_UTF8_VALUE))
.andExpect(jsonPath("free").value(false))
.andExpect(jsonPath("offline").value(true))
.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
.andExpect(jsonPath("_links.self").exists())
.andExpect(jsonPath("_links.query-events").exists())
.andExpect(jsonPath("_links.update-events").exists())
;
}
  • HTTP 응답 Body에 _links 프로퍼티가 포함된 HATEOAS 정보를 받는 것을 체크하는 테스트 코드입니다. _links 에는 리소스 자기 자신을 나타내는 self, events들을 질의할 수 있는 query-events, 그리고 이벤트들을 업데이트 할 수 있는 update-events 링크를 포함할 것입니다.

소스 코드

import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource;

import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;

public class EventResource extends Resource<Event> {

public EventResource(Event event, Link... links) {
super(event, links);
add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
}
}
  • 스프링 프로젝트에서 HATEOAS 기능을 제공하는 Resource 클래스를 상속받아 위와 같이 Event 클래스의 Resource인 EventResource 클래스를 작성합니다. 위 코드를 보면 생성자에서 EventController.class에 매핑되어 있는 URL정보 및 Event 객체 자기 자신을 나타내는 self를 더해 EventResource가 생성되는 것을 알 수 있습니다.
  • Resource 클래스를 상속받아 쓰는 이유는 Resource 클래스의 필드에 @JsonUnwrapped 어노테이션이 붙어 있어 Event와 같은 여러 프로퍼티가 있는 클래스를 event : {} 같은 감싼 형태가 아닌 아닌 프로퍼티들 그대로 데이터를 추출하여 직렬화 하기 때문입니다.

import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.mvc.ControllerLinkBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.validation.Valid;
import java.net.URI;

import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_UTF8_VALUE)
public class EventController {

@Autowired
EventRepository eventRepository;

@Autowired
ModelMapper modelMapper;

@Autowired
EventValidator eventValidator;

@PostMapping
public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) {
if(errors.hasErrors()) {
return ResponseEntity.badRequest().body(errors);
}

eventValidator.validate(eventDto, errors);
if(errors.hasErrors()) {
return ResponseEntity.badRequest().body(errors);
}

Event event = modelMapper.map(eventDto, Event.class);
event.update();
Event newEvent = this.eventRepository.save(event);

        // HATEOAS link added
ControllerLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(newEvent.getId());
URI createdURI = selfLinkBuilder.toUri();
EventResource eventResource = new EventResource(newEvent);
eventResource.add(linkTo(EventController.class).withRel("query-events"));
eventResource.add(linkTo(EventController.class).withRel("update-events"));
return ResponseEntity.created(createdURI).body(eventResource);
}

}

  • 위 컨트롤러 코드를 보면 ControllerLinkBuilder를 통해 컨트롤러와 매핑된 URL + 이벤트 ID로 selfLinkBuilder 객체를 만듭니다. 이때 링크되는 URL은 http://localhost:8080/api/events/{id} 와 같습니다.
  • Event를 EventResource 생성자의 인자로 넣어 손쉽게 EventResource 객체를 생성해 HTTP 응답 메세지에 담을 수 있습니다. 


결과 화면


요청

MockHttpServletRequest:
HTTP Method = POST
Request URI = /api/events/
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Accept:"application/hal+json;charset=UTF-8"]
Body = {"name":"Spring","description":"REST API Development","beginEnrollmentDateTime":"2010-11-23T14:23:00","closeEnrollmentDateTime":"2018-11-30T14:23:00","beginEventDateTime":"2018-12-05T14:30:00","endEventDateTime":"2018-12-06T14:30:00","location":"D Start up Factory","basePrice":100,"maxPrice":200,"limitOfEnrollment":100}
Session Attrs = {}


응답

MockHttpServletResponse:
Status = 201
Error message = null
Headers = [Location:"http://localhost/api/events/1", Content-Type:"application/hal+json;charset=UTF-8"]
Content type = application/hal+json;charset=UTF-8
Body = {"id":1,"name":"Spring","description":"REST API Development","beginEnrollmentDateTime":"2010-11-23T14:23:00","closeEnrollmentDateTime":"2018-11-30T14:23:00","beginEventDateTime":"2018-12-05T14:30:00","endEventDateTime":"2018-12-06T14:30:00","location":"D Start up Factory","basePrice":100,"maxPrice":200,"limitOfEnrollment":100,"offline":true,"free":false,"eventStatus":"DRAFT","_links":{"self":{"href":"http://localhost/api/events/1"},"query-events":{"href":"http://localhost/api/events"},"update-events":{"href":"http://localhost/api/events"}}}
Forwarded URL = null
Redirected URL = http://localhost/api/events/1
Cookies = []


Body 부분

{
"id":1,
"name":"Spring",
"description":"REST API Development",
"beginEnrollmentDateTime":"2010-11-23T14:23:00",
"closeEnrollmentDateTime":"2018-11-30T14:23:00",
"beginEventDateTime":"2018-12-05T14:30:00",
"endEventDateTime":"2018-12-06T14:30:00",
"location":"D Start up Factory",
"basePrice":100,
"maxPrice":200,
"limitOfEnrollment":100,
"offline":true,
"free":false,
"eventStatus":"DRAFT",
"_links":{
"self":{
"href":"http://localhost/api/events/1"
},
"query-events":{
"href":"http://localhost/api/events"
},
"update-events":{
"href":"http://localhost/api/events"
}
}
}


참조: https://www.inflearn.com/course/spring_rest-api/#

 https://en.wikipedia.org/wiki/HATEOAS

소스 코드 : https://github.com/engkimbs/spring-rest-api




이 글을 공유하기

댓글(3)

  • 2020.01.06 18:44 신고

    안녕하세요. 잘모르겠어서 그러는데 ㅠㅠ
    지금 reponse body부분에 self의 href에서 나온 주소값이 컨트롤러 클래스에 있는 @RequestMapping 에 설정된 값으로 되어있는 것 같은데 혹시 메서드에 적용된 각각의 requestMapping 주소값에는 어떻게 접근해야할까요..?ㅠㅠ

  • ㅁㄴㅇㄹ
    2020.05.09 15:07

    쟉자 오타있어엽

Designed by JB FACTORY