kimkkatta::LOG
Backend❯Spring2022-06-196 min read

[Spring] REST API 성숙도 Level 3 — Spring HATEOAS로 하이퍼미디어 링크 삽입하기

REST API 성숙도 모델의 최고 단계인 HATEOAS 개념을 이해하고, Spring HATEOAS의 EntityModel·CollectionModel·RepresentationModelAssembler를 활용해 응답 JSON에 동적 하이퍼링크(_links)를 삽입하는 방법을 정리합니다.

#REST API#Spring HATEOAS#Hypermedia#스프링 인 액션

HATEOAS란?

기존의 일반적인 REST API 응답 방식에서는 클라이언트가 특정 리소스 URI를 하드코딩해 두어야 합니다. 만약 서버의 API URI 경로가 변경되면 클라이언트는 404 Not Found 에러를 마주하게 됩니다.

**HATEOAS (Hypermedia As The Engine Of Application State)**는 서버가 응답 데이터를 내려줄 때 현재 리소스와 연관된 다음 작업(상태 전이)을 수행할 수 있는 동적 URI 링크(_links)를 함께 포함시켜, 클라이언트가 서버와 동적으로 상호작용할 수 있도록 만드는 REST 아키텍처의 핵심 제약 조건입니다.


REST API 성숙도 모델 (Richardson Maturity Model)

REST API 성숙도 모델 Level 0~3

리처드슨의 REST 성숙도 모델에서 HATEOAS는 가장 높은 Level 3 단계에 해당합니다. (참고: REST API 성숙도 모델)

의존성 추가 및 Spring Boot 2.2+ 클래스명 변경

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-hateoas'

Spring Boot 2.2(Spring HATEOAS 1.0) 이상부터는 기존 클래스 명칭이 직관적인 Model 접미사로 개편되었습니다.

Spring Boot 2.2 이전 Spring Boot 2.2 이후 (현재 표준)
ResourceSupport RepresentationModel
Resource EntityModel
Resources CollectionModel
PagedResources PagedModel
ResourceAssembler RepresentationModelAssembler
ControllerLinkBuilder WebMvcLinkBuilder

리스트 형태의 데이터를 반환할 때는 CollectionModel.of()로 감싸고, WebMvcLinkBuilder의 linkTo(methodOn(...))를 이용해 특정 컨트롤러 메서드의 URI를 동적으로 추출해 링크를 삽입합니다. URI가 변경되더라도 코드 수정 없이 항상 올바른 링크가 생성됩니다.

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@RestController
@RequestMapping(path = "/api/design", produces = "application/json")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class DesignTacoApiController {

    private final TacoRepository tacoRepository;

    @GetMapping("/recent")
    public CollectionModel<Taco> recentTacos() {
        PageRequest pageRequest = PageRequest.of(0, 12, Sort.by("createdAt").descending());
        List<Taco> tacos = tacoRepository.findAll(pageRequest).getContent();

        CollectionModel<Taco> recentResources = CollectionModel.of(tacos);
        Link link = linkTo(methodOn(DesignTacoApiController.class).recentTacos())
                .withRel("recents");
        recentResources.add(link);

        return recentResources;
    }
}

응답 JSON 결과:

{
  "_embedded": {
    "tacoList": [
      {
        "id": 2,
        "createdAt": "2022-06-18T19:07:13.816+00:00",
        "name": "taco123",
        "ingredients": [
          { "id": "FLTO", "name": "Flour Tortilla", "type": "WRAP" },
          { "id": "GRBF", "name": "Ground Beef", "type": "PROTEIN" }
        ]
      }
    ]
  },
  "_links": {
    "recents": {
      "href": "http://localhost:8080/api/design/recent"
    }
  }
}

2. RepresentationModelAssembler로 각 항목마다 링크 생성하기

컬렉션 내 개별 Taco 아이템 각각에도 self 링크를 붙이려면 반복문 대신 RepresentationModelAssembler를 구현하는 것이 깔끔합니다.

또한 엔티티의 내부 DB PK(id)를 그대로 노출하는 대신, RepresentationModel을 상속한 전용 리소스 DTO(TacoResources)를 만들어 _links.self.href로 리소스 주소를 대체할 수 있습니다.

리소스 모델 DTO (TacoResources)

@Relation을 사용하면 JSON _embedded 내부의 컬렉션 키 이름(예: tacoList → tacos)을 원하는 이름으로 깔끔하게 지정할 수 있습니다.

@Relation(value = "taco", collectionRelation = "tacos")
public class TacoResources extends RepresentationModel<TacoResources> {

    @Getter
    private final String name;

    @Getter
    private final Date createdAt;

    @Getter
    private final List<Ingredients> ingredients;

    public TacoResources(Taco taco) {
        this.name = taco.getName();
        this.createdAt = taco.getCreatedAt();
        this.ingredients = taco.getIngredients();
    }
}

어셈블러 구현 (TacoResourceAssembler)

  • toModel(): 단일 엔티티를 EntityModel<TacoResources>로 변환하고 개별 self 링크를 추가
  • toCollectionModel(): 전체 컬렉션에 대한 공통 링크(self, recents)를 추가
@Component
public class TacoResourceAssembler
        implements RepresentationModelAssembler<Taco, EntityModel<TacoResources>> {

    @Override
    public EntityModel<TacoResources> toModel(Taco taco) {
        return EntityModel.of(
                new TacoResources(taco),
                linkTo(methodOn(DesignTacoApiController.class).findTacoById(taco.getId())).withSelfRel()
        );
    }

    @Override
    public CollectionModel<EntityModel<TacoResources>> toCollectionModel(
            Iterable<? extends Taco> tacos) {
        return RepresentationModelAssembler.super.toCollectionModel(tacos).add(
                linkTo(DesignTacoApiController.class).withSelfRel(),
                linkTo(methodOn(DesignTacoApiController.class).recentTacos()).withRel("recents")
        );
    }
}

컨트롤러 적용

@RestController
@RequestMapping(path = "/api/design", produces = "application/json")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class DesignTacoApiController {

    private final TacoRepository tacoRepository;
    private final TacoResourceAssembler assembler;

    @GetMapping("/recent")
    public CollectionModel<EntityModel<TacoResources>> recentTacos() {
        PageRequest pageRequest = PageRequest.of(0, 12, Sort.by("createdAt").descending());
        List<Taco> tacos = tacoRepository.findAll(pageRequest).getContent();

        return assembler.toCollectionModel(tacos);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Taco> findTacoById(@PathVariable Long id) {
        return tacoRepository.findById(id)
                .map(taco -> new ResponseEntity<>(taco, HttpStatus.OK))
                .orElseGet(() -> new ResponseEntity<>(null, HttpStatus.NOT_FOUND));
    }
}

응답 JSON 결과:

{
  "_embedded": {
    "tacos": [
      {
        "name": "taco1",
        "createdAt": "2022-06-18T19:31:40.148+00:00",
        "ingredients": [
          { "id": "FLTO", "name": "Flour Tortilla", "type": "WRAP" },
          { "id": "GRBF", "name": "Ground Beef", "type": "PROTEIN" }
        ],
        "_links": {
          "self": {
            "href": "http://localhost:8080/api/design/2"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/api/design"
    },
    "recents": {
      "href": "http://localhost:8080/api/design/recent"
    }
  }
}

3. 중첩된 하위 객체(Ingredient)에도 링크 삽입하기

TacoResources 내부에 포함된 ingredients 리스트 역시 일반 List<Ingredient> 대신 CollectionModel<EntityModel<IngredientResources>>로 변환하면, 중첩 객체 안에서도 각각의 상세 URI를 참조할 수 있습니다.

@Getter
@Setter
@Relation(value = "ingredient", collectionRelation = "ingredients")
public class IngredientResources extends RepresentationModel<IngredientResources> {

    private String name;
    private Type type;

    public IngredientResources(Ingredient ingredient) {
        this.name = ingredient.getName();
        this.type = ingredient.getType();
    }
}
@Component
public class IngredientResourceAssembler
        implements RepresentationModelAssembler<Ingredient, EntityModel<IngredientResources>> {

    @Override
    public EntityModel<IngredientResources> toModel(Ingredient ingredient) {
        return EntityModel.of(
                new IngredientResources(ingredient),
                linkTo(methodOn(IngredientController.class).findIngredientById(ingredient.getId())).withSelfRel()
        );
    }

    @Override
    public CollectionModel<EntityModel<IngredientResources>> toCollectionModel(
            Iterable<? extends Ingredient> ingredients) {
        return RepresentationModelAssembler.super.toCollectionModel(ingredients).add(
                linkTo(IngredientController.class).withSelfRel()
        );
    }
}
@Relation(value = "taco", collectionRelation = "tacos")
public class TacoResources extends RepresentationModel<TacoResources> {

    private static final IngredientResourceAssembler ingredientResourceAssembler =
            new IngredientResourceAssembler();

    @Getter
    private final String name;

    @Getter
    private final Date createdAt;

    @Getter
    private final CollectionModel<EntityModel<IngredientResources>> ingredients;

    public TacoResources(Taco taco) {
        this.name = taco.getName();
        this.createdAt = taco.getCreatedAt();
        this.ingredients = ingredientResourceAssembler.toCollectionModel(taco.getIngredients());
    }
}
@RestController
@RequestMapping(path = "/ingredients", produces = "application/json")
@CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class IngredientController {

    private final IngredientRepository ingredientRepository;

    @GetMapping
    public Iterable<Ingredient> findAllIngredients() {
        return ingredientRepository.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Ingredient> findIngredientById(@PathVariable String id) {
        return ingredientRepository.findById(id)
                .map(ingredient -> new ResponseEntity<>(ingredient, HttpStatus.OK))
                .orElseGet(() -> new ResponseEntity<>(null, HttpStatus.NOT_FOUND));
    }
}

최종 중첩 응답 JSON 예시:

{
  "_embedded": {
    "tacos": [
      {
        "name": "taco2",
        "createdAt": "2022-06-18T19:49:45.909+00:00",
        "ingredients": {
          "_embedded": {
            "ingredients": [
              {
                "name": "Corn Tortilla",
                "type": "WRAP",
                "_links": {
                  "self": { "href": "http://localhost:8080/ingredients/COTO" }
                }
              },
              {
                "name": "Ground Beef",
                "type": "PROTEIN",
                "_links": {
                  "self": { "href": "http://localhost:8080/ingredients/GRBF" }
                }
              }
            ]
          },
          "_links": {
            "self": { "href": "http://localhost:8080/ingredients" }
          }
        },
        "_links": {
          "self": { "href": "http://localhost:8080/api/design/4" }
        }
      }
    ]
  },
  "_links": {
    "self": { "href": "http://localhost:8080/api/design" },
    "recents": { "href": "http://localhost:8080/api/design/recent" }
  }
}

참고: Spring HATEOAS 공식 문서 / 도서 스프링 인 액션

COMMENTS // GITHUB DISCUSSIONS (GISCUS)