상세 컨텐츠

본문 제목

소라브 샤르마, 『스프링 6와 스프링 부트 3로 배우는 모던 API 개발』 4장

Development Study/개발 관련 도서

by yooputer 2025. 1. 21. 10:00

본문

API를 위한 비즈니스 로직 작성

HATEOAS

  • Hypermedia As The Engine Of Application State
  • 스프링과 HAL(Hypertext Application Language)를 사용해 구현
  • HAL은 HATEOAS를 구현하기 위한 표준 중 하나
  • 다른 표준으로는 Collection+JSON, JSON-LD가 있다

서비스 설계 개요

  • 4개의 레이어로 이루어진 멀티레이어 아키텍처를 구현할 것이다. 
  • 멀티레이어 아키텍처는 DDD(Domain-Driven Design)로 알려진 아키텍처 스타일의 기본 빌딩 블록이다. 
  • 바텀업 방식으로 구현. 도메인 레이어부터 구현
프레진테이션 레이어 사용자 인터페이스 담당
애플리케이션 레이어 애플리케이션의 전체 흐름을 유지하고 조정.
RESTful 웹 서비스, 비동기 API, gRPC API, GraphQL API는 이 레이어의 일부
도메인 레이어 비즈니스 로직과 도메인 정보 포함. 
서비스와 리포지토리로 구성
인프라 레이어 다른 모든 레이어에 대한 지원 담당 
스프링부트가 인프라 레이어로 작동. 데이터베이스, 메시지 브로커 등과 같은 외부 및 내부 시스템과의 통신 및 상호작용 담당. 

Repository 컴포넌트 추가

@Repository 애노테이션

  • 데이터베이스와 상호작용할 때 사용하는 스프링 컴포넌트
  • DDD의 리포지토리와 자바 EE 패턴인 DAO를 모두 나타내는 범용 스트레오 타입
  • DDD에서 리포지토리는 모든 객체에 대한 참조를 전달하고 요청된 객체의 참조를 반환해야 하는 중심 객체
  • 해당 어노테이션을 사용하기 전 모든 의존성과 설정을 준비하여야 함

의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.flywaydb:flyway-core'
runtimeOnly 'com.h2database:h2'

application.properties에 DB 및 JPA 관련 설정 추가

# 데이터소스 설정
spring.datasource.name=ecomm
spring.datasource.url=jdbc:h2:mem:ecomm;DB_CLOSE_DELAY=-1;IGNORECASE=TRUE;DATABASE_TO_UPPER=false
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# H2 데이터베이스 설정
spring.h2.console.enabled=true
spring.h2.console.settings.web-allow-others=false

# JPA 설정
spring.jpa.properties.hibernate.default_schema=ecomm
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.show-sql=true
spring.jpa.format_sql=true
spring.jpa.generate-ddl=false
spring.jpa.hibernate.ddl-auto=none

# Flyway 설정
spring.flyway.url=jdbc:h2:mem:ecomm
spring.flyway.schemas=ecomm
spring.flyway.user=sa
spring.flyway.password=

 

데이터베이스 및 시드 데이터 스크립트 작성

  • src/main/resources/db/migration 안에 V1.0.0__Init.sql 파일 생성 후 아래 스크립트 작성

https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/src/main/resources/db/migration/V1.0.0__Init.sql

 

Modern-API-Development-with-Spring-6-and-Spring-Boot-3/Chapter04/src/main/resources/db/migration/V1.0.0__Init.sql at main · Pac

Modern API Development with Spring 6 and Spring Boot 3, Published by Packt - PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3

github.com

@Entity

  • Hibernate와 같은 ORM 구현체를 사용해 데이터베이스 테이블에 직접 맵핑

엔티티 추가

@Entity
@Table(name = "cart")
public class CartEntity {

  @Id
  @GeneratedValue
  @Column(name = "ID", updatable = false, nullable = false)
  private UUID id;

  @OneToOne
  @JoinColumn(name = "USER_ID", referencedColumnName = "ID")
  private UserEntity user;

  @ManyToMany(
      cascade = CascadeType.ALL
  )
  @JoinTable(
      name = "CART_ITEM",
      joinColumns = @JoinColumn(name = "CART_ID"),
      inverseJoinColumns = @JoinColumn(name = "ITEM_ID")
  )
  private List<ItemEntity> items = new ArrayList<>();
}
@Entity
@Table(name = "\"user\"")
public class UserEntity {
  @Id
  @GeneratedValue
  @Column(name = "ID", updatable = false, nullable = false)
  private UUID id;

  @NotNull(message = "User name is required.")
  @Basic(optional = false)
  @Column(name = "USERNAME")
  private String username;

  @Column(name = "PASSWORD")
  private String password;

  @Column(name = "FIRST_NAME")
  private String firstName;

  @Column(name = "LAST_NAME")
  private String lastName;

  @Column(name = "EMAIL")
  private String email;

  @Column(name = "PHONE")
  private String phone;

  @Column(name = "USER_STATUS")
  private String userStatus;

  @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
  @JoinTable(
      name = "USER_ADDRESS",
      joinColumns = @JoinColumn(name = "USER_ID"),
      inverseJoinColumns = @JoinColumn(name = "ADDRESS_ID")
  )
  private List<AddressEntity> addresses = new ArrayList<>();

  @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true)
  private List<CardEntity> cards;

  @OneToOne(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true)
  private CartEntity cart;

  @OneToMany(mappedBy = "userEntity", fetch = FetchType.LAZY, orphanRemoval = true)
  private List<OrderEntity> orders;
}

레포지토리 추가

  • @Query 어노테이션을 사용해 JPQR 작성 가능
  • JPQL은 SQL과 유사하나 테이블명 대신 클래스명을 사용한다. 
public interface CartRepository extends CrudRepository<CartEntity, UUID> {
  @Query("select c from CartEntity c join c.user u where u.id = :customerId")
  Optional<CartEntity> findByCustomerId(@Param("customerId") UUID customerId);
}

서비스 컴포넌트 추가

서비스 인터페이스 정의

public interface CartService {
  List<Item> addCartItemsByCustomerId(String customerId, @Valid Item item);
}

서비스 구현 클래스 구현

@Service
public class CartServiceImpl implements CartService {

  private final CartRepository repository;
  private final UserRepository userRepo;
  private final ItemService itemService;

  public CartServiceImpl(CartRepository repository, UserRepository userRepo,
      ItemService itemService) {
    this.repository = repository;
    this.userRepo = userRepo;
    this.itemService = itemService;
  }

  @Override
  public List<Item> addCartItemsByCustomerId(String customerId, @Valid Item item) {
    CartEntity entity = getCartByCustomerId(customerId);
    long count = entity.getItems().stream()
        .filter(i -> i.getProduct().getId().equals(UUID.fromString(item.getId()))).count();
    if (count > 0) {
      throw new GenericAlreadyExistsException(
          String.format("Item with Id (%s) already exists. You can update it.", item.getId()));
    }
    entity.getItems().add(itemService.toEntity(item));
    return itemService.toModelList(repository.save(entity).getItems());
  }
}

하이퍼미디어 구현

하이퍼미디어 정의를 위한 클래스

  • RepresentationModel : 모델/DTO는 이 클래스를 확장하여 링크를 수집할 수 있다
  • EntityModel : RepresentationModel을 확장하고, 그 안에 있는 도메인 객체를 content private 필드로 래핑하여 도메인 모델/DTO 및 링크를 포함한다
  • CollectionModel : RepresentationModel을 확장하고, 모델 컬렉션을 래핑하고 링클르 유지관리하고 저장하는 방법을 제공한다
  • PageModel : CollectionModel을 확장하고, 페이지 조회 메서드 및 반복 메서드를 제공한다. 

RepresentationModel 확장

  • RepresentationModel를 확장하면 getLink(), hasLink(), add() 등의 메서드가 추가된다. 
public class Cart extends RepresentationModel<Cart> implements Serializable {
	// ...
}

Swagger Codegen 설정

  • /resources/api/config.json
{
  ...
  "hateoas": true,
  ...
}

RepresentationModelAssembler 구현

  • RepresentationModelAssemblerSupport extend, toModel 메서드 overriding
@Component
public class CartRepresentationModelAssembler extends
    RepresentationModelAssemblerSupport<CartEntity, Cart> {
  private final ItemService itemService;

  public CartRepresentationModelAssembler(ItemService itemService) {
    super(CartsController.class, Cart.class);
    this.itemService = itemService;
  }

  @Override
  public Cart toModel(CartEntity entity) {
    String uid = Objects.nonNull(entity.getUser()) ? entity.getUser().getId().toString() : null;
    String cid = Objects.nonNull(entity.getId()) ? entity.getId().toString() : null;
    Cart resource = new Cart();
    BeanUtils.copyProperties(entity, resource);
    resource.id(cid).customerId(uid).items(itemService.toModelList(entity.getItems()));
    resource.add(linkTo(methodOn(CartsController.class).getCartByCustomerId(uid)).withSelfRel());
    resource.add(linkTo(methodOn(CartsController.class).getCartItemsByCustomerId(uid))
        .withRel("cart-items"));
    return resource;
  }

  public List<Cart> toListModel(Iterable<CartEntity> entities) {
    if (Objects.isNull(entities)) {
      return List.of();
    }
    return StreamSupport.stream(entities.spliterator(), false).map(this::toModel)
        .collect(toList());
  }
}

다른 컨트롤러에서 링크를 사용해야하는 경우

  • RepresentationModelProcessor를 구현하는 bean 작성, process() overriding
@Override
public Order process(Order model){
	model.add(Link.of("/payments/{orderId}").withRel(
    	LinkRelation.of("payments")).expand(model.getOrderId()));
    return model;
}

서비스와 HATEOAS로 컨트롤러 향상

Controller에 Assembler 영속성 주입

@RestController
public class CartsController implements CartApi {
  // ...
  private final CartRepresentationModelAssembler assembler;

Assembler 사용하여 하이퍼미디어 링크를 포함한 모델 반환

@Override
public ResponseEntity<Cart> getCartByCustomerId(String customerId) {
return ok(assembler.toModel(service.getCartByCustomerId(customerId)));
}

API 응답에 ETag 추가

ETag

  • HTTP 프로토콜 상에서 특정 자원의 버전을 식별하는데 사용되는 캐시 관리 메커니즘
  • 캐시 검증 및 중복 다운로드 방지 등에 사용된다. 
  • 웹페이지의 컨텐츠가 벼경될 때마다 새로운 ETag가 생성된다. 

If-None-Match 헤더를 사용해 요청하기

  • 처음 요청을 보낸 후 ETag값을 If-None-Match 헤더에 담아 동일한 요청을 보낸다. 
  • 만약 데이터에 변경사항이 없으면 200 대신 304가 반환된다

ShallowEtagHeaderFilter를 사용해 ETag 구현

  • 스프링은 응답에 기록된 캐시 콘텐츠에서 MD5해시값을 사용해 컨텐츠 수정 여부를 계산한다
  • 대역폭은 절약되지만 동일한 CPU계산을 사용한 작업이 수행된다. 

CacheControl 클래스를 사용해 ETag 구현

Return ResponseEntity.ok()
	.cacheControl(CacheControl.maxAge(5, TimeUnit.DAYS))
    .eTag(product.getModifiedDateInEpoch())
    .body(product);

API 테스트

  • 프로젝트 실행 후 Insomnia 또는 Postman API 클라이언트에서 아래 파일을 이용해 테스트를 진행한다. 

https://github.com/PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3/blob/main/Chapter04/Chapter04-API-Collection.har

 

Modern-API-Development-with-Spring-6-and-Spring-Boot-3/Chapter04/Chapter04-API-Collection.har at main · PacktPublishing/Modern-

Modern API Development with Spring 6 and Spring Boot 3, Published by Packt - PacktPublishing/Modern-API-Development-with-Spring-6-and-Spring-Boot-3

github.com

 

관련글 더보기