Thumbnail image

Table of Contents

[스프링인액션] REST API 생성하기

개요


스프링인액션 6장을 읽고 REST 서비스를 생성해보았다.

  • REST 엔드포인트 정의하기
  • REST 리소스 활성화

에 대해 기록하였다.

REST API 기본 정의


REST API란 REST 아키텍처의 제약 조건을 준수하는 애플리케이션 프로그래밍 인터페이스를 뜻한다.
범용성 있는 서버 구축과 함께 클라이언트와 서버 간의 역할을 명확히 분리하도록 설계하는 것이 일반적이 되면서 REST API를 많이 사용하고 있다.

REST API는 HTTP URI를 통해 자원을 명시하고 HTTP Method를 통해 해당 자원의 대한 CRUD Operation을 적용한다.

GET 요청 처리

@RestController 어노테이션은 컨트롤러의 모든 HTTP 요청 처리 메서드에서 HTTP 응답 body에 값을 반환한다는 것을 스프링에게 알려준다.

 1@RestController
 2@RequestMapping(path="/design", produces="application/json")    
 3@CrossOrigin(origins="*")                                       
 4public class DesignTacoController {
 5
 6  @GetMapping("/{id}")
 7  public ResponseEntity<Taco> tacoById(@PathVariable("id") Long id) {
 8    Optional<Taco> optTaco = tacoRepo.findById(id);
 9    if (optTaco.isPresent()) {
10      return new ResponseEntity<>(optTaco.get(), HttpStatus.OK);
11    }
12    return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
13  }
14}
  • @RequestMapping(path="/design") : /design 경로의 요청을 처리하도록 지시한다.
  • @RequestMapping(produces=“application/json”) : request의 Accept 헤더에 “application/json이 포함된 요청만을 해당 메서드에서 처리하도록 설정한다.
  • @CrossOrigin(origins="*") : 다른 도메인의 클라이언트에서 해당 REST API를 사용할 수 있도록 설정한다.
  • 반환값은 상태코드와 함께 객체를 포함하는 ResponseEntity를 전달한다.

POST 요청 처리

1@PostMapping(consumes="application/json")
2@ResponseStatus(HttpStatus.CREATED)
3public Taco postTaco(@RequestBody Taco taco) {
4  return tacoRepo.save(taco);
5}
  • consumes=“application/json” : Content-type이 application/json과 일치하는 요청만 처리한다.
  • @RequestBody : json 데이터가 Taco 객체로 변환되어 매개변수와 바인딩된다.
  • @ResponseStatus(HttpStatus.CREATED) : 요청이 성공적이라면 201(CREATED) 상태코드를 반환한다.

PUT/PATCH 요청 처리

  • PUT : 클라이언트부터 서버로 데이터를 전송한다. - 데이터 전체를 교체
  • PATCH : PUT과 비슷하지만 데이터의 일부분을 변경한다.

PUT 요청 예시

1@PutMapping(path="/{orderId}", consumes="application/json")
2public Order putOrder(@RequestBody Order order) {
3  return repo.save(order);
4}

해당 주문 데이터 전체를 PUT 요청으로 제출하는 것을 알 수 있다.

PATCH 요청 예시

 1@PatchMapping(path="/{orderId}", consumes="application/json")
 2public Order patchOrder(@PathVariable("orderId") Long orderId, @RequestBody Order patch) {
 3  
 4  Order order = repo.findById(orderId).get();
 5  if (patch.getDeliveryName() != null)
 6    order.setDeliveryName(patch.getDeliveryName());
 7  
 8  if (patch.getDeliveryStreet() != null)
 9    order.setDeliveryStreet(patch.getDeliveryStreet());
10  
11  if (patch.getDeliveryCity() != null)
12    order.setDeliveryCity(patch.getDeliveryCity());
13  
14  return repo.save(order);
15}

PATCH는 데이터의 일부만 변경하도록 각각의 필드값이 null이 아닌지 확인 후 해당 필드 값만 변경을 시도한다.

DELETE 요청 처리

1@DeleteMapping("/{orderId}")
2@ResponseStatus(HttpStatus.NO_CONTENT)
3public void deleteOrder(@PathVariable("orderId") Long orderId) {
4  try {
5    repo.deleteById(orderId);
6  } catch (EmptyResultDataAccessException e) {}
7}

PathVariable로 제공된 주문ID를 인자로 받아 해당 주문이 존재하면 삭제를 수행한다.
만약 해당 주문 데이터가 없다면 EmptyResultDataAccessException이 발생된다.

  • @ResponseStatus(HttpStatus.NO_CONTENT) : 삭제 완료 후 204(no content) 처리

※ DELETE 요청의 응답은 body 데이터를 갖지 않으며, 반환 데이터가 없다는 것을 알리기 위해 204 상태코드를 사용한다.

HATEOAS 사용하기


HATEOAS : Hypermedia As The Engine of Application State
API로부터 반환되는 리소스에 해당 리소스와 관련된 하이퍼링크들이 포함된다.
기존의 REST API의 경우 기존의 URL을 변경하면 이를 사용하는 모든 클라이언트가 함께 수정되어야 하기 때문에 API URL의 관리가 어렵다는 단점이 존재한다.

→ 하지만 HATEOAS를 사용한다면 최소한의 API URL만 알면 처리 가능한 다른 API URL을 알아내여 사용할 수 있다.

기존 REST API와 HATEOAS 비교

기존 REST API

 1[
 2  {
 3    "id": 4,
 4    "name": "Veg-Out",
 5    "createdAt": "2018-01-31T20:15:53.219+0000",
 6    "ingredients": [
 7      {"id": "FLTO", "name": "Flour Tortilla", "type": "WRAP"},
 8      {"id": "COTO", "name": "Corn Tortilla", "type": "WRAP"},
 9      {"id": "TMTO", "name": "Diced Tomatoes", "type": "VEGGIES"},
10    ]
11  },
12]

클라이언트가 HTTP 작업을 수행하고 싶다면 경로의 URL에 id 속성 값을 하드코딩해야 한다.

HATEOAS

 1{
 2  "_embedded": {
 3    "tacoModelList": [
 4      {
 5        "name": "Veg-Out",
 6        "createdAt": "2018-01-31T20:15:53.219+0000",
 7        "ingredients": [
 8          {
 9            "name": "Flour Tortilla", "type": "WRAP",
10            "_links": {
11              "self": { "href": "http://localhost:8080/ingredients/FLTO" }
12            },
13            "name": "Corn Tortilla", "type": "WRAP",
14            "_links": {
15              "self": { "href": "http://localhost:8080/ingredients/COTO" }
16            },
17            "name": "Diced Tomatoes", "type": "VEGGIES",
18            "_links": {
19              "self": { "href": "http://localhost:8080/ingredients/TMTO" }
20            }
21          }
22        ],
23        "_links": {
24          "self": { "href": "http://localhost:8080/design/4" }
25        }
26      }
27    ]
28  },
29  "_links": {
30    "recent": {
31      "self": { "href": "http://localhost:8080/design/resent" }
32    }
33  }
34}

이런 형태의 HATEOAS를 HAL(Hypertext Application Language)라고 한다.
JSON 응답에 하이퍼링크를 포함시킬 때 주로 사용된다.

HATEOAS 빌드 명세

1implementation 'org.springframework.hateoas:spring-hateoas:1.1.4.RELEASE'

API에서 하이퍼미디어를 사용할 수 있게 하려면 해당 dependency를 추가한다.

하이퍼링크 추가

HATEOAS는 하이퍼링크 리소스를 나타내는 두 개의 기본 타입이 있다.

  • EntityModel : 단일 모델을 의미
  • CollectionModel : 모델 컬렉션을 의미

두 타입이 전달하는 링크는 JSON 반환값에 포함된다.

기본 예제
List를 반환하는 대신 CollectionModel 객체를 반환하는 코드이다.

 1@GetMapping("/recent")
 2public CollectionModel<EntityModel<Taco>> recentTacos() {                 
 3  PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
 4
 5  List<Taco> tacos = tacoRepo.findAll(page).getContent();
 6  CollectionModel<EntityModel<Taco>> recentModels = CollectionModel.wrap(tacos);
 7  recentModels.add(WebMvcLinkBuilder.linkTo(DesignTacoController.class)
 8                                    .slash("recent")
 9                                    .withRel("recents"));
10  return recentModels ;
11}
  • CollectionModel.wrap() : Taco 리스트를 CollectionModel 인스턴스로 래핑한다.
  • WebMvcLinkBuilder : URL을 하드코딩하지 않고 호스트 이름을 알 수 있다.
  • WebMvcLinkBuilder.slash() : 지정한 값을 URL에 추가한다. - URL의 경로는 /design/recent가 된다.
  • WebMvcLinkBuilder.withRel() : 해당 URL의 relation name을 지정한다.

모델 어셈블러 생성

리스트에 포함된 각각의 객체에 대한 링크를 추가할 경우 리스트를 반환하는 API마다 루프를 실행하는 코드가 있어야 하기 때문에 상당히 번거롭다.
따라서 CollectionModel.wrap()를 사용하여 EntityModel로 생성하는 대신 각 객체를 새로운 EntityModel 객체로 변환하는 유틸리티 클래스를 정의할 필요가 있다.

 1public class TacoModel extends RepresentationModel<TacoModel> {
 2
 3  private static final IngredientModelAssembler ingredientAssembler = new IngredientModelAssembler();
 4  
 5  @Getter
 6  private final String name;
 7
 8  @Getter
 9  private final Date createdAt;
10
11  @Getter
12  private final CollectionModel<IngredientModel> ingredients;
13  
14  public TacoModel(Taco taco) {
15    this.name = taco.getName();
16    this.createdAt = taco.getCreatedAt();
17    this.ingredients = ingredientAssembler.toCollectionModel(taco.getIngredients());
18  }
19}

TacoModel 객체는 Taco 객체와 유사하지만 링크를 추가로 가질 수 있다.
또한 RepresentationModel의 서브클래스로서 Link 객체 리스트와 이것을 관리하는 메서드를 상속받는다.

Taco 객체를 TacoModel 객체들로 변환하는 데 도움을 주는 모델 어셈블러 클래스를 생성한다.

 1public class TacoModelAssembler extends RepresentationModelAssemblerSupport<Taco, TacoModel> {
 2
 3  public TacoModelAssembler() {
 4    super(DesignTacoController.class, TacoModel.class);
 5  }
 6
 7  @Override
 8  public TacoModel toModel(Taco taco) {
 9    return new TacoModel(taco);
10  }
11}
  • TacoModelAssembler 생성자 : URL의 기본 경로를 설정하기 위해 DesignTacoController를 사용한다.
  • toModel() : RepresentationModelAssemblerSupport 부터 상속받을 때 반드시 오버라이드 해야한다. TacoModel 인스턴스를 생성하면서 Taco 객체의 id 속성 값으로 생성되는 self 링크가 URL에 자동 지정된다.

모델 어셈블러 사용

 1@GetMapping("/recent")
 2public CollectionModel<TacoModel> recentTacos() {
 3  PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
 4  List<Taco> tacos = tacoRepo.findAll(page).getContent();
 5
 6  CollectionModel<TacoModel> recentCollection = new TacoModelAssembler().toCollectionModel(tacos);
 7  recentCollection.add(linkTo(methodOn(DesignTacoController.class).recentTacos())
 8                              .withRel("recents"));
 9  return recentCollection;
10}
  • toCollectionModel() : TacoModel 객체를 저장한 CollectionModel을 생성한다.

embedded 관계 이름 짓기

1{
2  "_embedded": {
3    "tacoModelList": [
4      
5    ]
6  }
7}

embedded 밑의 “tacoModelList” 이름은 CollectionModel 객체가 List로부터 생성되었다는 것을 나타낸다. 즉 TacoModel 클래스가 변경될 경우 JSON 필드 이름이 변경된다.

이를 해결하기 위해서는 @Relation 어노테이션을 사용한다.

1@Relation(value="taco", collectionRelation="tacos")
2public class TacoModel extends RepresentationModel<TacoModel> {
3  ...
4}

TacoModel 객체 리스트가 CollectionModel 객체에서 사용될 때 tacos라는 이름이 되도록 지정할 수 있다.

JSON 형식은 다음과 같이 변경된다.

1{
2  "_embedded": {
3    "tacos": [
4		
5    ]
6  }
7}

데이터 기반 서비스 활성화


스프링 데이터에는 어플리케이션의 API를 정의하는 데 도움을 주는 기능도 존재한다.
스프링 데이터 REST는 스프링 데이터가 생성하는 repository의 REST API를 자동 생성한다.

스프링 데이터 REST 빌드 명세

1implementation 'org.springframework.boot:spring-boot-starter-data-rest:2.3.9.RELEASE'

dependency를 추가함으로써 모든 repository의 REST API를 노출시킬 수 있다.

홈 리소스 내역 확인

스프링 데이터 REST dependency만 추가해도 REST API를 자동 생성할 수 있다.

1$ curl http://localhost:8080/api/

해당 명령어를 통해 노출된 모든 엔드포인트의 링크를 가지는 홈 리소스 내역을 확인할 수 있다.

리소스 경로 커스터마이징

스프링 데이터 REST의 문제점 중의 하나는 클래스 이름의 복수형을 사용한다는 점에 있다.
리소스 경로를 변경하기 위해서는 다음과 같이 설정한다.

1@Data
2@Entity
3@RestResource(rel="tacos", path="tacos")
4public class Taco {
5  ...
6}

@RestResource 어노테이션을 통해 tacoes로 자동 설정되었던 엔드포인트를 /api/tacos로 변경해주었다.

페이징, 정렬 처리

홈 리소스의 모든 링크는 page, size, sort를 제공한다.
기본적으로 한 페이지 당 20개의 항목을 반환하는데, page, size 매개변수를 통해 페이지 번호와 크기를 조절할 수 있다.

1$ curl localhost:8080/api/tacos?sort=createdAt,desc&page=0&size=12

createdAt 속성 값을 기준으로 내림차순으로 정렬하고 첫번째 페이지에 12개의 taco가 나타나도록 설정하는 예시이다.

커스텀 엔드포인트 추가하기

커스텀 엔드포인트를 추가하기 위해서는 두가지 사항을 고려하여 작성해야 한다.

  1. 스프링 데이터 REST 기본 경로를 포함하여 원하는 기본 경로가 앞에 붙도록 매핑시켜야 한다.
  2. 커스텀한 엔드포인트는 스프링 데이터 REST 엔드포인트에서 반환되는 리소스의 하이퍼링크에 포함되지 않는다.

스프링 데이터 REST 기본 경로 컨트롤러에 적용

 1@RepositoryRestController
 2public class RecentTacosController {
 3
 4  private TacoRepository tacoRepo;
 5
 6  public RecentTacosController(TacoRepository tacoRepo) {
 7    this.tacoRepo = tacoRepo;
 8  }
 9
10  @GetMapping(path="/tacos/recent", produces="application/hal+json")
11  public ResponseEntity<RepresentationModel<TacoModel>> recentTacos() {
12    PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
13    List<Taco> tacos = tacoRepo.findAll(page).getContent();
14
15    CollectionModel<TacoModel> tacoCollection = new TacoResourceAssembler().toCollectionModel(tacos);
16    CollectionModel<TacoModel> recentCollection = new CollectionModel<TacoModel>(tacoCollection);
17    
18    recentResources.add(linkTo(methodOn(RecentTacosController.class).recentTacos())
19                                .withRel("recents"));
20    return new ResponseEntity(recentCollection, HttpStatus.OK);
21  }
22}

스프링 데이터 REST의 기본 경로를 request mapping에 적용하기 위해 @RepositoryRestController를 사용하였다.
즉 @GetMapping은 /tacos/recent 경로로 매핑되지만 @RepositoryRestController를 통해 recentTacos() 메서드는 /api/tacos/recent의 GET 요청을 처리하게 된다.

커스텀 하이퍼링크를 스프링 데이터 엔드포인트에 추가하기

위에서 커스텀한 /api/tacos/recent는 스프링 데이터 엔드포인트(/api/tacos)를 요청할 때 하이퍼링크 리스트에 나타나지 않는다.
이를 해결하기 위해 RepresentationModelProcessor 를 사용한다.

 1@Bean
 2public RepresentationModelProcessor<PagedModel<EntityModel<Taco>>>
 3  tacoProcessor(EntityLinks links) {
 4
 5  return new RepresentationModelProcessor<PagedModel<EntityModel<Taco>>>() {
 6    @Override
 7    public PagedModel<EntityModel<Taco>> process(PagedModel<EntityModel<Taco>> resource) {
 8      resource.add(links.linkFor(Taco.class)
 9                        .slash("recent")
10                        .withRel("recents"));
11      return resource;
12    }
13  };
14}

해당 코드를 통해 스프링 HATEOAS는 자동으로 빈을 찾은 후 해당되는 리소스에 적용한다.
여기서는 /api/tacos 요청 응답에 /api/tacos/recent 링크를 포함하게 된다.

Posts in this Series