상세 컨텐츠

본문 제목

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

Development Study/개발 관련 도서

by yooputer 2025. 2. 3. 08:58

본문

비동기 API 설계

리액티브 스트림 이해하기

  • 일반적으로 자바에서는 비동기성을 구현하기 위해 쓰레드 풀 사용. 한 요청에 한 스레드 할당
  • 쓰레드는 비용이 들고 한정적이므로 비동기방식을 통해 블로킹을 줄여야함. 
  • 자바스크립트와 같은 콜백 유틸리티를 사용하여 비동기식 호출을 구현한다. 
  • 리액티브 스트림은 데이터 소스인 발행자(publisher)가 구독자(subscriber)에게 데이터를 푸시하는 발행자-구독자 모델이다. 
  • 리액티브 API는 이벤트 루프 설계를 기반으로 한 푸시 스타일 알림을 사용한다. 
  • 리액티브 스트림에서 데이터 스트림은 비동기적이고 논블로킹이며 백프레셔를 지원한다. 

발행자(Publisher)

  • 발행자는 한 명 이상의 구독자에게 데이터 스트림을 제공한다. 
  • 구독자는 subscriber()메서드를 사용해 발행자를 구독한다
  • 각 구독자는 발행자를 한번만 구독할 수 있다. 
  • 리액티브 스트림은 lazy하여 구독자가 있는 경우에만 데이터를 푸시한다. 
public insterface Publisher<T>{
	public void subscribe(Subscriber<? super T> s);
}

발행자와 구독자 사이의 통신 방법

  1. Subscriber 인스턴스가 Publisher.subscribe() 메소드에 전달되면 onSubscribe() 메소드를 트리거한다. 
    이때 구독자가 발행자에게 요구하는 데이터의 양을 제어하는 Subscription 매개변수를 포함한다. 
    이러한 방식을 백프레셔라고 부른다. 
  2. Publisher는 Subscription.request(long) 호출을 기다린 후 해당 메서드가 호출되면 데이터를 Subscriber에게 푸시한다. 
  3. 요청이 이루어지면 Publisher는 데이터 알림을 보내고 데이터를 소비하기 위해 onNext()메소드가 사용된다. 
  4. 마지막으로 onError() 또는 onComplete()이 끝내는 상태로 트리거된다. 

구독자(Subscriber)

public interface Subscriber<T> {
	public void onSubscribe(Subscription s);
    public void onNext(T t);
    public void onError(Throwable t);
    public void onComplete();
}

구독(Subscription)

  • 발행자와 구독자 사이에 중재자(mediator)
public interface Subscription{
	public void request(long n);
    public void cancel();
}

 

프로세서(Processor)

  • 발행자와 구독자 사이에서 다리 역할을 하며 프로세싱 단계를 나타낸다
  • 발행자와 구독자 모두에게 작동하며 각 인터페이스에서 정의한 컨트랙트를 따른다
public interface Process<T, R> extends Subscriber<T>, Publisher<R> {
}

스프링 웹플럭스 살펴보기

스프링 웹플럭스

  • 스프링 MVC 프레임워크는 서블릿 API와 서블릿 컨테이너를 기반으로 하는데,
    서블릿은 비동기성을 지원하며 논-블로킹 I/O 스트림 API를 제공하지만 완전히 논-블로킹은 아니다. 
  • 그래서 스프링은 완전히 논-블로킹이며 백프레셔 기능을 제공하는 스프링 웹플럭스를 제공한다
  • 웹플럭스는 적은 수의 스레드와 동시성을 제공하고 더 적은 하드웨어 리소스로도 확장된다
  • 스프링 웹플럭스와 스프링MVC는 공존할 수 있으나, 리액티브 프로그래밍 모델의 효과적인 사용을 위해 블로킹 호출과 리액티브 흐름을 섞어서는 안된다

스프링 웹플럭스가 지원하는 아키타입(archetype)

  • 이벤트 루프 동시성 모델
  • 애노테이션이 달린 컨트롤러와 함수형 엔드포인트
  • 리액티브 클라이언트
  • Tomcat, Undertow 및 Jetty와 같은 Netty 및 서블릿 3.1 컨테이너 기반 웹서버

리액티브 API의 이해

  • Mono와 Flux 두가지 발행자 구현을 제공한다. 
  • Mono는 구독자에게 0 또는 1개의 요소를 반환할 수 있고 Flux는 0에서 N까지의 요소를 반환한다. 
  • 스트림은 소스가 재시작될 수 있는지 여부에 따라 핫스트림과 콜드 스트림으로 분류된다
  • 프로젝트 리액터의 스트림은 기본적으로 콜드다. 따라서 스트림을 소비하면 다시 시작할 때까지 재사용할 수 없다. 
  • cache() 메소드를 사용하면 콜드 스트림을 핫스트림으로 전환할 수 있다

리액티브 코어

  • 웹 애플리케이션은 HTTP 웹 요청을 처리하기 위해 보통 세가지 수준의 지원을 필요로 한다.
  1. 서버에 의한 웹 요청 처리
    • HttpHandler : 다양한 HTTP 서버 API 위에 요청/응답 핸들러의 추상화를 제공
    • WebHandler : 사용자 세션, 요청 및 세션 속성, 요청에 대한 로케일과 주체, 폼 데이터 등을 지원한다
  2. WebClient를 이용한 클라이언트의 웹 요청 처리
  3. 요청 및 응답에 대해 서버 및 클라이언트 수준 모두에서 콘텐츠 직렬화 및 역직렬화를 하기 위한 코덱
    • Encoder, Decoder, HttpMessageWriter, HttpMessageReader, DataBuffer
  • 기본 서버가 Netty와 같은 리액티브 스트림을 지원하는 경우 기본적으로 구독은 서버에서 수행된다. 
    그렇지 않으면 웹플럭스는 서블릿 3.1 컨테이너 기반 서버에 ServletHttpHandlerAdapter를 사용한다. 

DispatcherHandler 이해하기

DispatcherHandler의 요청 처리 순서

  1. DispatcherHandler가 웹 요청을 받는다
  2. DispatcherHandler는 HandlerMapping을 사용해 요청에 일치하는 핸들러를 찾고, 첫번째로 일치하는 것을 사용한다
  3. HandlerAdapter를 사용해 요청을 처리하고 HandlerResult를 노출한다
    반환값은 ResponseEntity, ServerResponse, @RestController에서 반환된 값, 뷰 리졸버가 반환한 값 중 하나이다. 
  4. HandlerResultHandler를 사용해 2단계에서 수신한 HandlerResult 기반으로 Response를 작성하거나 뷰를 렌더링한다. 
  5. 요청을 완료한다. 

컨트롤러

  • 스프링MVC와 스프링 웹플럭스에 동일한 애노테이션을 사용할 수 있다. 

함수형 엔드포인트

  • 스프링 웹플럭스는 함수형 엔드포인트를 사용하여 REST 엔드포인트를 정의하는 대체 방법을 허용한다. 
RaouterFunction<ServerResponse> route = route()
	.GET("/v1/api/orders/{id}", accept(APPLICATION_JSON), handler::getOrderById)
    .POST("/v1/api/orders", handler::addOrder)
    .build();

전자 상거래 앱용 리액티브 API 구현

api/config.json 수정

  • 리액티브 API는 library로 spring-boot를 선택한 경우에만 지원된다
{"library": "spring-boot",
	...
	"reactive":true, 
	...
}

API 인터페이스 코드 생성

  • gradlew clean generateSwaggerCode

build.gradle 수정

  • spring-boot-starter-web 의존성 제거
  • spring-boot-starter-webflux 의존성 추가
  • reactor-test 의존성 추가
  • spring-boot-starter-data-r2dbc 의존성 추가(작성 당시에는 Hibernate Reactive가 H2를 지원하지 않아서 Spring Data 사용)
  • H2 의존성 추가

예외처리

단순 예외 반환

return mono.error(() -> new RuntimeException("Error Msg"));

DB 반환 결과가 empty한 경우

Mono<List<String>> ids = itemRepo.findById(id)
	.switchIfEmpty(Mono.error(new ResourceNotFoundException("Error Msg")))
    .map(i -> i.getId())
    .collectList();

예외가 발생한 경우 반환값 설정

return service.getCartByCustomId(id)
	.onErrorReturn(notFound().build())

컨트롤러에 대한 전역 예외 처리

  • DefaultErrorAttributes를 상속하는 ApiErrorAttributes 클래스 정의
@Component
public class ApiErrorAttributes extends DefaultErrorAttributes {

  private HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
  private String message = ErrorCode.GENERIC_ERROR.getErrMsgKey();

  @Override
  public Map<String, Object> getErrorAttributes(ServerRequest request,
      ErrorAttributeOptions options) {
    var attributes = super.getErrorAttributes(request, options);
    attributes.put("status", status);
    attributes.put("message", message);
    attributes.put("code", ErrorCode.GENERIC_ERROR.getErrCode());
    return attributes;
  }

  public HttpStatus getStatus() {
    return status;
  }

  public ApiErrorAttributes setStatus(HttpStatus status) {
    this.status = status;
    return this;
  }

  public String getMessage() {
    return message;
  }

  public ApiErrorAttributes setMessage(String message) {
    this.message = message;
    return this;
  }
}

 

  • AbstractErrorWebExceptionHandler를 상속하는 ApiErrorWebExceptionHandler 클래스 정의
  • ResponseStatusExceptionHandler는 우선순위가 0이고 DefaultErrorWebExceptionHandler는 -1이다. 
    이 두가지보다 우선순위를 지정하지 않으면 실행되지 않으므로 @Order(-2) 어노테이션으로 우선순위를 -2로 설정한다
@Component
@Order(-2)
public class ApiErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {

  public ApiErrorWebExceptionHandler(ApiErrorAttributes errorAttributes,
      ApplicationContext applicationContext,
      ServerCodecConfigurer serverCodecConfigurer) {
    super(errorAttributes, new WebProperties().getResources(), applicationContext);
    super.setMessageWriters(serverCodecConfigurer.getWriters());
    super.setMessageReaders(serverCodecConfigurer.getReaders());
  }

  @Override
  protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
    return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
  }

  private Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
    
    Map<String, Object> errorPropertiesMap = getErrorAttributes(request,
        ErrorAttributeOptions.defaults());
    
    Throwable throwable = (Throwable) request
        .attribute("org.springframework.boot.web.reactive.error.DefaultErrorAttributes.ERROR")
        .orElseThrow(
            () -> new IllegalStateException("Missing exception attribute in ServerWebExchange"));
    
    throwable.printStackTrace();
    
    ErrorCode errorCode = ErrorCode.GENERIC_ERROR;
    
    if (throwable instanceof IllegalArgumentException
        || throwable instanceof DataIntegrityViolationException
        || throwable instanceof ServerWebInputException) {
      errorCode = ILLEGAL_ARGUMENT_EXCEPTION;
    } else if (throwable instanceof CustomerNotFoundException) {
      errorCode = CUSTOMER_NOT_FOUND;
    }
    ...

    switch (errorCode) {
      case ILLEGAL_ARGUMENT_EXCEPTION -> {
        errorPropertiesMap.put("status", HttpStatus.BAD_REQUEST);
        errorPropertiesMap.put("code", ILLEGAL_ARGUMENT_EXCEPTION.getErrCode());
        errorPropertiesMap.put("error", ILLEGAL_ARGUMENT_EXCEPTION);
        errorPropertiesMap.put("message", String
            .format("%s %s", ILLEGAL_ARGUMENT_EXCEPTION.getErrMsgKey(),
                throwable.getMessage()));
        return ServerResponse.status(HttpStatus.BAD_REQUEST)
            .contentType(MediaType.APPLICATION_JSON)
            .body(BodyInserters.fromValue(errorPropertiesMap));
      }
      ...
      default -> ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
          .contentType(MediaType.APPLICATION_JSON)
          .body(BodyInserters.fromValue(errorPropertiesMap));
    }
    
    return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .contentType(MediaType.APPLICATION_JSON)
        .body(BodyInserters.fromValue(errorPropertiesMap));
  }
}

API 응답에 하이퍼미디어 링크 추가

HateoasSupport 인터페이스 구현

public interface HateoasSupport {
    default UriComponentsBuilder getUriComponentBuilder(@Nullable ServerWebExchange exchange) {
      if (exchange == null) {
        return UriComponentsBuilder.fromPath("/");
      }

      ServerHttpRequest request = exchange.getRequest();
      PathContainer contextPath = request.getPath().contextPath();

      return UriComponentsBuilder.fromHttpRequest(request)
          .replacePath(contextPath.toString())
          .replaceQuery("");
    }
}

 

ReactiveRepresentationModelAssembler를 구현하는 클래스 구현

@Component
public class AddressRepresentationModelAssembler implements
    ReactiveRepresentationModelAssembler<AddressEntity, Address>, HateoasSupport {

  	private static String serverUri = null;
 	
    private String getServerUri(@Nullable ServerWebExchange exchange) {
        if (Strings.isBlank(serverUri)) {
          serverUri = getUriComponentBuilder(exchange).toUriString();
        }
   	 	return serverUri;
    }
    
    
    @Override
    public Mono<Address> toModel(AddressEntity entity, ServerWebExchange exchange) {
    	return Mono.just(entityToModel(entity, exchange));
    }



    public Address entityToModel(AddressEntity entity, ServerWebExchange exchange) {
        Address resource = new Address();
        if(Objects.isNull(entity)) {
          return resource;
        }
        BeanUtils.copyProperties(entity, resource);
        resource.setId(entity.getId().toString());
        String serverUri = getServerUri(exchange);
        resource.add(Link.of(String.format("%s/api/v1/addresses", serverUri)).withRel("addresses"));
        resource.add(
            Link.of(String.format("%s/api/v1/addresses/%s", serverUri, entity.getId())).withSelfRel());
        return resource;
    }

    public Address getModel(Mono<Address> m) {
        AtomicReference<Address> model = new AtomicReference<>();
        m.cache().subscribe(i -> model.set(i));
        return model.get();
    }

    public Flux<Address> toListModel(Flux<AddressEntity> entities, ServerWebExchange exchange) {
        if (Objects.isNull(entities)) {
          return Flux.empty();
        }
        return Flux.from(entities.map(e -> entityToModel(e, exchange)));
    }
}

 

엔티티 정의

  • SpringData 애노테이션을 사용하여 엔티티 정의
@Table("ecomm.orders")
public class OrderEntity {
  @Id
  @Column("id")
  private UUID id;
  @Column("customer_id")
  private UUID customerId;
  @Column("address_id")
  private UUID addressId;
  @Column("card_id")
  private UUID cardId;
  @Column("order_date")
  private Timestamp orderDate;
  @Column("total")
  private BigDecimal total;
  @Column("payment_id")
  private UUID paymentId;
  @Column("shipment_id")
  private UUID shipmentId;
  @Column("status")
  private StatusEnum status;
  private UUID cartId;
  private UserEntity userEntity;
  private AddressEntity addressEntity;
  private PaymentEntity paymentEntity;
  private List<ShipmentEntity> shipments = new ArrayList<>();
  private CardEntity cardEntity;
  private List<ItemEntity> items = new ArrayList<>();
  private AuthorizationEntity authorizationEntity;
}

 

리포지토리 추가

  • 스프링 데이터 R2DBC는 ReactiveCrudRepository, ReactiveSortingRepository, RxJava2CrudRepository, RxJava3CrudRepository와 같은 Reactor 및 RxJava를 위한 다양한 리포지토리를 제공한다. 
@Repository
public interface OrderRepository extends ReactiveCrudRepository<OrderEntity, UUID>,
    OrderRepositoryExt {

  @Query("select o.* from ecomm.orders o join ecomm.\"user\" u on o.customer_id = u.id where u.id = :custId")
  Flux<OrderEntity> findByCustomerId(UUID custId);
}
public interface OrderRepositoryExt {
	Mono<OrderEntity> insert(Mono<NewOrder> m);
	Mono<OrderEntity> updateMapping(OrderEntity orderEntity);
}
@Repository
public class OrderRepositoryExtImpl implements OrderRepositoryExt {
    private ConnectionFactory connectionFactory;
    private DatabaseClient dbClient;
    private ItemRepository itemRepo;
    private CartRepository cartRepo;
    private OrderItemRepository oiRepo;

    private OrderEntity toEntity(NewOrder order, CartEntity c) {
        OrderEntity orderEntity = new OrderEntity();
        BeanUtils.copyProperties(order, orderEntity);
        orderEntity.setUserEntity(c.getUser());
        orderEntity.setCartId(c.getId());
        orderEntity.setItems(c.getItems())
            .setCustomerId(UUID.fromString(order.getCustomerId()))
            .setAddressId(UUID.fromString(order.getAddress().getId()))
            .setOrderDate(Timestamp.from(Instant.now()))
            .setTotal(c.getItems().stream().collect(Collectors.toMap(k -> k.getProductId(),
                v -> BigDecimal.valueOf(v.getQuantity()).multiply(v.getPrice())))
                .values().stream().reduce(BigDecimal::add).orElse(BigDecimal.ZERO));
        return orderEntity;
    }

    @Override
    public Mono<OrderEntity> insert(Mono<NewOrder> mdl) {
        R2dbcEntityTemplate template = new R2dbcEntityTemplate(connectionFactory);
        AtomicReference<UUID> orderId = new AtomicReference<>();
        Mono<List<ItemEntity>> itemEntities = mdl
            .flatMap(m -> itemRepo.findByCustomerId(UUID.fromString(m.getCustomerId())).collectList().cache());
        Mono<CartEntity> cartEntity = mdl
            .flatMap(m -> cartRepo.findByCustomerId(UUID.fromString(m.getCustomerId()))).cache();
        cartEntity = Mono.zip(cartEntity, itemEntities, (c, i) -> {
          if (i.size() < 1) {
            throw new ResourceNotFoundException(String
                .format("There is no item found in customer's (ID:%s) cart.", c.getUser().getId()));
          }
          return c.setItems(i);
        }).cache();
        Mono<OrderEntity> orderEntity = Mono.zip(mdl, cartEntity, (m, c) -> toEntity(m, c)).cache();
        return orderEntity.flatMap(oe -> dbClient.sql("""
            INSERT INTO ecomm.orders (address_id, card_id, customer_id, order_date, total, status) 
            VALUES($1, $2, $3, $4, $5, $6)
            """)
            .bind("$1", Parameter.fromOrEmpty(oe.getAddressId(), UUID.class))
            .bind("$2", Parameter.fromOrEmpty(oe.getCardId(), UUID.class))
            .bind("$3", Parameter.fromOrEmpty(oe.getCustomerId(), UUID.class))
            .bind("$4",
                OffsetDateTime.ofInstant(oe.getOrderDate().toInstant(), ZoneId.of("Z")).truncatedTo(
                    ChronoUnit.MICROS))
            .bind("$5", oe.getTotal())
            .bind("$6", StatusEnum.CREATED.getValue()).map(new OrderMapper()::apply)
            .one())
            .then(orderEntity.flatMap(x ->
                template.selectOne(
                    query(where("customer_id").is(x.getCustomerId()).and("order_date")
                        .greaterThanOrEquals(
                            OffsetDateTime.ofInstant(x.getOrderDate().toInstant(), ZoneId.of("Z"))
                                .truncatedTo(
                                    ChronoUnit.MICROS))),
                    OrderEntity.class).map(t -> x.setId(t.getId()).setStatus(t.getStatus()))
            ));
    }

    @Override
    public Mono<OrderEntity> updateMapping(OrderEntity orderEntity) {
        return oiRepo.saveAll(orderEntity.getItems().stream().map(i -> new OrderItemEntity()
            .setOrderId(orderEntity.getId()).setItemId(i.getId())).collect(toList()))
            .then(
                itemRepo.deleteCartItemJoinById(
                    orderEntity.getItems().stream().map(i -> i.getId()).collect(toList()),
                    orderEntity.getCartId()).then(Mono.just(orderEntity)));
    }
}

class OrderMapper implements BiFunction<Row, Object, OrderEntity> {
    @Override
    public OrderEntity apply(Row row, Object o) {
        OrderEntity oe = new OrderEntity();
        return oe.setId(row.get("id", UUID.class))
            .setCustomerId(row.get("customer_id", UUID.class))
            .setAddressId(row.get("address_id", UUID.class))
            .setCardId(row.get("card_id", UUID.class))
            .setOrderDate(Timestamp.from(
                ZonedDateTime.of((LocalDateTime) row.get("order_date"), ZoneId.of("Z")).toInstant()))
            .setTotal(row.get("total", BigDecimal.class))
            .setPaymentId(row.get("payment_id", UUID.class))
            .setShipmentId(row.get("shipment_id", UUID.class))
            .setStatus(StatusEnum.fromValue(row.get("status", String.class)));
    }
}

 

서비스 추가

public interface OrderService {
  Mono<OrderEntity> addOrder(@Valid Mono<NewOrder> newOrder);
  Mono<OrderEntity> updateMapping(@Valid OrderEntity orderEntity);
  Flux<OrderEntity> getOrdersByCustomerId(@NotNull @Valid String customerId);
  Mono<OrderEntity> getByOrderId(String id);
}
@Service
public class OrderServiceImpl implements OrderService {
  private OrderRepository repository;
  private UserRepository userRepo;
  private AddressRepository addRepo;
  private CardRepository cardRepo;
  private ItemRepository itemRepo;
  private ShipmentRepository shipRepo;
  private BiFunction<OrderEntity, List<ItemEntity>, OrderEntity> biOrderItems = (o, fi) -> o
      .setItems(fi);

  @Override
  public Mono<OrderEntity> addOrder(@Valid Mono<NewOrder> newOrder) {
    return repository.insert(newOrder);
  }

  @Override
  public Mono<OrderEntity> updateMapping(@Valid OrderEntity orderEntity) {
    return repository.updateMapping(orderEntity);
  }

  @Override
  public Flux<OrderEntity> getOrdersByCustomerId(String customerId) {
    return repository.findByCustomerId(UUID.fromString(customerId)).flatMap(order ->
        Mono.just(order)
            .zipWith(userRepo.findById(order.getCustomerId()))
            .map(t -> t.getT1().setUserEntity(t.getT2()))
            .zipWith(addRepo.findById(order.getAddressId()))
            .map(t -> t.getT1().setAddressEntity(t.getT2()))
            .zipWith(cardRepo.findById(order.getCardId() != null ? order.getCardId()
                : UUID.fromString("0a59ba9f-629e-4445-8129-b9bce1985d6a"))
                .defaultIfEmpty(new CardEntity()))
            .map(t -> t.getT1().setCardEntity(t.getT2()))
            .zipWith(itemRepo.findByCustomerId(order.getCustomerId()).collectList(),
                biOrderItems)
    );
  }

  @Override
  public Mono<OrderEntity> getByOrderId(String id) {
    return repository.findById(UUID.fromString(id)).flatMap(order ->
        Mono.just(order)
            .zipWith(userRepo.findById(order.getCustomerId()))
            .map(t -> t.getT1().setUserEntity(t.getT2()))
            .zipWith(addRepo.findById(order.getAddressId()))
            .map(t -> t.getT1().setAddressEntity(t.getT2()))
            .zipWith(cardRepo.findById(order.getCardId()))
            .map(t -> t.getT1().setCardEntity(t.getT2()))
            .zipWith(itemRepo.findByCustomerId(order.getCustomerId()).collectList(),
                biOrderItems)
    );
  }
}

 

컨트롤러 구현 추가

@RestController
public class OrderController implements OrderApi {
  private final OrderRepresentationModelAssembler assembler;
  private OrderService service;

  @Override
  public Mono<ResponseEntity<Order>> addOrder(@Valid Mono<NewOrder> newOrder,
      ServerWebExchange exchange) {
    return service.addOrder(newOrder.cache())
        .zipWhen(x -> service.updateMapping(x))
        .map(t -> status(HttpStatus.CREATED).body(assembler.entityToModel(t.getT2(), exchange)))
        .defaultIfEmpty(notFound().build());
  }

  @Override
  public Mono<ResponseEntity<Flux<Order>>> getOrdersByCustomerId(@NotNull @Valid String customerId,
      ServerWebExchange exchange) {
    return Mono
        .just(ok(assembler.toListModel(service.getOrdersByCustomerId(customerId), exchange)));
  }

  @Override
  public Mono<ResponseEntity<Order>> getByOrderId(String id, ServerWebExchange exchange) {
    return service.getByOrderId(id).map(o -> assembler.entityToModel(o, exchange))
        .map(ResponseEntity::ok)
        .defaultIfEmpty(notFound().build());
  }
}

 

애플리케이션에 H2 콘솔 추가

  • 스프링 웹플럭스에서는 H2 콘솔 앱을 기본적으로 지원하지 않아 직접 bean을 정의하여야 한다
@Component
public class H2ConsoleComponent {
    private final static Logger log = LoggerFactory.getLogger(H2ConsoleComponent.class);
    private Server webServer;

    @Value("${modern.api.h2.console.port:8081}")
    Integer h2ConsolePort;

    @EventListener(ContextRefreshedEvent.class)
    public void start() throws java.sql.SQLException {
      log.info("starting h2 console at port "+h2ConsolePort);
      this.webServer = org.h2.tools.Server.createWebServer("-webPort", h2ConsolePort.toString(), "-tcpAllowOthers").start();
    }

    @EventListener(ContextClosedEvent.class)
    public void stop() {
      log.info("stopping h2 console at port "+h2ConsolePort);
      this.webServer.stop();
    }
}

 

application.properties 설정 추가

  • Flyway는 아직 R2DBC를 지원하지 않아(책 작성 당시) JDBC를 사용한다
  • 스프링 데이터는 R2DBC를 지원하므로 R2DBC 기반 URL을 사용한다
spring.flyway.url=jdbc:h2:file:./data/ecomm;AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1;IGNORECASE=TRUE;DATABASE_TO_UPPER=FALSE
spring.flyway.schemas=ecomm
spring.flyway.user=
spring.flyway.password=

spring.r2dbc.url=r2dbc:h2:file://././data/ecomm?options=AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1;IGNORECASE=TRUE;DATABASE_TO_UPPER=FALSE;;TRUNCATE_LARGE_LENGTH=TRUE;DB_CLOSE_ON_EXIT=FALSE
spring.r2dbc.driver=io.r2dbc:r2dbc-h2
spring.r2dbc.name=
spring.r2dbc.password=

 

관련글 더보기