Spring HATEOAS 사용해 보기

Yukicow·2024년 3월 23일
0

이전에 REST API와 관련된 글을 작성한 적이 있는데, HATEOAS와 self-descriptive messages가 중요하다는 얘기를 했다.

self-descriptive messages는 사실상 스펙 문서를 열심히 작성하면 될 문제이다. 하지만 HATEOAS를 제대로 활용하기 위해서는 Link를 개발자가 일일이 신경 써 주어야 한다는 느낌이 강하게 들었다.

이러한 문제점을 해결하기 위해서 Spring에서는 Spring HATEOAS을 도입하여 제공하고 있다.

이번 기회에 Spring HATEOAS가 무엇을 제공해 주고 어떻게 사용하는지 알아 보도록 하자.



Spring HATEOAS란

This project provides some APIs to ease creating REST representations that follow the 'HATEOAS' principle when working with Spring and especially Spring MVC. The core problem it tries to address is link creation and representation assembly.

쉽게 요약하면, HATEOAS를 따르는 REST한 representations을 만드는 것을 도와준다는 뜻이다.

( representation이 무엇인지는 이전 글에서 설명했지만 간단하게 설명하면, Resource의 특정 시점에 대한 상태 정보를 나타낸다. 응답으로 표현되는 데이터들은 Resource가 아닌 representation에 해당한다고 볼 수 있다. )



구조

Spring HATEOAS는 위와 같은 클래스 계층 모델을 제공하고 있다.

가장 일반적인 방법은 RepresentaionModel을 상속받는 클래스를 생성하여 모든 프로퍼티를 포함하도록 구현하는 것이다.

하지만, 그렇게 할 경우 일일이 개발자가 클래스를 생성해야 하기 때문에, 밑에 있는 구현체들을 사용하는게 편리하다.


Link

Link는 실제 상태 전이에 필요한 링크를 나타내는 클래스이다.

이 Link를 위의 Model에 추가하면 링크를 포함하는 응답(일반적으로는 JSON이나 XML)을 만들 수 있다.

1. 키워드

Link에는 rel, href, title, description, name, profile, type 등 많은 키워드를 제공한다.

JSON Hypertext Application Language(JSON HAL) 문서에 나와 있는 내용으로 정리해 본 것이다.

1. rel

링크의 관계를 나타낸다. 이것은 해당 링크가 현재 리소스와 어떤 관계에 있는지를 설명하는 데에 사용된다.

예를 들어, 'self' 링크는 현재 리소스 자체를 가리키며, 'next' 링크는 다음 페이지를 가리키는 형태가 된다. 이를 통해 클라이언트가 어떤 정보를 기대할 수 있는지 파악할 수 있다.

( 사실 rel에 대한 내용을 보았는데 이해를 못 해서 그냥 GPT와 일반적으로 구글에서 검색되는 내용을 조합했다. 문서를 직접 보고 싶다면 이곳을 참조하자. )

2. profile(optional)

리소스에 대한 추가 정보(프로필)를 제공하는 데 사용될 수 있다. 예를 들어, 해당 리소스의 meaning/structure/semantics/type 정보를 나타내는 문서를 작성하고 profile에 링크로 달 수 있다.

Uinform Interface의 조건 중 self descriptive message를 만족시키기 위한 속성이 아닐까 한다.

3. href

URI 또는 URI 템플릿을 말한다. ( 템플릿이라 함은 /boards/{id} 이런걸 말한다. )

만약 URI가 템플릿이라면 true값을 갖는 templated 속성을 함께 추가해야 한다.

4. templated(optional)

href가 나타내는 URI가 템플릿인지 나타내는 속성이다.

5. type(optional)

target resource에 대해 요청을 보낼 때 어떤 미디어 타입으로 응답이 반환될지에 대한 힌트로 사용되는 문자열이다.

즉, href를 클릭하면 어떤 미디어 타입이 오는지 또는 올 수 있는지(?) 알려주는 속성이라고 보면 된다.

6. deprecation(optional)

추후에 더 이상 사용되지 않는 URI인지 나타내는 속성이다.

7. name(optional)

같은 "rel" 속성을 공유하는 링크 객체들을 구분하기 위한 두번째 식별키로 사용된다.

정확히 "rel"을 의미하는게 맞는지는 모르겠다..

8. title(optional)

링크에 대한 제목이다.

8. hreflang(optional)

target resource의 언어를 나타낸다.



2. 생성

org.springframework.hateoas.Link의 스태틱 메소드를 통해서도 만들 수 있고, WebMvcLinkBuilder를 통해서도 만들 수 있다.

Link.of("/boards").withRel("board").withTitle("Board list")

WebMvcLinkBuilder를 방식

linkTo(BoardController.class).withRel("board").withTitle("Board list")

WebMvcLinkBuilder를 사용하면, URI를 하드코딩하는 것을 막을 수 있다.

Controller의 root path(공통 @RequestMapping값)를 포함하여 링크를 생성할 수 있기 때문에, 나중에 Controller의 URI 스펙이 조금 바뀌더라도 코드를 고치는 일이 없어진다.

예를 들어 이런 경우가 있을 수 있다. 좀 억지스러울 수도 있음ㅋ

게시판 컨트롤러에서 응답으로 특정 게시판의 특정 댓글을 조회하는 URI을 포함시키고 싶다고 가정해 보자.

게시판 컨트롤러는 BoardController이고 댓글 컨트롤러는 CommentController이다.

댓글의 상세 조회 URI는 httpx://localhost:8080/boards/{boardId}/comments/{commentId} 구조라고 가정해 보자 ( 링크 걸려서 일부로 x 붙임 )

위처럼 EntityModel에 해당 URI를 담기 위해서는 계층 구조를 갖고 있어 Controller 정보만을 가지고는 하드코딩을 피할 수 없다.


EntityModel.of(board, 
	linkTo(BoardController.class).slash(board.getId()).slash("comment").slash(commentId)
);

대충 이런 형태가 될 텐데 "comment"가 하드코딩 되어 있기 때문에 변경에 용의하지 않다.

나중에 comment가 아니라 다른 단어로 바꿔야 되면 모든 comment를 하나하나 바꿔 주어야 한다.

이걸 막기 위해 WebMvcLinkBuilder는 메소드를 통해 특정 메소드의 URI까지 가져올 수가 있다.

EntityModel.of(board, 
	linkTo(CommentController.class.getMethod("fetchComment", Long.class, Long.class), boardId, commentId)
);

OR

EntityModel.of(board, 
	linkTo(methodOn(CommentController.class).fetchComment(boardId, commentId))
);

첫 번째 방식은 예외처리도 해야하고 코드가 길어서 너무 불편하다. methodOn()을 사용하는 방식이 더 간편하다.

WebMvcLinkBuilder를 사용하는 방식에는 단점도 존재한다.

  • The return type has to be capable of proxying, as we need to expose the method invocation on it.

  • The parameters handed into the methods are generally neglected (except the ones referred to through @PathVariable, because they make up the URI).

첫 번째는 무슨 말인지 잘 모르겠다..

두 번째는 @PathVariable과 사용되는 인자를 제외한 나머지 인자는 무시된다는 뜻이다.

methodOn()을 호출하기 위해서는 실제 참조되는 메소드의 인자들을 함께 넘겨야 하는데, @PathVariable과 사용되는 인자들은 URI를 만드는데 사용되어야 하기 때문에 필요하지만 나머지는 안 쓰인다.

즉, 실제 불필요한 데이터이기 때문에 그냥 null을 넘겨 주면 된다.


편의 모델 종류

1. EntityModel

단일 객체를 응답으로 표현하기에 적합한 편의 모델로서, 모든 응답에 대해 커스텀할 필요 없이 EntityModel 객체를 이용하면 편리하게 사용이 가능하다.

EntityModel에는 포함할 데이터(객체)와, Link를 담으면 링크가 포함된 응답을 반환할 수 있다.


EntityModel.of(board, .. links);

위와 같이 응답에 포함할 board라는 객체를 EntityModel에 인자로 넘겨 주었고, 그 뒤에는 상태 전이에 사용할 link들을 원하는 수만큼 추가할 수 있다.

{
  "id": 1,
  "title": "제목",
  "content": "내용",
  "_links": {
    "self": {
      "href": "http://localhost:8080/boards"
      "title": "Board Detail"
    }
  }
}

2. CollectionModel

CollectionModel은 단일 데이터가 아닌 여러 데이터를 묶기 위한 모델이다.

CollectionModel에 포함되는 데이터들은 (필요시) 하나하나가 HATEOAS한 개체이어야 한다.

CollectionModel.of(entityModels, 
            linkTo((BoardController.class)).withSelfRel()
        );

사용법은 EntityModel과 크게 다르지 않다.

{
  "_embedded": {
    "boardDtoList": [
      {
        "id": 1,
        "title": "1",
        "content": "1",
        "_links": {
          "self": {
            "href": "http://localhost:8080/boards/1",
            "title": "Board Detail"
          },
          "boards": {
            "href": "http://localhost:8080/boards",
            "title": "Board list"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/boards"
    }
  }
}



3. PagedModel

CollectionModel과 비슷하지만 Page와 관련된 데이터를 포함한다.

PagedModel.of(entityModels,
            new PagedModel.PageMetadata(pageable.getPageSize(), pageable.getPageNumber(), page.getTotalElements(), page.getTotalPages()),
            linkTo(methodOn(BoardController.class).fetchBoardList(null)).withSelfRel()
 );

PagedModel의 PageMetadata를 인자로 받으며 size, pageNumber, totalElements, totalPage를 필요로 한다.

{
  "_embedded": {
    "boardDtoList": [
      {
        "id": 1,
        "title": "1",
        "content": "1",
        "_links": {
          "self": {
            "href": "http://localhost:8080/boards/1",
            "title": "Board Detail"
          },
          "boards": {
            "href": "http://localhost:8080/boards",
            "title": "Board list"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/boards"
    }
  },
  "page": {
    "size": 10,
    "totalElements": 1,
    "totalPages": 1,
    "number": 0
  }
}



4. SlicedModel

PagedModel과 비슷하지만 totalPages와 number 데이터가 빠진다.

return SlicedModel.of(entityModels,
            new SlicedModel.SliceMetadata(pageable.getPageSize(), pageable.getPageNumber()),
            linkTo(methodOn(BoardController.class).fetchBoardList(null)).withSelfRel()
);

왜인지는 모르겠지만 hasNext에 대한 정보를 포함하고 있지 않다.

{
  "_embedded": {
    "boardDtoList": [
      {
        "id": 1,
        "title": "1",
        "content": "1",
        "_links": {
          "self": {
            "href": "http://localhost:8080/boards/1",
            "title": "Board Detail"
          },
          "boards": {
            "href": "http://localhost:8080/boards",
            "title": "Board list"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/boards"
    }
  },
  "page": {
    "size": 10,
    "number": 0
  }
}





정리

Spring HATEOAS를 간략하게 이해하고 적용해 보았지만, 아직 정확하게 이해되지 않는 키워드들이 좀 많았다.

공식문서의 설명이 많이 부실하고 구글링해도 제대로 나오질 않았다. GPT가 제일 잘 알려주었지만 믿어도 될지는 모르겠다..

profile
자료를 찾다 보면 사소한 부분에서 궁금한 부분이 생기도 한다. 똑같은 복붙식 블로그 때문에 시간만 낭비되고 시원하게 해결하지 못 하는 경우가 많았다. 그런 부분들까지 세세하게 고민하고 함께 해결해 나가고자 글을 작성한다. 혼자서 작성하는 블로그가 아닌 함께 만들어 가는 블로그이다. ( 지식 공유를 환영합니다. )

0개의 댓글