(Hypermedia As The Engine Of Application State)
애플리케이션의 상태는 Hyperlink를 이용해 전이되어야 한다는 뜻입니다. 이러한 방법은 클라이언트가 서버로부터 특정 요청을 할 때, 요청에 필요한 URI를 응답에 포함시켜 반환하는 것으로 가능하게 할 수 있습니다.
접근 가능한 추가 API들이 links라는 이름으로 제공됩니다. 즉, 조회(GET)하는 요청 이후, 이를 조회, 수정, 삭제할 때 이러한 동작을 URI를 이용해 동적으로 알려준다는 의미입니다. 각 기능마다 URI를 링크시킴으로써, 동적인 API 제공이 가능하도록 합니다.
아래는 HATEOAS 사용시, 이를 위한 규격인 HAL의 예제입니다.
"data": {
"idx" : 123,
"userId" : asd123,
...,
"_links": [
{
"rel": "modify",
"href": "http://localhost:8080/duplicate"
},
{
"rel" : "duplicate-list",
"href": "http://localhost:8080/members/abcdefghifkey
}
]
}
응답에서 "_links"로 클라이언트가 수행할 수 있는 작업을 포함합니다.
이것이 HATEOAS 의 기본 개념입니다.
📌 HATEOAS 링크에 들어가는 기본 정보는 현재 Resource의 관계이자 링크의 레퍼런스 정보인 REL 과 HREF 두 정보가 들어간다.
- HREF(Hyper Media Reference) : Resource의 URI 표시
- REL(Relation) : Resource와의 관계
- self : 자기자신의 URL
- profile : 자원들에 대한 명세의 링크
✅ 참고
HATEOAS는 Richardson 성숙도 모델에서 가장 높은 단계인 3단계 구현 수준이다.
Richardson 성숙도 모델에 대해서는 다음 글에 설명이 있습니다!
다음 글 : Richardson 성숙도 모델
- 요청 URI가 변경되더라도 클라이언트에서 동적으로 생성된 URI를 사용함으로써, 클라이언트가 URI 수정에 따른 코드를 변경하지 않아도 되는 편리함을 제공합니다.
- URI 정보를 통해 들어오는 요청을 예측할 수 있게 됩니다.
- Resource가 포함된 URI를 보여주기 때문에, Resource에 대한 신뢰를 얻을 수 있습니다.
- 클라이언트가 제공되는 API의 변화에 일일이 대응하지 않아도 되는 편리함을 얻을 수 있습니다.
- 하지만 응답 데이터가 다른 리소스 URI와 의존성을 가지게 되기 때문에 구현이 다소 까다롭다는 단점이 있다.
spring boot에 적용한 분들과 블로그 글들이 많았는데 spring으로 한거는 정말정말 찾기 힘들어서 사실상 스프링 홈페이지에서 레퍼런스해서 적용했습니다.
역시 공식 문서보고 하는건 어렵기도 하면서 쉽기도하고 재밌기도하고....😂😂
참고하시기 전에 제가 사용한 개발 환경이 궁금하시면 개발환경 여기 가셔서 확인해주세요!!
(이쯤되면 잘 아시겠지만 https://mvnrepository.com/ 가서 검색하셔서 추가해주시면 됩니다!)
<dependency>
<groupId>org.springframework.hateoas</groupId>
<artifactId>spring-hateoas</artifactId>
<version>0.25.0.RELEASE</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>0.25.0.RELEASE</version>
</dependency>
hateoas 기능을 사용하려면 DTO에 RepresentationModel과 ResourceSupport 클래스중 하나를 extends 해서 사용해야하는데요, 예전에 쓰는 이름들이 있어서 먼저 확인해볼게요.
변경 전 | 변경 후 |
---|---|
ResourceSupport | RepresentationModel |
Resource | EntityModel |
Resources | CollectionModel |
PagedResources | PagedModel |
따라서 DTO에 RepresentationModel를 사용해서 링크를 만들어야합니다.
package com.project.dto;
import org.springframework.hateoas.ResourceSupport;
import lombok.Data;
@Data
public class Post extends ResourceSupport {
private int rownum; // pk
❗❗ 문제점
DTO 생성시 @Data 어노테이션을 사용해서 DTO를 생성했는데요, @Data는 @Getter, @Setter, @RequiredArgsConstructor, @ToString, @EqualsAndHashCode를 한번에 설정해주는 어노테이션이라 편하게 사용하고 있지만, @Data 어노테이션에 extends를 사용하게 되면 Lombok에는 부모클래스가 포함되지 않는게 기본이라 경고메시지가 나올수 있습니다.
@EqualsAndHashCode 어노테이션에서 사용하는 equals와 hashCode 메소드가 부모클래스까지 선언되도록 하려면 @EqualsAndHashCode(callSuper=true), 아니라면 @EqualsAndHashCode(callSuper=false)를 선언해서 사용하시면 됩니다.
HATEOAS를 적용하기 전엔 @RestController를 이용한 JSON 데이터 응답으로 name-value 형식의 데이터를 전달 받았었습니다.
요청 : http://localhost:8000/post/list (게시글 목록)
응답
[
{
"postIdx": 2,
"userinfoId": null,
"postTitle": "제목 테스트2",
"postContent": "내용 테스트2",
"postLoc": "부산",
"postRegdate": "23-10-29",
"postStatus": 1
},
{
"postIdx": 1,
"userinfoId": null,
"postTitle": "제목 테스트1",
"postContent": "내용 테스트1",
"postLoc": "경주",
"postRegdate": "23-10-28",
"postStatus": 1
}
]
이런식으로 각 게시글의 데이터들이 JSON 형태의 데이터로 응답받아 졌는데요,
HATEOAS를 구현할 때는 HTTP 메소드의 종류마다 적절한 HTTP 응답코드를 사용해야 하기 때문에 ResponseEntity를 사용하여 응답 코드를 구현하였습니다.
@RestController
@RequestMapping("/post")
@RequiredArgsConstructor
.
.
.
@GetMapping(value = "/list")
public ResponseEntity<List<Post>> getNoticeList() { // ====> ①
try {
List<Post> postList = postService.getSelectPostList();
// 데이터마다 self link 추가합니다.
for (Post post : postList) { // ====> ②
Link link = WebMvcLinkBuilder.linkTo(PostRestController.class)
.slash(post.getPostIdx())
.withSelfRel();
// 다른 컨트롤러의 링크 // ====> ③
Link noticeListLink = WebMvcLinkBuilder.linkTo(NoticeRestController.class)
.slash("list")
.withRel("notice-list");
post.add(link);
post.add(noticeListLink);
}
// 헤더에 content type 명시
HttpHeaders headers = new HttpHeaders(); // ====> ④
headers.add(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE);
return new ResponseEntity<>(postList, headers, HttpStatus.OK); // ===> ⑤
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); // ====> ⑥
}
}
① HTTP 응답 코드를 구현하기 위해 ResponseEntity 클래스를 사용
- 응답 처리는 HttpEntity와 ResponseEntity 클래스가 존재하지만 HttpEntity는 응답 코드를 제공하지 않기 때문에 ResponseEntity를 사용하였습니다.
② JSON 데이터에 링크 추가
- .slash() : http://localhost:8000/post 뒤에 슬래시(/)뒤에 추가될 주소를 선언해 줄 수 있습니다
- .withSelfRel() : 생성된 링크에 self 관계를 설정합니다.
- .withRel("xxxx") : 생성된 링크의 관계 명칭을 설정할 수 있습니다.
③ JSON 데이터에 링크 추가
- 다른 컨트롤러 링크에도 이동 가능 하도록 linkTo 메소드에 다른 컨트롤러 클래스 명 사용
④ 응답 헤더에 Content Type을 설정합니다.
⑤ 올바른 응답 결과라면 200코드 출력
⑥ 오류 발생시 200번대 코드가 아닌 400번대 코드 출력
[
{
"postIdx": 2,
"userinfoId": null,
"postTitle": "제목 테스트2",
"postContent": "내용 테스트2",
"postLoc": "부산",
"postRegdate": "23-10-29",
"postStatus": 1,
"links": [
{
"rel": "post-detail",
"href": "http://localhost:8000/post/2"
},
{
"rel": "self",
"href": "http://localhost:8000/post/list"
}
]
},
{
"postIdx": 1,
"userinfoId": null,
"postTitle": "제목 테스트1",
"postContent": "내용 테스트1",
"postLoc": "경주",
"postRegdate": "23-10-28",
"postStatus": 1,
"links": [
{
"rel": "post-detail",
"href": "http://localhost:8000/post/1"
},
{
"rel": "self",
"href": "http://localhost:8000/post/list"
}
]
}
]
❗️❗️ 문제점
각각의 데이터에 Self 링크를 추가해 주었지만 다른 컨트롤러로 이동할 수 있는 링크들도 각각의 데이터에 넣게되면 중복되는 링크들이 너무 많아져 데이터의 양도 늘어나고 가독성도 많이 떨어진다고 생각했다. 그래서 링크를 추가로 담을 수 있게 해주는 CollectionModel 클래스를 사용해 개선해 주었습니다.
CollectionModel을 사용해 별도로 링크를 추가하는 방식으로 했습니다.
CollectionModel 클래스는 Spring HATEOAS에서 사용되는 컨테이너 클래스로 EntityModel이나 CollectionModel 같은 여러 타입을 캡슐화할 수 있습니다.
JSON 데이터 안에 링크를 포함시켰던 형식에서 링크와 content를 분리하는 형식으로 변경되었습니다.
Data { links } ➡︎ links + Data
@GetMapping(value = "/list")
public ResponseEntity<CollectionModel<Post>> getNoticeList() { // ====> ①
try {
List<Post> postList = postService.getSelectPostList(selectKeyword);
// 데이터마다 self link 추가하기
for (Post post : postList) { // ====> ②
Link link = WebMvcLinkBuilder.linkTo(PostRestController.class)
.slash(post.getPostIdx())
.withSelfRel();
post.add(link);
}
// 컨트롤러 다른 자원에 접근하는 링크
CollectionModel<Post> postResources = CollectionModel.of(postList); // ====> ③
Link postListLink = WebMvcLinkBuilder.linkTo(PostRestController.class) // ====> ④
.slash("list")
.withSelfRel();
Link noticeListLink = WebMvcLinkBuilder.linkTo(NoticeRestController.class)
.slash("list")
.withRel("notice-list");
postResources.add(postListLink);
postResources.add(noticeListLink);
// 헤더에 content type 명시
HttpHeaders headers = new HttpHeaders(); // ====> ⑤
headers.add(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE);
return new ResponseEntity<>(postResources, headers, HttpStatus.OK); // ====> ⑥
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); // ====> ⑦
}
}
① HTTP 응답 코드를 구현하기 위한 ResponseEntity, 링크를 담기 위한 CollectionModel 사용
- 응답 처리는 HttpEntity와 ResponseEntity 클래스가 존재하지만 HttpEntity는 응답 코드를 제공하지 않기 때문에 ResponseEntity를 사용하였습니다.
② JSON 데이터에 링크 추가
- .slash() : http://localhost:8000/post 뒤에 슬래시(/)뒤에 추가될 주소를 선언해 줄 수 있습니다
- .withSelfRel() : 생성된 링크에 self 관계를 설정합니다.
- .withRel("xxxx") : 생성된 링크의 명칭을 설정할 수 있습니다.
③ CollectionModel 클래스를 사용해 그룹화합니다.
④ 다른 컨트롤러의 링크를 생성합니다.
- 다른 컨트롤러 링크에도 이동 가능 하도록 linkTo 메소드에 다른 컨트롤러 클래스 명 사용
⑤ 응답 헤더에 Content Type을 설정합니다.
⑥ 올바른 응답 결과라면 200코드 출력
⑦ 오류 발생시 200번대 코드가 아닌 400번대 코드 출력
{
"links": [
{
"rel": "self",
"href": "http://localhost:8000/post/list"
},
{
"rel": "notice-list",
"href": "http://localhost:8000/notice/list"
}
],
"content": [
{
"rownum": 0,
"postIdx": 2,
"userinfoId": null,
"postTitle": "제목 테스트2",
"postContent": "내용 테스트2",
"postLoc": "부산",
"postRegdate": "23-10-29",
"postStatus": 1,
"links": [
{
"rel": "post-detail",
"href": "http://localhost:8000/post/2"
}
]
},
{
"postIdx": 1,
"userinfoId": null,
"postTitle": "제목 테스트1",
"postContent": "내용 테스트1",
"postLoc": "경주",
"postRegdate": "23-10-28",
"postStatus": 1,
"links": [
{
"rel": "post-detail",
"href": "http://localhost:8000/post/1"
}
]
}
]
}
응답 내용을 그룹안에 "links", "content"로 나눠서 출력되도록 했습니다.
📌 보완 사항
● API 문서 만들기 : Hal Browser나 API 명세를 만들어 링크에 추가
○ 요청 문서화
○ 응답 문서화
○ 링크 문서화○ 컨트롤러의 메소드를 이용해서 링크를 가져오는 방법으로 특정 클래스의 요청 URL이 변경 되어도 전체의 컨트롤러에서 링크 수정없이 동적으로 변경가능한 형태로 개선해봐야겠다.
○ 동적으로 불러오는 형태가 사실 이상적인거긴하지만 팀프로젝트를 하면서 자바와 스프링 버전이 충족되지 않아 methodOn 메소드를 사용할 수 없어 하드코딩한 면이 있습니다..
참고사이트
[docs.spring.io] : https://docs.spring.io/spring-hateoas/docs/current/reference/html/