본문 바로가기
Develop Study/Spring 2025. 4. 16.

(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 처럼 필드와 값을 주어지게 됨

추가 필드 설정(참고)

  • titletypehreflang 같은 필드는 자동으로 생성되지 않기 때문에 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 에 대한 개념을 모르고 있어서는 안된다고 생각하기 기록해서 잊지 않고 활용해야할 때 바로 활용할 수 있도록 했다.