Spring HATEOAS란?
HATEOAS(Hypermedia As The Engine Of Application State)는 REST API의 성숙도 모델 최고 수준(Level 3)으로, 응답에 관련 리소스의 링크를 포함하여 클라이언트가 API를 탐색할 수 있게 한다. Spring HATEOAS는 이 하이퍼미디어 원칙을 Spring MVC에서 쉽게 구현하는 라이브러리다.
이 글에서는 RepresentationModel, EntityModel, CollectionModel의 사용법부터 LinkRelation, RepresentationModelAssembler, HAL Forms, Affordance까지 실전 수준으로 다룬다.
의존성 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
Spring Boot Starter HATEOAS는 자동으로 HAL(Hypertext Application Language) 미디어 타입을 기본 응답 형식으로 설정한다.
EntityModel: 단일 리소스에 링크 추가
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping("/{id}")
public EntityModel<OrderDto> getOrder(@PathVariable Long id) {
OrderDto order = orderService.findById(id);
return EntityModel.of(order,
// self 링크
linkTo(methodOn(OrderController.class).getOrder(id))
.withSelfRel(),
// 관련 리소스 링크
linkTo(methodOn(OrderController.class).getOrderItems(id))
.withRel("items"),
// 상태 전이 링크 (주문이 PENDING일 때만 취소 가능)
order.getStatus().equals("PENDING")
? linkTo(methodOn(OrderController.class).cancelOrder(id))
.withRel("cancel")
: null,
// 고객 리소스
linkTo(methodOn(CustomerController.class).getCustomer(order.getCustomerId()))
.withRel("customer")
).removeLinks(Link::isNull); // null 링크 제거 안전 처리
}
}
응답 예시 (HAL+JSON):
{
"id": 42,
"status": "PENDING",
"totalAmount": 150000,
"customerId": 7,
"_links": {
"self": { "href": "http://localhost:8080/api/orders/42" },
"items": { "href": "http://localhost:8080/api/orders/42/items" },
"cancel": { "href": "http://localhost:8080/api/orders/42/cancel" },
"customer": { "href": "http://localhost:8080/api/customers/7" }
}
}
CollectionModel: 컬렉션 리소스
@GetMapping
public CollectionModel<EntityModel<OrderDto>> getAllOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<OrderDto> orderPage = orderService.findAll(PageRequest.of(page, size));
List<EntityModel<OrderDto>> orders = orderPage.getContent().stream()
.map(order -> EntityModel.of(order,
linkTo(methodOn(OrderController.class).getOrder(order.getId()))
.withSelfRel()
))
.toList();
return CollectionModel.of(orders,
linkTo(methodOn(OrderController.class).getAllOrders(page, size))
.withSelfRel(),
// 페이징 링크
linkTo(methodOn(OrderController.class).getAllOrders(0, size))
.withRel(IanaLinkRelations.FIRST),
linkTo(methodOn(OrderController.class)
.getAllOrders(orderPage.getTotalPages() - 1, size))
.withRel(IanaLinkRelations.LAST)
);
}
RepresentationModelAssembler: 변환 로직 분리
Controller에 링크 생성 로직이 산재하면 유지보수가 어렵다. RepresentationModelAssembler로 DTO→EntityModel 변환을 캡슐화한다.
@Component
public class OrderModelAssembler
implements RepresentationModelAssembler<OrderDto, EntityModel<OrderDto>> {
@Override
public EntityModel<OrderDto> toModel(OrderDto order) {
EntityModel<OrderDto> model = EntityModel.of(order);
// self 링크는 항상
model.add(linkTo(methodOn(OrderController.class)
.getOrder(order.getId())).withSelfRel());
// 컬렉션 링크
model.add(linkTo(methodOn(OrderController.class)
.getAllOrders(0, 20)).withRel("orders"));
// 상태별 조건부 링크 — HATEOAS의 핵심
switch (order.getStatus()) {
case "PENDING" -> {
model.add(linkTo(methodOn(OrderController.class)
.confirmOrder(order.getId())).withRel("confirm"));
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.getId())).withRel("cancel"));
}
case "CONFIRMED" -> {
model.add(linkTo(methodOn(OrderController.class)
.shipOrder(order.getId())).withRel("ship"));
}
case "SHIPPED" -> {
model.add(linkTo(methodOn(OrderController.class)
.completeOrder(order.getId())).withRel("complete"));
}
}
// 관련 리소스
model.add(linkTo(methodOn(CustomerController.class)
.getCustomer(order.getCustomerId())).withRel("customer"));
model.add(linkTo(methodOn(OrderController.class)
.getOrderItems(order.getId())).withRel("items"));
return model;
}
@Override
public CollectionModel<EntityModel<OrderDto>> toCollectionModel(
Iterable<? extends OrderDto> entities) {
CollectionModel<EntityModel<OrderDto>> models =
RepresentationModelAssembler.super.toCollectionModel(entities);
models.add(linkTo(methodOn(OrderController.class)
.getAllOrders(0, 20)).withSelfRel());
return models;
}
}
Controller가 깔끔해진다:
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final OrderModelAssembler assembler;
@GetMapping("/{id}")
public EntityModel<OrderDto> getOrder(@PathVariable Long id) {
return assembler.toModel(orderService.findById(id));
}
@GetMapping
public CollectionModel<EntityModel<OrderDto>> getAllOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return assembler.toCollectionModel(
orderService.findAll(PageRequest.of(page, size)).getContent()
);
}
}
Affordance: HAL-FORMS 지원
Affordance는 링크에 어떤 HTTP 메서드와 요청 본문이 필요한지 메타데이터를 추가한다. HAL-FORMS 미디어 타입을 사용하면 클라이언트가 폼을 자동 생성할 수 있다.
@GetMapping("/{id}")
public EntityModel<OrderDto> getOrderWithAffordance(@PathVariable Long id) {
OrderDto order = orderService.findById(id);
Link selfLink = linkTo(methodOn(OrderController.class).getOrder(id))
.withSelfRel()
// PUT 업데이트 affordance 추가
.andAffordance(afford(methodOn(OrderController.class)
.updateOrder(id, null)))
// DELETE 삭제 affordance 추가
.andAffordance(afford(methodOn(OrderController.class)
.deleteOrder(id)));
return EntityModel.of(order, selfLink);
}
Accept: application/prs.hal-forms+json 헤더로 요청하면:
{
"id": 42,
"status": "PENDING",
"_links": {
"self": { "href": "/api/orders/42" }
},
"_templates": {
"default": {
"method": "PUT",
"properties": [
{ "name": "status", "type": "text" },
{ "name": "shippingAddress", "type": "text" }
]
},
"deleteOrder": {
"method": "DELETE"
}
}
}
PagedModel: 페이징 + HATEOAS
@RestController
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
private final ProductModelAssembler assembler;
private final PagedResourcesAssembler<ProductDto> pagedAssembler;
@GetMapping("/api/products")
public PagedModel<EntityModel<ProductDto>> getProducts(
@PageableDefault(size = 20, sort = "name") Pageable pageable) {
Page<ProductDto> page = productService.findAll(pageable);
return pagedAssembler.toModel(page, assembler);
}
}
자동으로 first, prev, self, next, last 페이징 링크가 생성된다:
{
"_embedded": {
"productDtoList": [ ... ]
},
"_links": {
"first": { "href": "/api/products?page=0&size=20" },
"prev": { "href": "/api/products?page=1&size=20" },
"self": { "href": "/api/products?page=2&size=20" },
"next": { "href": "/api/products?page=3&size=20" },
"last": { "href": "/api/products?page=9&size=20" }
},
"page": { "size": 20, "totalElements": 200, "totalPages": 10, "number": 2 }
}
커스텀 LinkRelation
public class OrderLinkRelations {
public static final LinkRelation CONFIRM = LinkRelation.of("confirm");
public static final LinkRelation CANCEL = LinkRelation.of("cancel");
public static final LinkRelation SHIP = LinkRelation.of("ship");
public static final LinkRelation INVOICE = LinkRelation.of("invoice");
public static final LinkRelation TRACKING = LinkRelation.of("tracking");
}
// 사용
model.add(linkTo(methodOn(OrderController.class).confirmOrder(id))
.withRel(OrderLinkRelations.CONFIRM));
HATEOAS 도입 시 주의점
| 안티패턴 | 문제점 | 해결책 |
|---|---|---|
| 모든 API에 HATEOAS 적용 | 불필요한 복잡성, 응답 크기 증가 | 상태 전이가 복잡한 리소스에 선택적 적용 |
| 링크만 있고 의미 없음 | 클라이언트가 활용 불가 | 조건부 링크로 가능한 액션만 노출 |
| Controller에 링크 로직 산재 | 중복, 유지보수 어려움 | RepresentationModelAssembler로 분리 |
| URL 하드코딩 | URL 변경 시 전파 실패 | linkTo/methodOn으로 타입 안전 링크 |
| 테스트에서 링크 미검증 | 깨진 링크 배포 | MockMvc + jsonPath로 _links 검증 |
마무리
Spring HATEOAS는 REST API를 자기 기술적(self-descriptive)으로 만드는 핵심 도구다. 상태에 따라 가능한 액션을 링크로 노출하면 클라이언트는 URL을 하드코딩할 필요 없이 API를 탐색할 수 있다. RepresentationModelAssembler로 변환 로직을 캡슐화하고, Affordance로 HAL-FORMS까지 지원하면 프론트엔드와의 계약이 한층 명확해진다. Spring MVC 비동기 처리와 결합하면 실시간 리소스 변경을 SSE로 푸시하면서 HATEOAS 링크를 유지할 수 있고, RestClient 에러 처리에서 HATEOAS 링크를 파싱하여 서비스 간 탐색도 가능하다.