지난 포스팅에는 EntityModel<T>
과, CollectionModel<EntityModel<T>>
를 이용하여 RESTful
스럽게 객체 정보와 관련 link들을 함께 응답하는 것을 해보았다. 거기에 코드 중복을 줄이기위해 RepresentationModelAssembler
를 상속하여 하나의 클래스로 만들어 사용하도록 코드를 수정하였다.
이번 시간에는 코드를 좀 더 발전시켜보자.
그 전에, RESTful API는 애플리케이션의 복원력을 향상 시키기 위한 아키텍쳐 제약 조건이다.
복원력의 핵심 요소는 서비스를 업그레이드할 때 클라이언트가 downtime으로 어려움을 받지 않는 것이다.
downtime: 시스템을 이용할 수 없는 시간을 일컫는다. 이용 불가능의 의미는 시스템이 오프라인이거나 사용할 수 없는 상황에 놓이는 상태를 가리킨다. 이 용어는 일반적으로 네트워크와 서버에 적용된다.
출처: 위키백과 - 다운타임
즉, 서비스를 업그레이드 할 때 클라이언트가 downtime으로 피해를 입지 않도록 RESTful하게 코드를 작성하는 것이다.
좀 더 구체적으로 이를 코드에 대입시켜보자.
만약 지금까지 개발해온 시스템이 정상적으로 배포 및 운영이 되고 있다고 가정해보자. 이 상황에서 Employee
에 firstName
과 lastName
을 추가해야한다고 생각해보자.
아래와 같은 결과가 반환되는 것이 이상적인 결과일 것이다.
{
"id": 1,
"firstName": "Kim",
"lastName": "Dongho",
"role": "Student",
"name": "Kim",
"_links": {
"self": {
"href": "http://localhost:8080/employees/1"
},
"employees": {
"href": "http://localhost:8080/employees"
}
}
}
그저 firstName
과 lastName
을 정보에 추가했을 뿐이다.
이렇게 되면, 기존 고객은 필요한 정보들을 그대로 사용할 수 있으며 신규 고객은 새로운 정보도 이용할 수 있게 된다. 물론 기존 고객 또한 새로운 정보들을 새로이 이용할 수 있다.
그렇다면 어떻게 코드로 구현할 수 있을까?
그냥 Employee.java
에 아래 필드와 메소드를 추가해주자.
다만 name
필드는 firstName
과 lastName
의 조합으로 get
or set
할 수 있으니 지워주자.
private String firstName;
private String lastName;
public String getName() {
return this.firstName + " " + this.lastName;
}
public void setName(String name) {
String[] parts = name.split(" ");
this.firstName = parts[0];
this.lastName = parts[1];
}
또 다른 업그레이드는, 각 REST 메소드들의 return이 적절한 반응을 하는 것이다.
다음과 같이 @POST
메소드를 바꿔주자.
@PostMapping("/employees")
public ResponseEntity<?> newEmployee(@RequestBody Employee newEmployee) {
EntityModel<Employee> entityModel = assembler.toModel(repository.save(newEmployee));
return ResponseEntity.created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()).body(entityModel);
}
기존 @POST
메소드와 마찬가지로 새로운 Employee
객체가 저장되지만, 그 결과 객체는 EmployeeModelAssembler
로 감싸져 반환된다.
Spring MVC의 ResponseEntity
는 HTTP 201 Created 상태 메시지를 만드는 데 사용된다.
이러한 유형의 응답은 일반적으로 location
응답 헤더와, 모델의 자체 관련 링크에서 파생된 URI를 사용한다.
추가적으로, 저장된 객체의 model 기반 버전을 반환한다.
위는 기존 객체만을 반환하는 코드의 결과이다.
아래는 ResponseEntity<?>
로서 반환하는 코드의 결과이다.
기존 객체 정보와 관련 링크들, 그리고 기존 name
필드 정보까지 반환하는 것을 확인할 수 있다.
그리고 응답 Header - Location 필드를 보면 실제로 http://localhost:8080/employees/3
으로 값이 담겨져 반환되는 것을 확인할 수 있다.
이제는 @PUT
메소드도 수정해보자.
@PutMapping("/employees/{id}")
public ResponseEntity<?> replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) {
Employee updatedEmployee = repository.findById(id).map(employee -> {
employee.setName(newEmployee.getName());
employee.setRole(newEmployee.getRole());
return repository.save(employee);
}).orElseGet(() -> {
newEmployee.setId(id);
return repository.save(newEmployee);
});
EntityModel<Employee> entityModel = assembler.toModel(updatedEmployee);
return ResponseEntity.created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()).body(entityModel);
}
@PUT
도 마찬가지로 업데이트하는 로직은 같지만, ResponseEntity<?>
으로 반환한다.
getRequiredLink()
메소드를 이용하여 EmployeeModelAssembler
에 의해 작성된 링크를 SELF
로 검색할 수 있게 된다.
이 메소드는 toUri()
메소드를 통해 URI로 변환해야 한다.
따라서, 200 OK 보다 더 상세한 응답 코드를 사용하기 위해서는 Spring MVC의
ResponseEntity
를 사용하자.
이제 마지막으로 @DELETE
를 수정해보자.
@DeleteMapping("/employees/{id}")
public ResponseEntity<?> deleteEmployee(@PathVariable Long id) {
repository.findById(id).orElseThrow(() -> new EmployeeNotFoundException(id));
repository.deleteById(id);
return ResponseEntity.noContent().build();
}
여기까지는 API 기초 골격을 구축했다. 여기서 더 API를 확장하고 클라이언트에게 더 나은 서비스를 제공하려면 Hypermedia
라는 개념을 애플리케이션 상태의 엔진으로 수용해야한다.
자 이제, 클라이언트에게 영향을 끼치지 않고 상태 변화에 대처하는 방법을 보여주기 위해 주문 시스템을 추가해보자.
\<Order> 엔티티
package com.example.Payroll;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
@Entity @Getter @Setter @ToString @EqualsAndHashCode
@Table(name = "CUSTOMER_ORDER")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String decription;
private Status status;
public Order() {}
public Order(String decription, Status status) {
this.decription = decription;
this.status = status;
}
}
\<Status> 열거형
package com.example.Payroll;
public enum Status {
IN_PROGRESS, COMPLETED, CANCELLED
}
\<OrderRepository> Order 레포지토리
package com.example.Payroll;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {
}
\<OrderController> Order 컨트롤러
package com.example.Payroll;
import lombok.RequiredArgsConstructor;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderRepository orderRepository;
private final OrderModelAssembler assembler;
@GetMapping("/orders")
public CollectionModel<EntityModel<Order>> all() {
List<EntityModel<Order>> orders = orderRepository.findAll().stream()
.map(assembler::toModel)
.collect(Collectors.toList());
return CollectionModel.of(orders,
linkTo(methodOn(OrderController.class).all()).withSelfRel());
}
@GetMapping("/orders/{id}")
public EntityModel<Order> one(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(new OrderNotFoundException(id));
return assembler.toModel(order);
}
@PostMapping("/orders")
ResponseEntity<EntityModel<Order>> newOrder(@RequestBody Order order) {
order.setStatus(Status.IN_PROGRESS);
Order newOrder = orderRepository.save(order);
return ResponseEntity.created(linkTo(methodOn(OrderController.class).one(newOrder.getId())).toUri())
.body(assembler.toModel(newOrder));
}
}
지금까지 했던 코드와 동일한 패턴이다.
거기에 만약 Status
가 IN_PROGRESS
이면 CANCLE
과 COMPLETED
와 관련된 링크도 함께 주도록 하자.
package com.example.Payroll;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
@Component
public class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<Order>> {
@Override
public EntityModel<Order> toModel(Order order) {
EntityModel<Order> orderModel = EntityModel.of(order,
linkTo(methodOn(OrderController.class).one(order.getId())).withSelfRel(),
linkTo(methodOn(OrderController.class).all()).withRel("orders"));
if (order.getStatus() == Status.IN_PROGRESS) {
orderModel.add(linkTo(methodOn(OrderController.class).cancel(order.getId())).withRel("cancel"));
orderModel.add(linkTo(methodOn(OrderController.class).complete(order.getId())).withRel("complete"));
}
return orderModel;
}
}
@DeleteMapping("/orders/{id}/cancel")
public ResponseEntity<?> cancel(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
if (order.getStatus() == Status.IN_PROGRESS) {
order.setStatus(Status.CANCELLED);
return ResponseEntity.ok(assembler.toModel(orderRepository.save(order)));
}
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.header(HttpHeaders.CONTENT_TYPE, MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE)
.body(Problem.create()
.withTitle("Method not allowed")
.withDetail("You can't cancel an order that is in the " + order.getStatus() + " status"));
}
@PutMapping("/orders/{id}/complete")
ResponseEntity<?> complete(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
if (order.getStatus() == Status.IN_PROGRESS) {
order.setStatus(Status.COMPLETED);
return ResponseEntity.ok(assembler.toModel(orderRepository.save(order)));
}
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.header(HttpHeaders.CONTENT_TYPE, MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE)
.body(Problem.create()
.withTitle("Method not allowed")
.withDetail("You can't complete an order that is in the " + order.getStatus() + " status"));
}
이제 모든 LoadDatabase
코드를 조금 수정하고 테스트해보자.
@Bean
CommandLineRunner initDatabase(EmployeeRepository employeeRepository, OrderRepository orderRepository) {
return args -> {
employeeRepository.save(new Employee("Kim", "Dongho", "Student"));
employeeRepository.save(new Employee("Hong", "Gilodng", "hero"));
employeeRepository.findAll().forEach(employee -> log.info("Preloaded " + employee));
orderRepository.save(new Order("MacBook Pro", Status.COMPLETED));
orderRepository.save(new Order("iPhone", Status.IN_PROGRESS));
orderRepository.findAll().forEach(order -> log.info("Preloaded " + order));
};
}
모두 잘 동작하는 것을 확인할 수 있다.
이번 Building REST service with SPRING을 실습 후 느낀 것이 있다.
RESTful이라는 것은 단지 URI 설계와 JSON을 반환하는 것만이 아니라는 것을 배웠다.
대신에 해당 객체의 정보에 덧붙여 관련 link들을 같이 JSON 형식으로 반환해주는 것이 RESTful이다.
이렇게 함으로써, 서비스를 업그레이드 해나가도 링크 체계만 유지된다면 유지보수가 쉽고, 클라이언트와 서버간의 의존성을 낮출 수 있다는 것을 배웠다.
아직 와닿지는 않지만 알아두면 두고두고 도움이 될 것 같다.
그리고 또 느낀 점은, 프로젝트를 개발할 때 ERD를 설계 후 엔티티를 개발한다. 그리고 각 엔티티 별 repo와 service를 개발 후 API를 개발하는 단계에서 해당 글을 참고하여 각 메소드 별로 구현해보고 싶다는 생각이 들었다.
이번 카카오 같이가치 웹 클론 코딩 프로젝트가 끝난 후 리팩토링이 있을 예정인데, 그때 이러한 방법으로 리팩터링을 하거나. 아예 이러한 패턴을 참조하여 개발을 진행 해봐야겠다.