Spring HATEOAS 하이퍼미디어 API

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 링크를 파싱하여 서비스 간 탐색도 가능하다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux