(25.04.16) HATEOAS & Spring Boot 에서 적용하기
Spring 면접준비 및 공부를 하면서 HATEOAS라는 도구에 대해서 알게 되었는데,
Swagger 처럼 문서나 UI로 작동하는 것이 아닌,
클라이언트가 JSON 형태로 다음 행동을 할 수 있도록 전이 할 수 있도록 응답하는 도구로 특이하게 보였다.
특히, 하드 코딩이나 또는 미리 만들어진 게 아니라, 동적으로 동작할 수 있또록 한다는 점에서
학습하고 실습해보았다.
HATEOAS 에 대해서 정리하고, 미리 만들어둔 간단한 User CRUD Boilerplate에 Spring Boot 의 starter 의존성을 주입해
User CRUD의 Fetching(읽기) 요청에 대해 응답하는 것을 예로 들었다.
HATEOAS, Hypermedia As The Engine of Application State
// HATEOS 의 간단한 Example
{
"id": 1,
"name": "Andy",
"_links": {
"self": {
"href": "<http://localhost:8080/users/1>"
},
"users": {
"href": "<http://localhost:8080/users>"
}
}
}
- 헤이티오에스, 클라이언트가 서버에서 Response 응답을 받을 때, 응답 안에 다음 행동을 참고해 결정할 수 있도록 하는 도구
- RESTful 한 API 를 구성할 때, 사용할 수 있는 제약 조건
- 동적으로 동작을 안내하고 제어할 수 있는 응답 구조를 만들 수 있는 도구
- API 문서를 참고하지 않아도 동작을 용이하게 파악 가능
- 하이퍼미디어 Hypermedia
- HTML 에서 <a href="..."> 하이퍼링크처럼 API 응답에도 해당 링크가 포함이 되어 있을 수 있기 때문에 하이퍼미디어라고 할 수 있음
- 상태전이 State Transition
- 클라이언트가 다음 행동을 파악 → 결정하게 할 수 있도록 하는 것
- 클라이언트가 응답 안의 링크를 따라 상태 전이 라고 함
- 클라이언트가 적절한 서버 내부 로직을 모르더라도 도와줄 수 있는 점에서 State 적용이라는 말을 사용
단점
- 응답하는 크기가 모두 커지게 됨
- 단, 모든 Response DTO 에 강제적으로 추가하는 것이 아닌, 협의된 Scope 내에서 적용하는 것을 원칙으로 함
- FE에서 링크를 따라갈 수 있도록 따로 Parsing작업이 필요할 수 있음
구조
- 필드명들은 표준화 된 것이 아니지만, 일반적으로 사용되는 공통되는 부분을 참고 밑은 대표적인 예시
{
"id": 1,
"name": "Andy",
"_links": {
"self": {
"href": "<http://localhost:8080/users/1>"
},
"users": {
"href": "<http://localhost:8080/users>"
},
"orders": {
"href": "<http://localhost:8080/users/1/orders>",
"title": "This user’s orders",
"type": "application/json",
"hreflang": "en"
}
},
"_embedded": {
"orders": [
{
"id": 100,
"item": "Apple",
"_links": {
"self": {
"href": "<http://localhost:8080/orders/100>"
}
}
}
]
}
}
_links | 리소스 관련 링크들을 포함하는 객체 | { "self": {...}, "orders": {...} } |
self | 해당 Response 리소스의 URL | "href": "<http://localhost:8080/users/1>" |
_embedded | 관련된 하위 리소스 포함 | "_embedded": { "orders": [ ... ] } |
templated | URI가 템플릿이면 true | "templated": true |
type | 링크된 자원의 콘텐츠 타입 | "type": "application/json" |
title | 링크 설명 | "title": "User's orders" |
hreflang | 링크 리소스의 언어 | "hreflang": "en" |
name | 링크 이름 | "name": "get-user-orders" |
- 등등 정해져 있는 것이 아닌 자유롭게 사용할 수 있음
Spring Boot 의 HATEOAS
- spring-boot-starter-hateoas starter 의존성을 통해서 Spring Boot 에서는 자동으로 HATEOAS를 지원
- 즉, 모든 DTO 클래스 하나하나에 전부 위와 같은 HATEOAS 필드를 다 기재하고, 적용시킬 필요없이 동적으로 UPDATE가 가능
- 클라이언트가 URI를 하드코딩 필요X
- 단, 하이퍼링크이기 때문에 리소스 중심인 HATEOAS에서는 엔드포인트만을 제공하지 HTTP 메서드, RequestBody 는 담을 수 없음!
- HTTP 계층 이기 때문에 전혀 상관이 없음
의존성 (Gradle 기준)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
}
- Spring Boot 이기 때문에 Starter 의존성을 지원
- 아래 HATEOAS 클래스들을 활용하기 위해서 필요
사용 클래스
EntityModel<T>
- 하나의 리소스(Response할 엔드포인트)로 감싸는 용도
CollectionModel<T>
- 여러 리소스(여러개의 링크)를 감싸는 용도
RepresentationModelAssembler
- EntityModel을 만들어주는 어셈블러 인터페이스
WebMvcLinkBuilder
- Controller를 참고해서 링크를 만들어주는 빌더
기존 User Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@PostMapping("/signup")
public ResponseEntity<UserResponseDTO> createUser(@Valid @RequestBody SignupRequestDTO requestDTO) {
UserResponseDTO responseDTO = userService.createUser(requestDTO);
return new ResponseEntity<>(responseDTO, HttpStatus.CREATED);
}
@GetMapping("/{userId}")
public ResponseEntity<UserResponseDTO> getUser(@PathVariable UUID userId) {
UserResponseDTO responseDTO = userService.getUser(userId);
return new ResponseEntity<>(responseDTO, HttpStatus.OK);
}
@GetMapping("")
public ResponseEntity<List<UserResponseDTO>> getAllUsers() {
List<UserResponseDTO> responseDTOList = userService.getAllUsers();
return new ResponseEntity<>(responseDTOList, HttpStatus.OK);
}
@PutMapping("/{userId}/update")
public ResponseEntity<UserResponseDTO> updateUser(@PathVariable UUID userId,
@Valid @RequestBody UserUpdateRequestDTO requestDTO) {
UserResponseDTO responseDTO = userService.updateUser(userId, requestDTO);
return new ResponseEntity<>(responseDTO, HttpStatus.OK);
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> deleteUser(@PathVariable UUID userId) {
userService.deleteUser(userId);
return ResponseEntity.noContent().build();
}
}
- 직접 작성한 간단하게 작동할 User CRUD 기본
→ GET Method 즉, Fetching 하는 요청일 때, HATEOAS를 활용해서 수정(Update)와 삭제(Delete) 까지 상태 전이를 하는 것을 목적
UserModelAssembler 설정
@Component
public class UserModelAssembler implements RepresentationModelAssembler<UserResponseDTO, EntityModel<UserResponseDTO>> {
@Override
public EntityModel<UserResponseDTO> toModel(UserResponseDTO user) {
// PostgreSQL 기준, UUID 로 Id를 작성했기 때문에
UUID userId = UUID.fromString(user.getId());
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(userId)).withSelfRel(),
linkTo(methodOn(UserController.class).getAllUsers()).withRel("fetch-all-users"),
linkTo(methodOn(UserController.class).updateUser(userId, null)).withRel("update-user"), // null 위치는 RequestBody 부분이므로 null로 생략가능
linkTo(methodOn(UserController.class).deleteUser(userId)).withRel("delete-user")
);
}
}
RepresentationModelAssembler 인터페이스를 구현체 Assembler를 정의
- 해당 Assembler는 User Controller에서 작동하기 때문에, DDD 에 의해서 domain.user.assembler 에 @Component 로 등록
- toModel 메서드를 @Override 해서 구현
- EntityModel.of(user, )
- user 리소스를 감싸는 HATEOAS 모델을 생성
- linkTo(methodOn(컨트롤러 클래스))
- 특정 컨트롤러의 메서드에 대한 링크를 생성
- .withSelfRel()
- 생성한 링크가 가장 기초인 리소스(위에서는 user 한명 Fetch)를 가리키는 "self" 링크로 설정
- .withRel()
- 추가적인 링크 관계(rel)를 정의하여 다른 행동을 전이하기 위해서 사용
HATEOAS 적용 User Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final UserModelAssembler userModelAssembler;
...
@GetMapping("/{userId}")
public ResponseEntity<EntityModel<UserResponseDTO>> getUser(@PathVariable UUID userId) {
UserResponseDTO responseDTO = userService.getUser(userId);
EntityModel<UserResponseDTO> userModel = userModelAssembler.toModel(responseDTO);
return new ResponseEntity<>(userModel ,HttpStatus.OK);
}
@GetMapping("")
public ResponseEntity<CollectionModel<EntityModel<UserResponseDTO>>> getAllUsers() {
List<UserResponseDTO> responseDTOList = userService.getAllUsers();
List<EntityModel<UserResponseDTO>> userModels = responseDTOList.stream()
.map(userModelAssembler::toModel)
.toList();
CollectionModel<EntityModel<UserResponseDTO>> collectionModel =
CollectionModel.of(userModels,
linkTo(methodOn(UserController.class).getAllUsers()).withSelfRel());
return new ResponseEntity<>(collectionModel, HttpStatus.OK);
}
...
}
- 직관적으로 한 DTO EntityModel , 여러개의 DTO는**EntityModel** 여러개를 담는 CollectionModel 로 만들어서 그대로 ResponseEntity로 응답만 해주면 됨
getUser Response
{
"id": "fb8c2c91-e6cc-43dc-87f4-237b79eb643c",
"username": "testuser",
"email": "testuser@email.com",
"phoneNumber": "010-1234-5678",
"createdAt": "2025-04-15T22:01:40.76627",
"updatedAt": "2025-04-15T22:57:01.164316",
"_links": {
"self": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c>"
},
"fetch-all-users": {
"href": "<http://localhost:8080/api/users>"
},
"update-user": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c/update>"
},
"delete-user": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c>"
}
}
}
- fb8c2c91-e6cc-43dc-87f4-237b79eb643c 아이디를 가지는 사용자를 조회할 때, _links 를 통해 다음 행도을 할 수 있도록 함
- 실제로 해당 주소 링크를 통해 바로 사용할 수 있음
getAllUsers Response
{
"_embedded": {
"userResponseDTOList": [
{
"id": "040c2941-5a71-4519-8fc6-f8089bbb800b",
"username": "testuser2",
"email": "testuser2@email.com",
"phoneNumber": "010-1234-5678",
"createdAt": "2025-04-15T22:31:45.528014",
"updatedAt": "2025-04-15T22:31:45.528014",
"_links": {
"self": {
"href": "<http://localhost:8080/api/users/040c2941-5a71-4519-8fc6-f8089bbb800b>"
},
"fetch-all-users": {
"href": "<http://localhost:8080/api/users>"
},
"update-user": {
"href": "<http://localhost:8080/api/users/040c2941-5a71-4519-8fc6-f8089bbb800b/update>"
},
"delete-user": {
"href": "<http://localhost:8080/api/users/040c2941-5a71-4519-8fc6-f8089bbb800b>"
}
}
},
{
"id": "129f4a44-9bdc-40b8-88ae-1d864aad2bb2",
"username": "testuser3",
"email": "testuser3@email.com",
"phoneNumber": "010-1234-5678",
"createdAt": "2025-04-15T22:31:51.851279",
"updatedAt": "2025-04-15T22:31:51.851279",
"_links": {
"self": {
"href": "<http://localhost:8080/api/users/129f4a44-9bdc-40b8-88ae-1d864aad2bb2>"
},
"fetch-all-users": {
"href": "<http://localhost:8080/api/users>"
},
"update-user": {
"href": "<http://localhost:8080/api/users/129f4a44-9bdc-40b8-88ae-1d864aad2bb2/update>"
},
"delete-user": {
"href": "<http://localhost:8080/api/users/129f4a44-9bdc-40b8-88ae-1d864aad2bb2>"
}
}
},
{
"id": "fb8c2c91-e6cc-43dc-87f4-237b79eb643c",
"username": "testuser",
"email": "testuser@email.com",
"phoneNumber": "010-1234-5678",
"createdAt": "2025-04-15T22:01:40.76627",
"updatedAt": "2025-04-15T22:57:01.164316",
"_links": {
"self": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c>"
},
"fetch-all-users": {
"href": "<http://localhost:8080/api/users>"
},
"update-user": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c/update>"
},
"delete-user": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c>"
}
}
}
]
},
"_links": {
"self": {
"href": "<http://localhost:8080/api/users>"
}
}
}
- _embedded 를 통해서 응답 안에 여러 리소스 응답을 다 HATEOAS 적용해서 보여주고 있음
- 자기 자신은 self로 지정
- getAllUsers의 응답은 List 이기 때문에 자체 DTO가 없지만, 만약에 있었다면, 제일 상단에 getUser 처럼 필드와 값을 주어지게 됨
추가 필드 설정(참고)
- title, type, hreflang 같은 필드는 자동으로 생성되지 않기 때문에 Link 객체를 만들어서 Model 에 지정해야함(ModelAssembler)
@Component
public class UserModelAssembler implements RepresentationModelAssembler<UserResponseDTO, EntityModel<UserResponseDTO>> {
@Override
public EntityModel<UserResponseDTO> toModel(UserResponseDTO user) {
UUID userId = UUID.fromString(user.getId());
EntityModel<UserResponseDTO> model = EntityModel.of(user);
// self 링크
Link selfLink = Link.of("<http://localhost:8080/api/users/>" + userId)
.withSelfRel()
.withTitle("Get single user");
// update 링크
Link updateLink = Link.of("<http://localhost:8080/api/users/>" + userId + "/update")
.withRel("update-user")
.withTitle("Update this user")
.withType("application/json");
// delete 링크
Link deleteLink = Link.of("<http://localhost:8080/api/users/>" + userId)
.withRel("delete-user")
.withTitle("Delete this user");
// fetch all 링크
Link fetchAllLink = Link.of("<http://localhost:8080/api/users>")
.withRel("fetch-all-users")
.withTitle("Get all users");
// 모델에 링크 추가
model.add(selfLink, updateLink, deleteLink, fetchAllLink);
return model;
}
}
- 추가 메타데이터들은 Controller단에서 어떤게 어떤 설명을 해야하는지 알지 못하기 때문에, 적용하고자하면 일일이 다 써야함
- 동적으로 적용이 안되기 때문에 수정사항을 매번 확인해야함
- 따라서 최소한으로만 사용
{
"id": "fb8c2c91-e6cc-43dc-87f4-237b79eb643c",
"username": "testuser",
"email": "testuser@email.com",
"phoneNumber": "010-1234-5678",
"createdAt": "2025-04-15T22:01:40.76627",
"updatedAt": "2025-04-15T22:57:01.164316",
"_links": {
"self": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c>",
"title": "Get single user"
},
"update-user": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c/update>",
"title": "Update this user",
"type": "application/json"
},
"delete-user": {
"href": "<http://localhost:8080/api/users/fb8c2c91-e6cc-43dc-87f4-237b79eb643c>",
"title": "Delete this user"
},
"fetch-all-users": {
"href": "<http://localhost:8080/api/users>",
"title": "Get all users"
}
}
}
- 정상적으로 HATEOAS가 적용된 예
참고자료
https://docs.spring.io/spring-hateoas/docs/current/reference/html/
Spring HATEOAS - Reference Documentation
Example 46. Configuring WebTestClient when using Spring Boot @SpringBootTest @AutoConfigureWebTestClient (1) class WebClientBasedTests { @Test void exampleTest(@Autowired WebTestClient.Builder builder, @Autowired HypermediaWebTestClientConfigurer configure
docs.spring.io
GitHub
https://github.com/andrew75313/Boilerplate-Practice.git
GitHub - andrew75313/Boilerplate-Practice: Boilerplate code and development practice for initializing new projects and experimen
Boilerplate code and development practice for initializing new projects and experimenting with various features. / 프로젝트 초기 설정에 사용되는 보일러플레이트와 다양한 기능 실습을 위한 코드 - andrew75313/Boilerplate-Practi
github.com
사용자 행동을 전이시킬 수 있지만, 위에서 언급했다 시피 FE에서의 파싱이 따로 필요하기 때문에, BE 개발 단독으로 마음대로 적용해서 편하겠지 하면서 적용해서는 안될 것이고, 협의가 된 후에 사용할 수 있을 것이다.
하지만, HATEOAS 에 대한 개념을 모르고 있어서는 안된다고 생각하기 기록해서 잊지 않고 활용해야할 때 바로 활용할 수 있도록 했다.
'Develop Study > Spring' 카테고리의 다른 글
(25.04.28) Spring Security 인증 Authentication Filter 의 Success & Failure 핸들러 (0) | 2025.04.28 |
---|---|
(25.04.22) Spring Security 인증 인가를 통한 Login 기능 - Filter & JWT에 대해 (0) | 2025.04.22 |
(25.04.03) Singleton Pattern & Spring Bean (0) | 2025.04.03 |
(25.03.13) Java Spring JPA @Converter & AttributeConverter (0) | 2025.03.13 |
(25.02.18) Spring Boot 의 MongoDB 연결 (0) | 2025.02.18 |